rl-core-front 0.4.0 → 0.5.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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/_services/api/app-error-code.enum.ts +23 -0
  3. package/src/_services/api/schema.d.ts +43 -0
  4. package/src/_services/auth/index.ts +2 -0
  5. package/src/_utils/password.ts +40 -0
  6. package/src/_utils/permission.ts +14 -0
  7. package/src/_utils/storage.ts +64 -0
  8. package/src/_utils/user.ts +19 -0
  9. package/src/components/app-shell.tsx +37 -13
  10. package/src/components/password-field.tsx +49 -22
  11. package/src/components/ui/alert.tsx +34 -5
  12. package/src/components/ui/badge.tsx +2 -2
  13. package/src/components/ui/index.ts +2 -0
  14. package/src/components/ui/password-requirements.tsx +105 -0
  15. package/src/components/ui/segmented-control.tsx +7 -6
  16. package/src/components/ui/simple-select.tsx +74 -0
  17. package/src/components/ui/switch.tsx +3 -1
  18. package/src/components/ui/tabs.tsx +4 -8
  19. package/src/components/ui/track.styles.ts +31 -0
  20. package/src/contexts/color-mode-context.tsx +4 -2
  21. package/src/contexts/i18n-context.tsx +3 -2
  22. package/src/contexts/socket-context.tsx +27 -1
  23. package/src/features/audit/components/audit-diff.tsx +1 -1
  24. package/src/features/login/components/backup-codes-dialog.tsx +4 -4
  25. package/src/features/login/components/login-form.tsx +18 -3
  26. package/src/features/login/components/two-factor-setup.tsx +32 -2
  27. package/src/features/login/hooks/use-login-flow.ts +60 -7
  28. package/src/features/login/login-screen.tsx +17 -4
  29. package/src/features/profile/force-password-change.tsx +18 -3
  30. package/src/features/profile/profile-screen.tsx +8 -1
  31. package/src/features/rbac/panels/groups-panel.tsx +58 -23
  32. package/src/features/rbac/panels/roles-panel.tsx +2 -2
  33. package/src/features/rbac/services/rbac.service.ts +23 -27
  34. package/src/features/recovery/reset-password-screen.tsx +16 -2
  35. package/src/features/users/components/user-form-dialog.tsx +49 -2
  36. package/src/features/users/hooks/use-users.ts +15 -1
  37. package/src/features/users/services/users.service.ts +16 -0
  38. package/src/features/users/users-screen.tsx +17 -12
  39. package/src/hooks/use-column-visibility.ts +13 -12
  40. package/src/hooks/use-countdown.ts +70 -0
  41. package/src/hooks/use-request.ts +21 -2
  42. package/src/i18n/messages/en.ts +20 -1
  43. package/src/i18n/messages/pt.ts +23 -1
  44. package/src/index.ts +3 -0
  45. package/src/styles/core.css +99 -2
  46. package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
  47. package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
  48. package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
  49. package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
  50. package/tailwind-preset.ts +17 -0
@@ -4,7 +4,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
4
4
  import { Camera, ShieldCheck, Trash2 } from "lucide-react";
5
5
  import type { JSX } from "react";
6
6
  import { useEffect, useRef, useState } from "react";
7
- import { useForm } from "react-hook-form";
7
+ import { useForm, useWatch } from "react-hook-form";
8
8
  import * as yup from "yup";
9
9
 
10
10
  import { api } from "#core/_services/api/axios.factory";
@@ -106,8 +106,13 @@ export function ProfileScreen(): JSX.Element {
106
106
  const {
107
107
  register,
108
108
  handleSubmit,
109
+ control,
109
110
  formState: { errors },
110
111
  } = useForm({ resolver: yupResolver(schema) });
112
+ const [newPassword = "", confirm = ""] = useWatch({
113
+ control,
114
+ name: ["newPassword", "confirm"],
115
+ });
111
116
  const onChangePassword = handleSubmit(async (data) => {
112
117
  const res = await password.run(
113
118
  () => authService.changePassword(data.currentPassword, data.newPassword),
@@ -314,6 +319,7 @@ export function ProfileScreen(): JSX.Element {
314
319
  >
315
320
  <PasswordField
316
321
  error={!!errors.newPassword}
322
+ rulesFor={newPassword}
317
323
  {...register("newPassword")}
318
324
  />
319
325
  </Field>
@@ -323,6 +329,7 @@ export function ProfileScreen(): JSX.Element {
323
329
  >
324
330
  <PasswordField
325
331
  error={!!errors.confirm}
332
+ rulesFor={confirm}
326
333
  {...register("confirm")}
327
334
  />
328
335
  </Field>
@@ -5,6 +5,7 @@ import { Pencil, Plus, Trash2 } from "lucide-react";
5
5
  import type { JSX } from "react";
6
6
  import { useEffect, useMemo, useState } from "react";
7
7
 
8
+ import { fullName } from "#core/_utils/user";
8
9
  import {
9
10
  Button,
10
11
  Checkbox,
@@ -20,11 +21,7 @@ import {
20
21
  Input,
21
22
  Label,
22
23
  RowActions,
23
- Select,
24
- SelectContent,
25
- SelectItem,
26
- SelectTrigger,
27
- SelectValue,
24
+ SimpleSelect,
28
25
  } from "#core/components/ui";
29
26
  import { useAuth, useI18n } from "#core/contexts";
30
27
  import { RecordAuditButton } from "#core/features/audit/components";
@@ -33,11 +30,19 @@ import {
33
30
  rbacService,
34
31
  Role,
35
32
  } from "#core/features/rbac/services/rbac.service";
33
+ import type { User } from "#core/features/users/services/users.service";
34
+ import { usersService } from "#core/features/users/services/users.service";
36
35
  import { useConfirm } from "#core/hooks/use-confirm";
37
36
  import { RequestOperation, useRequest } from "#core/hooks/use-request";
38
37
 
39
38
  const TYPES = ["company", "department", "team"];
40
39
 
40
+ /**
41
+ * Quantos candidatos a gerente o `<select>` carrega. Lista longa em combo não
42
+ * se navega — passando disso, o caminho é a busca da tela de usuários.
43
+ */
44
+ const MANAGER_OPTIONS_LIMIT = 100;
45
+
41
46
  export function GroupsPanel(): JSX.Element {
42
47
  const { run, loading } = useRequest();
43
48
  const { t } = useI18n();
@@ -45,22 +50,29 @@ export function GroupsPanel(): JSX.Element {
45
50
  const { hasPermission } = useAuth();
46
51
  const [rows, setRows] = useState<Group[]>([]);
47
52
  const [roles, setRoles] = useState<Role[]>([]);
53
+ const [candidates, setCandidates] = useState<User[]>([]);
48
54
  const [open, setOpen] = useState(false);
49
55
  const [editing, setEditing] = useState<Group | null>(null);
50
56
  const [form, setForm] = useState({
51
57
  name: "",
52
58
  description: "",
53
59
  type: "team",
60
+ managerId: "",
54
61
  roleIds: [] as string[],
55
62
  });
56
63
 
57
64
  const load = async (): Promise<void> => {
58
- const [g, r] = await Promise.all([
65
+ const [g, r, u] = await Promise.all([
59
66
  run(() => rbacService.listGroups()),
60
67
  run(() => rbacService.listRoles()),
68
+ // Quem pode virar gerente. Uma página basta: o `<select>` não é lugar de
69
+ // paginar, e quem tem centenas de candidatos gerencia isto pela busca da
70
+ // tela de usuários, não aqui.
71
+ run(() => usersService.list({ page: 0, limit: MANAGER_OPTIONS_LIMIT })),
61
72
  ]);
62
73
  if (g) {setRows(g);}
63
74
  if (r) {setRoles(r);}
75
+ if (u) {setCandidates(u.items);}
64
76
  };
65
77
  useEffect(() => {
66
78
  void load();
@@ -69,7 +81,13 @@ export function GroupsPanel(): JSX.Element {
69
81
 
70
82
  const openNew = (): void => {
71
83
  setEditing(null);
72
- setForm({ name: "", description: "", type: "team", roleIds: [] });
84
+ setForm({
85
+ name: "",
86
+ description: "",
87
+ type: "team",
88
+ managerId: "",
89
+ roleIds: [],
90
+ });
73
91
  setOpen(true);
74
92
  };
75
93
  const openEdit = (g: Group): void => {
@@ -78,17 +96,21 @@ export function GroupsPanel(): JSX.Element {
78
96
  name: g.name,
79
97
  description: g.description ?? "",
80
98
  type: g.type,
99
+ managerId: g.managerId ?? "",
81
100
  roleIds: g.roles?.map((r) => r.id) ?? [],
82
101
  });
83
102
  setOpen(true);
84
103
  };
85
104
 
86
105
  const save = async (): Promise<void> => {
106
+ // `""` é "sem gerente": vai como null explícito, que é o que o backend
107
+ // entende como remover — omitir manteria o gerente atual.
108
+ const payload = { ...form, managerId: form.managerId || null };
87
109
  const res = editing
88
- ? await run(() => rbacService.updateGroup(editing.id, form), {
110
+ ? await run(() => rbacService.updateGroup(editing.id, payload), {
89
111
  success: RequestOperation.Update,
90
112
  })
91
- : await run(() => rbacService.createGroup(form), {
113
+ : await run(() => rbacService.createGroup(payload), {
92
114
  success: RequestOperation.Create,
93
115
  });
94
116
  if (res) {
@@ -135,6 +157,18 @@ export function GroupsPanel(): JSX.Element {
135
157
  meta: { label: t("users.name") },
136
158
  },
137
159
  { accessorKey: "type", header: "Tipo", meta: { label: "Tipo" } },
160
+ {
161
+ id: "manager",
162
+ header: t("rbac.manager"),
163
+ meta: { label: t("rbac.manager") },
164
+ enableSorting: false,
165
+ // Sem gerente, o escopo `team` não alcança ninguém aqui dentro — e é
166
+ // exatamente isso que o traço precisa deixar visível na listagem.
167
+ cell: ({ row }) => {
168
+ const manager = candidates.find((c) => c.id === row.original.managerId);
169
+ return manager ? fullName(manager) : "—";
170
+ },
171
+ },
138
172
  {
139
173
  accessorKey: "description",
140
174
  header: "Descrição",
@@ -218,22 +252,23 @@ export function GroupsPanel(): JSX.Element {
218
252
  }
219
253
  />
220
254
  </Field>
255
+ <Field label={t("rbac.manager")}>
256
+ <SimpleSelect
257
+ value={form.managerId}
258
+ onChange={(managerId) => setForm({ ...form, managerId })}
259
+ emptyLabel={t("rbac.noManager")}
260
+ options={candidates.map((c) => ({
261
+ value: c.id,
262
+ label: fullName(c),
263
+ }))}
264
+ />
265
+ </Field>
221
266
  <Field label="Tipo">
222
- <Select
267
+ <SimpleSelect
223
268
  value={form.type}
224
- onValueChange={(v) => setForm({ ...form, type: v })}
225
- >
226
- <SelectTrigger>
227
- <SelectValue />
228
- </SelectTrigger>
229
- <SelectContent>
230
- {TYPES.map((ty) => (
231
- <SelectItem key={ty} value={ty}>
232
- {ty}
233
- </SelectItem>
234
- ))}
235
- </SelectContent>
236
- </Select>
269
+ onChange={(type) => setForm({ ...form, type })}
270
+ options={TYPES.map((ty) => ({ value: ty, label: ty }))}
271
+ />
237
272
  </Field>
238
273
  <div className="space-y-2">
239
274
  <Label>Papéis</Label>
@@ -67,7 +67,7 @@ export function RolesPanel(): JSX.Element {
67
67
  setForm({
68
68
  name: role.name,
69
69
  description: role.description ?? "",
70
- permissionIds: role.permissions.map((p) => p.id),
70
+ permissionIds: (role.permissions ?? []).map((p) => p.id),
71
71
  });
72
72
  setOpen(true);
73
73
  };
@@ -156,7 +156,7 @@ export function RolesPanel(): JSX.Element {
156
156
  {role.description}
157
157
  </p>
158
158
  <div className="flex flex-wrap gap-1">
159
- {role.permissions.map((p) => (
159
+ {(role.permissions ?? []).map((p) => (
160
160
  <Badge key={p.id} variant="outline">
161
161
  {p.code}
162
162
  </Badge>
@@ -1,32 +1,26 @@
1
1
  import { api } from "#core/_services/api/axios.factory";
2
+ import type { components } from "#core/_services/api/schema";
2
3
 
3
- export interface Permission {
4
- id: string;
5
- code: string;
6
- description?: string | null;
7
- isActive: boolean;
8
- }
9
- export interface Role {
10
- id: string;
11
- name: string;
12
- description?: string | null;
13
- isActive: boolean;
14
- permissions: Permission[];
15
- }
16
- export interface Group {
17
- id: string;
18
- name: string;
19
- description?: string | null;
20
- type: string;
21
- isActive: boolean;
22
- roles?: Role[];
23
- }
24
- export interface RbacCatalogItem {
25
- id: string;
26
- name: string;
27
- description?: string | null;
28
- isActive: boolean;
29
- }
4
+ /** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
5
+ export type Permission = components["schemas"]["PermissionResponse"];
6
+ export type Role = components["schemas"]["RoleResponse"];
7
+ export type Group = components["schemas"]["GroupResponse"];
8
+ export type Resource = components["schemas"]["ResourceResponse"];
9
+ export type Action = components["schemas"]["ActionResponse"];
10
+ export type Scope = components["schemas"]["ScopeResponse"];
11
+
12
+ /** Conjunto fechado do backend (`groups.type`) — nunca `string` livre. */
13
+ export type GroupType = components["schemas"]["GroupType"];
14
+
15
+ /**
16
+ * Item de qualquer um dos três catálogos, para o `CatalogPanel` que serve aos
17
+ * três.
18
+ *
19
+ * É união, e não um tipo só: os três têm a mesma forma hoje, mas o painel
20
+ * genérico só pode tocar o que os três **continuarem** tendo em comum. No dia
21
+ * em que `Scope` ganhar um campo, o TypeScript cobra em vez de deixar passar.
22
+ */
23
+ export type RbacCatalogItem = Resource | Action | Scope;
30
24
 
31
25
  export const rbacService = {
32
26
  // Roles
@@ -62,6 +56,7 @@ export const rbacService = {
62
56
  name: string;
63
57
  description?: string;
64
58
  type?: string;
59
+ managerId?: string | null;
65
60
  roleIds?: string[];
66
61
  }) => api.post<Group>("/rbac/groups", data).then((r) => r.data),
67
62
  updateGroup: (
@@ -70,6 +65,7 @@ export const rbacService = {
70
65
  name?: string;
71
66
  description?: string;
72
67
  type?: string;
68
+ managerId?: string | null;
73
69
  roleIds?: string[];
74
70
  },
75
71
  ) => api.patch<Group>(`/rbac/groups/${id}`, data).then((r) => r.data),
@@ -4,7 +4,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
4
4
  import { useRouter, useSearchParams } from "next/navigation";
5
5
  import type { JSX } from "react";
6
6
  import { useEffect, useState } from "react";
7
- import { useForm } from "react-hook-form";
7
+ import { useForm, useWatch } from "react-hook-form";
8
8
  import * as yup from "yup";
9
9
 
10
10
  import {
@@ -14,7 +14,13 @@ import {
14
14
  } from "#core/_services/auth";
15
15
  import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
16
16
  import { BrandHeader, PasswordField } from "#core/components";
17
- import { Alert, Button, Card, Field, Spinner } from "#core/components/ui";
17
+ import {
18
+ Alert,
19
+ Button,
20
+ Card,
21
+ Field,
22
+ Spinner,
23
+ } from "#core/components/ui";
18
24
  import { useI18n } from "#core/contexts";
19
25
  import { useRequest } from "#core/hooks/use-request";
20
26
 
@@ -68,10 +74,16 @@ export function ResetPasswordScreen(): JSX.Element {
68
74
  const {
69
75
  register,
70
76
  handleSubmit,
77
+ control,
71
78
  formState: { errors },
72
79
  } = useForm<{ newPassword: string; confirm: string }>({
73
80
  resolver: yupResolver(schema),
74
81
  });
82
+ // Os dois campos numa leitura só — cada um mostra as exigências no foco.
83
+ const [newPassword = "", confirm = ""] = useWatch({
84
+ control,
85
+ name: ["newPassword", "confirm"],
86
+ });
75
87
 
76
88
  const onSubmit = async (data: { newPassword: string; confirm: string }): Promise<void> => {
77
89
  const res = await run(
@@ -138,6 +150,7 @@ export function ResetPasswordScreen(): JSX.Element {
138
150
  >
139
151
  <PasswordField
140
152
  error={!!errors.newPassword}
153
+ rulesFor={newPassword}
141
154
  {...register("newPassword")}
142
155
  />
143
156
  </Field>
@@ -147,6 +160,7 @@ export function ResetPasswordScreen(): JSX.Element {
147
160
  >
148
161
  <PasswordField
149
162
  error={!!errors.confirm}
163
+ rulesFor={confirm}
150
164
  {...register("confirm")}
151
165
  />
152
166
  </Field>
@@ -20,11 +20,13 @@ import {
20
20
  Field,
21
21
  Input,
22
22
  Label,
23
+ SimpleSelect,
23
24
  } from "#core/components/ui";
24
- import { useI18n } from "#core/contexts";
25
+ import { useAuth, useI18n } from "#core/contexts";
25
26
  import { RecordAuditButton } from "#core/features/audit/components";
26
27
  import {
27
28
  CreateUserPayload,
29
+ ManagedGroup,
28
30
  Role,
29
31
  User,
30
32
  } from "#core/features/users/services/users.service";
@@ -33,6 +35,8 @@ interface Props {
33
35
  open: boolean;
34
36
  loading: boolean;
35
37
  roles: Role[];
38
+ /** Grupos que o ator gerencia — usados só quando ele cria pelo escopo `team`. */
39
+ managedGroups?: ManagedGroup[];
36
40
  editing?: User | null;
37
41
  onClose: () => void;
38
42
  onSubmit: (data: CreateUserPayload, id?: string) => Promise<boolean>;
@@ -42,13 +46,24 @@ export function UserFormDialog({
42
46
  open,
43
47
  loading,
44
48
  roles,
49
+ managedGroups = [],
45
50
  editing,
46
51
  onClose,
47
52
  onSubmit,
48
53
  }: Props): JSX.Element {
49
54
  const { t } = useI18n();
55
+ const { hasPermission } = useAuth();
50
56
  const isEdit = !!editing;
51
57
 
58
+ // Papel é coisa de administrador (§ `users:promote:any`). Para o gerente o
59
+ // bloco nem aparece: marcar qualquer papel voltaria 403 do backend, e um
60
+ // campo que só serve para dar erro é pior que campo nenhum.
61
+ const canPromote = hasPermission("users:promote:any");
62
+ // O admin cria sem grupo; o gerente precisa dizer em qual time a pessoa
63
+ // entra, senão ela nasceria fora do alcance dele.
64
+ const mustPickGroup =
65
+ !isEdit && !hasPermission("users:create:any") && managedGroups.length > 0;
66
+
52
67
  const schema = yup.object({
53
68
  name: yup.string().required(t("validation.required")).min(2),
54
69
  lastName: yup.string().optional(),
@@ -58,6 +73,9 @@ export function UserFormDialog({
58
73
  .required(t("validation.required"))
59
74
  .email(t("validation.email")),
60
75
  roleIds: yup.array().of(yup.string()).optional(),
76
+ groupId: mustPickGroup
77
+ ? yup.string().required(t("validation.required"))
78
+ : yup.string().optional(),
61
79
  });
62
80
 
63
81
  const {
@@ -74,6 +92,8 @@ export function UserFormDialog({
74
92
  phone: "",
75
93
  email: "",
76
94
  roleIds: [] as string[],
95
+ // Gerenciando um time só, não há escolha a fazer: já vem marcado.
96
+ groupId: managedGroups.length === 1 ? managedGroups[0].id : "",
77
97
  },
78
98
  });
79
99
 
@@ -85,9 +105,10 @@ export function UserFormDialog({
85
105
  phone: editing?.phone ?? "",
86
106
  email: editing?.email ?? "",
87
107
  roleIds: editing?.roles?.map((r) => r.id) ?? [],
108
+ groupId: managedGroups.length === 1 ? managedGroups[0].id : "",
88
109
  });
89
110
  }
90
- }, [open, editing, reset]);
111
+ }, [open, editing, reset, managedGroups]);
91
112
 
92
113
  const submit = handleSubmit(async (data) => {
93
114
  const payload: CreateUserPayload = {
@@ -97,6 +118,7 @@ export function UserFormDialog({
97
118
  email: data.email,
98
119
  // o schema Yup infere (string | undefined)[]; aqui estreitamos sem cast
99
120
  roleIds: data.roleIds?.filter((id): id is string => !!id),
121
+ groupIds: data.groupId ? [data.groupId] : undefined,
100
122
  };
101
123
  const ok = await onSubmit(payload, editing?.id);
102
124
  if (ok) {onClose();}
@@ -139,6 +161,30 @@ export function UserFormDialog({
139
161
  <Alert variant="info">{t("users.passwordByEmailHint")}</Alert>
140
162
  )}
141
163
 
164
+ {mustPickGroup && (
165
+ <Controller
166
+ name="groupId"
167
+ control={control}
168
+ render={({ field }) => (
169
+ <Field
170
+ label={t("users.team")}
171
+ error={errors.groupId?.message}
172
+ >
173
+ <SimpleSelect
174
+ value={field.value ?? ""}
175
+ onChange={field.onChange}
176
+ placeholder={t("users.selectTeam")}
177
+ options={managedGroups.map((group) => ({
178
+ value: group.id,
179
+ label: group.name,
180
+ }))}
181
+ />
182
+ </Field>
183
+ )}
184
+ />
185
+ )}
186
+
187
+ {canPromote && (
142
188
  <Controller
143
189
  name="roleIds"
144
190
  control={control}
@@ -171,6 +217,7 @@ export function UserFormDialog({
171
217
  );
172
218
  }}
173
219
  />
220
+ )}
174
221
  </div>
175
222
 
176
223
  <DialogFooter className="gap-2 sm:justify-between">
@@ -9,9 +9,10 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
 
12
- import { useI18n } from "#core/contexts";
12
+ import { useAuth, useI18n } from "#core/contexts";
13
13
  import {
14
14
  CreateUserPayload,
15
+ ManagedGroup,
15
16
  Role,
16
17
  UpdateUserPayload,
17
18
  User,
@@ -24,6 +25,8 @@ import { RequestOperation, useRequest } from "#core/hooks/use-request";
24
25
  export interface UseUsersResult {
25
26
  rows: User[];
26
27
  roles: Role[];
28
+ /** Grupos que o próprio usuário gerencia — vazio para quem não gerencia. */
29
+ managedGroups: ManagedGroup[];
27
30
  total: number;
28
31
  page: number;
29
32
  pageSize: number;
@@ -53,11 +56,13 @@ export function useUsers(): UseUsersResult {
53
56
  // spinner da tabela — que não recarrega quando a ação não muda nenhuma coluna.
54
57
  const { run: runAction, loading: acting } = useRequest();
55
58
  const { t } = useI18n();
59
+ const { hasPermission } = useAuth();
56
60
 
57
61
  const list = useListQuery("/users/filter-schema");
58
62
 
59
63
  const [rows, setRows] = useState<User[]>([]);
60
64
  const [roles, setRoles] = useState<Role[]>([]);
65
+ const [managedGroups, setManagedGroups] = useState<ManagedGroup[]>([]);
61
66
  const [total, setTotal] = useState(0);
62
67
 
63
68
  const fetch = useCallback(async () => {
@@ -79,6 +84,14 @@ export function useUsers(): UseUsersResult {
79
84
 
80
85
  useEffect(() => {
81
86
  void run(() => usersService.listRoles()).then((r) => r && setRoles(r));
87
+ // Só para quem cria pelo escopo `team`: quem tem `users:create:any` não
88
+ // escolhe grupo no formulário, e buscar a lista seria uma requisição a
89
+ // mais em toda abertura da tela para nada.
90
+ if (!hasPermission("users:create:any")) {
91
+ void run(() => usersService.listManagedGroups()).then(
92
+ (g) => g && setManagedGroups(g),
93
+ );
94
+ }
82
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
83
96
  }, []);
84
97
 
@@ -164,6 +177,7 @@ export function useUsers(): UseUsersResult {
164
177
  return {
165
178
  rows,
166
179
  roles,
180
+ managedGroups,
167
181
  total,
168
182
  page: list.page,
169
183
  pageSize: list.pageSize,
@@ -10,6 +10,7 @@ import type { components } from "#core/_services/api/schema";
10
10
  export type Role = components["schemas"]["UserRoleResponse"];
11
11
  export type User = components["schemas"]["UserResponse"];
12
12
  export type PaginationMeta = components["schemas"]["PaginationMetaResponse"];
13
+ export type ManagedGroup = components["schemas"]["GroupResponse"];
13
14
 
14
15
  export interface Paginated<T> {
15
16
  items: T[];
@@ -26,6 +27,13 @@ export interface CreateUserPayload {
26
27
  phone?: string;
27
28
  email: string;
28
29
  roleIds?: string[];
30
+ /**
31
+ * Em que grupos o usuário nasce.
32
+ *
33
+ * Obrigatório para quem cria pelo escopo `team` e gerencia mais de um grupo
34
+ * — o backend responde `GROUP_REQUIRED` quando falta.
35
+ */
36
+ groupIds?: string[];
29
37
  }
30
38
 
31
39
  export interface UpdateUserPayload {
@@ -80,4 +88,12 @@ export const usersService = {
80
88
  listRoles() {
81
89
  return api.get<Role[]>("/rbac/roles").then((r) => r.data);
82
90
  },
91
+
92
+ /**
93
+ * Os grupos que o próprio usuário gerencia — é o que o gerente escolhe ao
94
+ * criar alguém. Vem vazio para quem não gerencia nada, inclusive o admin.
95
+ */
96
+ listManagedGroups() {
97
+ return api.get<ManagedGroup[]>("/rbac/groups/managed").then((r) => r.data);
98
+ },
83
99
  };
@@ -19,6 +19,8 @@ import {
19
19
  import type { JSX } from "react";
20
20
  import { useMemo, useState } from "react";
21
21
 
22
+ import { anyOrTeam } from "#core/_utils/permission";
23
+ import { fullName } from "#core/_utils/user";
22
24
  import { Badge, Button, DataTable, DataTableFeatures, RowActions } from "#core/components/ui";
23
25
  import { useAuth, useI18n } from "#core/contexts";
24
26
  import { UserFormDialog } from "#core/features/users/components/user-form-dialog";
@@ -31,18 +33,23 @@ import { useConfirm } from "#core/hooks/use-confirm";
31
33
 
32
34
  export function UsersScreen(): JSX.Element {
33
35
  const u = useUsers();
34
- const { hasPermission } = useAuth();
36
+ const { hasPermission, hasAnyPermission } = useAuth();
35
37
  const { t } = useI18n();
36
38
  const { confirm, confirmDialog } = useConfirm();
37
39
  const [formOpen, setFormOpen] = useState(false);
38
40
  const [editing, setEditing] = useState<User | null>(null);
39
41
 
40
- const canUpdate = hasPermission("users:update:any");
41
- const canReset2fa = hasPermission("users:update:any");
42
- const canReset = hasPermission("users:reset-password:any");
43
- const canDeactivate = hasPermission("users:deactivate:any");
42
+ // `:any` ou `:team` habilitam o mesmo botão: quem só tem `:team` recebe do
43
+ // backend uma lista que já é só a equipe dele, então tudo que ele enxerga
44
+ // aqui é coisa que ele pode mexer. Quem manda no alcance é o servidor — isto
45
+ // decide apenas se o ícone aparece.
46
+ const canUpdate = hasAnyPermission(...anyOrTeam("users:update"));
47
+ const canReset = hasAnyPermission(...anyOrTeam("users:reset-password"));
48
+ // Reset de 2FA anda com o reset de senha: as duas devolvem o acesso a alguém.
49
+ const canReset2fa = canReset;
50
+ const canDeactivate = hasAnyPermission(...anyOrTeam("users:deactivate"));
44
51
  const canDelete = hasPermission("users:delete:any");
45
- const canInvite = hasPermission("users:invite:any");
52
+ const canInvite = hasAnyPermission(...anyOrTeam("users:invite"));
46
53
  const showActions =
47
54
  canUpdate ||
48
55
  canInvite ||
@@ -72,10 +79,7 @@ export function UsersScreen(): JSX.Element {
72
79
  accessorKey: "name",
73
80
  header: t("users.name"),
74
81
  meta: { label: t("users.name") },
75
- cell: ({ row }) =>
76
- [row.original.name, row.original.lastName]
77
- .filter(Boolean)
78
- .join(" "),
82
+ cell: ({ row }) => fullName(row.original),
79
83
  },
80
84
  {
81
85
  accessorKey: "email",
@@ -225,7 +229,7 @@ export function UsersScreen(): JSX.Element {
225
229
  {item.isActive ? (
226
230
  <Ban className="h-4 w-4" />
227
231
  ) : (
228
- <CheckCircle2 className="h-4 w-4 text-success" />
232
+ <CheckCircle2 className="h-4 w-4 text-success-strong" />
229
233
  )}
230
234
  </Button>
231
235
  )}
@@ -307,7 +311,7 @@ export function UsersScreen(): JSX.Element {
307
311
  onSortingChange={onSortingChange}
308
312
  filters={u.filters}
309
313
  toolbar={
310
- hasPermission("users:create:any") && (
314
+ hasAnyPermission(...anyOrTeam("users:create")) && (
311
315
  <Button onClick={openCreate}>
312
316
  <Plus className="h-4 w-4" />
313
317
  {t("users.newUser")}
@@ -319,6 +323,7 @@ export function UsersScreen(): JSX.Element {
319
323
  <UserFormDialog
320
324
  open={formOpen}
321
325
  loading={u.acting}
326
+ managedGroups={u.managedGroups}
322
327
  roles={u.roles}
323
328
  editing={editing}
324
329
  onClose={() => setFormOpen(false)}