rl-core-front 0.3.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 (80) 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 +86 -132
  4. package/src/_services/auth/index.ts +2 -0
  5. package/src/_utils/filter.ts +15 -34
  6. package/src/_utils/password.ts +40 -0
  7. package/src/_utils/permission.ts +14 -0
  8. package/src/_utils/storage.ts +64 -0
  9. package/src/_utils/user.ts +19 -0
  10. package/src/components/app-shell.tsx +85 -11
  11. package/src/components/brand-header.tsx +23 -8
  12. package/src/components/brand-panel.tsx +41 -0
  13. package/src/components/index.ts +1 -0
  14. package/src/components/password-field.tsx +49 -22
  15. package/src/components/ui/alert.tsx +34 -5
  16. package/src/components/ui/badge.tsx +2 -2
  17. package/src/components/ui/button.tsx +5 -0
  18. package/src/components/ui/confirm-dialog.tsx +3 -1
  19. package/src/components/ui/data-table.tsx +13 -4
  20. package/src/components/ui/dialog.tsx +120 -21
  21. package/src/components/ui/filter-sheet.tsx +47 -25
  22. package/src/components/ui/index.ts +3 -1
  23. package/src/components/ui/password-requirements.tsx +105 -0
  24. package/src/components/ui/segmented-control.tsx +75 -0
  25. package/src/components/ui/simple-select.tsx +74 -0
  26. package/src/components/ui/switch.tsx +3 -1
  27. package/src/components/ui/tabs.tsx +4 -8
  28. package/src/components/ui/track.styles.ts +31 -0
  29. package/src/contexts/brand-context.tsx +15 -0
  30. package/src/contexts/color-mode-context.tsx +4 -2
  31. package/src/contexts/i18n-context.tsx +72 -6
  32. package/src/contexts/socket-context.tsx +27 -1
  33. package/src/features/audit/audit-screen.tsx +13 -140
  34. package/src/features/audit/components/audit-diff.tsx +156 -0
  35. package/src/features/audit/components/index.ts +3 -0
  36. package/src/features/audit/components/record-audit-button.tsx +176 -0
  37. package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
  38. package/src/features/audit/hooks/use-audit-trail.ts +9 -2
  39. package/src/features/audit/services/audit.service.ts +43 -1
  40. package/src/features/login/components/backup-codes-dialog.tsx +4 -4
  41. package/src/features/login/components/login-form.tsx +18 -3
  42. package/src/features/login/components/two-factor-setup.tsx +32 -2
  43. package/src/features/login/hooks/use-login-flow.ts +60 -7
  44. package/src/features/login/login-screen.tsx +99 -40
  45. package/src/features/logs/enums/error-type.enum.ts +21 -0
  46. package/src/features/logs/enums/request-outcome.enum.ts +14 -0
  47. package/src/features/logs/hooks/use-request-logs.ts +26 -3
  48. package/src/features/logs/logs-screen.tsx +2 -14
  49. package/src/features/logs/panels/index.ts +0 -1
  50. package/src/features/logs/panels/request-logs-panel.tsx +177 -25
  51. package/src/features/logs/services/logs.service.ts +13 -51
  52. package/src/features/notifications/components/notification-item.tsx +10 -8
  53. package/src/features/notifications/enums/notification-type.enum.ts +8 -0
  54. package/src/features/profile/force-password-change.tsx +18 -3
  55. package/src/features/profile/profile-screen.tsx +10 -2
  56. package/src/features/rbac/panels/groups-panel.tsx +78 -30
  57. package/src/features/rbac/panels/roles-panel.tsx +2 -2
  58. package/src/features/rbac/services/rbac.service.ts +23 -27
  59. package/src/features/recovery/reset-password-screen.tsx +16 -2
  60. package/src/features/users/components/user-form-dialog.tsx +65 -9
  61. package/src/features/users/hooks/use-users.ts +24 -3
  62. package/src/features/users/services/users.service.ts +16 -0
  63. package/src/features/users/users-screen.tsx +17 -12
  64. package/src/hooks/use-column-visibility.ts +13 -12
  65. package/src/hooks/use-countdown.ts +70 -0
  66. package/src/hooks/use-filters.ts +25 -2
  67. package/src/hooks/use-list-query.ts +14 -1
  68. package/src/hooks/use-request.ts +21 -2
  69. package/src/i18n/messages/en.ts +37 -6
  70. package/src/i18n/messages/pt.ts +41 -6
  71. package/src/index.ts +15 -1
  72. package/src/styles/core.css +99 -2
  73. package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
  74. package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
  75. package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
  76. package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
  77. package/tailwind-preset.ts +17 -0
  78. package/src/components/ui/filter-chips.tsx +0 -88
  79. package/src/features/logs/hooks/use-error-logs.ts +0 -65
  80. package/src/features/logs/panels/error-logs-panel.tsx +0 -183
@@ -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,10 +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";
26
+ import { RecordAuditButton } from "#core/features/audit/components";
25
27
  import {
26
28
  CreateUserPayload,
29
+ ManagedGroup,
27
30
  Role,
28
31
  User,
29
32
  } from "#core/features/users/services/users.service";
@@ -32,6 +35,8 @@ interface Props {
32
35
  open: boolean;
33
36
  loading: boolean;
34
37
  roles: Role[];
38
+ /** Grupos que o ator gerencia — usados só quando ele cria pelo escopo `team`. */
39
+ managedGroups?: ManagedGroup[];
35
40
  editing?: User | null;
36
41
  onClose: () => void;
37
42
  onSubmit: (data: CreateUserPayload, id?: string) => Promise<boolean>;
@@ -41,13 +46,24 @@ export function UserFormDialog({
41
46
  open,
42
47
  loading,
43
48
  roles,
49
+ managedGroups = [],
44
50
  editing,
45
51
  onClose,
46
52
  onSubmit,
47
53
  }: Props): JSX.Element {
48
54
  const { t } = useI18n();
55
+ const { hasPermission } = useAuth();
49
56
  const isEdit = !!editing;
50
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
+
51
67
  const schema = yup.object({
52
68
  name: yup.string().required(t("validation.required")).min(2),
53
69
  lastName: yup.string().optional(),
@@ -57,6 +73,9 @@ export function UserFormDialog({
57
73
  .required(t("validation.required"))
58
74
  .email(t("validation.email")),
59
75
  roleIds: yup.array().of(yup.string()).optional(),
76
+ groupId: mustPickGroup
77
+ ? yup.string().required(t("validation.required"))
78
+ : yup.string().optional(),
60
79
  });
61
80
 
62
81
  const {
@@ -73,6 +92,8 @@ export function UserFormDialog({
73
92
  phone: "",
74
93
  email: "",
75
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 : "",
76
97
  },
77
98
  });
78
99
 
@@ -84,9 +105,10 @@ export function UserFormDialog({
84
105
  phone: editing?.phone ?? "",
85
106
  email: editing?.email ?? "",
86
107
  roleIds: editing?.roles?.map((r) => r.id) ?? [],
108
+ groupId: managedGroups.length === 1 ? managedGroups[0].id : "",
87
109
  });
88
110
  }
89
- }, [open, editing, reset]);
111
+ }, [open, editing, reset, managedGroups]);
90
112
 
91
113
  const submit = handleSubmit(async (data) => {
92
114
  const payload: CreateUserPayload = {
@@ -96,6 +118,7 @@ export function UserFormDialog({
96
118
  email: data.email,
97
119
  // o schema Yup infere (string | undefined)[]; aqui estreitamos sem cast
98
120
  roleIds: data.roleIds?.filter((id): id is string => !!id),
121
+ groupIds: data.groupId ? [data.groupId] : undefined,
99
122
  };
100
123
  const ok = await onSubmit(payload, editing?.id);
101
124
  if (ok) {onClose();}
@@ -138,6 +161,30 @@ export function UserFormDialog({
138
161
  <Alert variant="info">{t("users.passwordByEmailHint")}</Alert>
139
162
  )}
140
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 && (
141
188
  <Controller
142
189
  name="roleIds"
143
190
  control={control}
@@ -170,15 +217,24 @@ export function UserFormDialog({
170
217
  );
171
218
  }}
172
219
  />
220
+ )}
173
221
  </div>
174
222
 
175
- <DialogFooter className="gap-2">
176
- <Button type="button" variant="outline" onClick={onClose}>
177
- {t("common.cancel")}
178
- </Button>
179
- <Button type="submit" disabled={loading}>
180
- {isEdit ? t("common.save") : t("common.create")}
181
- </Button>
223
+ <DialogFooter className="gap-2 sm:justify-between">
224
+ {/* na edição: registro que ainda não existe não tem histórico. */}
225
+ <div>
226
+ {editing && (
227
+ <RecordAuditButton entity="User" entityId={editing.id} />
228
+ )}
229
+ </div>
230
+ <div className="flex gap-2">
231
+ <Button type="button" variant="outline" onClick={onClose}>
232
+ {t("common.cancel")}
233
+ </Button>
234
+ <Button type="submit" disabled={loading}>
235
+ {isEdit ? t("common.save") : t("common.create")}
236
+ </Button>
237
+ </div>
182
238
  </DialogFooter>
183
239
  </DialogForm>
184
240
  </DialogContent>
@@ -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 () => {
@@ -68,12 +73,25 @@ export function useUsers(): UseUsersResult {
68
73
  }
69
74
  }, [run, list.query]);
70
75
 
76
+ // Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
77
+ // resposta pode chegar depois da certa e pintar a tela com tudo.
71
78
  useEffect(() => {
79
+ if (!list.ready) {
80
+ return;
81
+ }
72
82
  void fetch();
73
- }, [fetch]);
83
+ }, [fetch, list.ready]);
74
84
 
75
85
  useEffect(() => {
76
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
+ }
77
95
  // eslint-disable-next-line react-hooks/exhaustive-deps
78
96
  }, []);
79
97
 
@@ -159,11 +177,14 @@ export function useUsers(): UseUsersResult {
159
177
  return {
160
178
  rows,
161
179
  roles,
180
+ managedGroups,
162
181
  total,
163
182
  page: list.page,
164
183
  pageSize: list.pageSize,
165
184
  sorting: list.sorting,
166
- loading,
185
+ // Esperar o catálogo também é carregar: sem isso a tabela pisca
186
+ // "Nenhum registro" antes da primeira busca sair.
187
+ loading: loading || !list.ready,
167
188
  acting,
168
189
  setPage: list.setPage,
169
190
  setPageSize: list.setPageSize,
@@ -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)}
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useCallback, useEffect, useState } from "react";
4
4
 
5
+ import { safeStorage } from "#core/_utils/storage";
6
+
5
7
  const STORAGE_PREFIX = "cols:";
6
8
 
7
9
  export interface UseColumnVisibilityResult {
@@ -20,10 +22,13 @@ export function useColumnVisibility(
20
22
  const [hiddenColumns, setHiddenColumns] = useState<string[]>([]);
21
23
 
22
24
  useEffect(() => {
23
- if (!storageKey || typeof window === "undefined") {return;}
25
+ if (!storageKey) {return;}
26
+ const raw = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
27
+ if (!raw) {return;}
24
28
  try {
25
- const raw = window.localStorage.getItem(`${STORAGE_PREFIX}${storageKey}`);
26
- if (raw) {setHiddenColumns(JSON.parse(raw));}
29
+ // O `try` que sobrou é do JSON: o acesso ao armazenamento já é seguro,
30
+ // mas o conteúdo pode estar corrompido por uma versão anterior da tela.
31
+ setHiddenColumns(JSON.parse(raw));
27
32
  } catch {
28
33
  setHiddenColumns([]);
29
34
  }
@@ -37,15 +42,11 @@ export function useColumnVisibility(
37
42
  const saveHiddenColumns = useCallback(
38
43
  (nextHidden: string[]) => {
39
44
  setHiddenColumns(nextHidden);
40
- if (!storageKey || typeof window === "undefined") {return;}
41
- try {
42
- window.localStorage.setItem(
43
- `${STORAGE_PREFIX}${storageKey}`,
44
- JSON.stringify(nextHidden),
45
- );
46
- } catch {
47
- /* ignore */
48
- }
45
+ if (!storageKey) {return;}
46
+ safeStorage.set(
47
+ `${STORAGE_PREFIX}${storageKey}`,
48
+ JSON.stringify(nextHidden),
49
+ );
49
50
  },
50
51
  [storageKey],
51
52
  );
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export interface UseCountdownResult {
6
+ /** Segundos restantes, nunca negativo. */
7
+ remaining: number;
8
+ /** `mm:ss` pronto para exibir. */
9
+ formatted: string;
10
+ /** Chegou a zero. */
11
+ expired: boolean;
12
+ }
13
+
14
+ const format = (totalSeconds: number): string => {
15
+ const minutes = Math.floor(totalSeconds / 60);
16
+ const seconds = totalSeconds % 60;
17
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
18
+ };
19
+
20
+ /**
21
+ * Contagem regressiva em segundos.
22
+ *
23
+ * Conta pelo relógio, não somando ticks: um intervalo de 1s atrasa quando a aba
24
+ * fica em segundo plano, e o tempo iria derretendo. O instante-alvo é fixado
25
+ * uma vez, dentro do efeito, e cada tique só relê a diferença — voltar para a
26
+ * aba mostra o tempo real, não o que o timer conseguiu contar.
27
+ *
28
+ * `seconds` nulo (prazo ainda desconhecido) deixa o contador parado em zero sem
29
+ * marcar como expirado.
30
+ *
31
+ * O relógio é lido só no efeito e no callback do intervalo: `Date.now()` é
32
+ * impuro e durante o render produziria resultado instável a cada re-render.
33
+ */
34
+ export function useCountdown(seconds: number | null): UseCountdownResult {
35
+ /*
36
+ * Guarda de que prazo veio a contagem. Sem isso, trocar `seconds` deixaria o
37
+ * valor do prazo anterior à mostra até o primeiro tique — e um zero herdado
38
+ * marcaria como expirado um contador que acabou de começar.
39
+ */
40
+ const [tick, setTick] = useState<{ source: number | null; value: number }>({
41
+ source: seconds,
42
+ value: seconds ?? 0,
43
+ });
44
+
45
+ useEffect(() => {
46
+ if (seconds === null) {
47
+ return;
48
+ }
49
+ const deadline = Date.now() + seconds * 1000;
50
+ const id = setInterval(
51
+ () =>
52
+ setTick({
53
+ source: seconds,
54
+ value: Math.max(0, Math.round((deadline - Date.now()) / 1000)),
55
+ }),
56
+ 1000,
57
+ );
58
+ return () => clearInterval(id);
59
+ }, [seconds]);
60
+
61
+ // Enquanto o tique ainda é do prazo anterior, vale o prazo cheio recebido.
62
+ const remaining =
63
+ seconds === null ? 0 : tick.source === seconds ? tick.value : seconds;
64
+
65
+ return {
66
+ remaining,
67
+ formatted: format(remaining),
68
+ expired: seconds !== null && remaining === 0,
69
+ };
70
+ }
@@ -32,6 +32,15 @@ export interface FiltersState {
32
32
  validation: AdvancedValidation;
33
33
  /** JSON pronto para a query — `undefined` quando não há filtro. */
34
34
  filter: string | undefined;
35
+ /**
36
+ * A URL já foi lida e o `filter` abaixo é o definitivo.
37
+ *
38
+ * Antes disso `filter` é `undefined` porque ninguém leu a querystring ainda —
39
+ * e não porque a listagem esteja sem filtro. Quem busca precisa esperar, ou
40
+ * dispara uma consulta sem filtro cuja resposta pode chegar depois da certa e
41
+ * pintar a tela com tudo.
42
+ */
43
+ hydrated: boolean;
35
44
  }
36
45
 
37
46
  const QUERY_PARAM = "filter";
@@ -57,8 +66,16 @@ const writeToUrl = (encoded: string | undefined): void => {
57
66
  * Espelhar na URL faz o filtro sobreviver ao F5 e virar link compartilhável.
58
67
  * O espelho é `replaceState` e não `router.replace`: o filtro não é navegação,
59
68
  * e trocá-lo não deve empilhar histórico nem refazer o render da rota.
69
+ *
70
+ * `defaultValues` é o filtro de quem chega sem `?filter=` na URL — a listagem
71
+ * abre já filtrada. Vale só na hidratação: link com filtro manda, e limpar os
72
+ * filtros continua limpando (o padrão volta no F5 seguinte, que é o que
73
+ * "padrão" quer dizer).
60
74
  */
61
- export function useFilters(schemaUrl: string): FiltersState {
75
+ export function useFilters(
76
+ schemaUrl: string,
77
+ defaultValues?: FilterValues,
78
+ ): FiltersState {
62
79
  const { schema, loading } = useFilterSchema(schemaUrl);
63
80
 
64
81
  const [mode, setModeState] = useState<FilterMode>("simple");
@@ -82,9 +99,14 @@ export function useFilters(schemaUrl: string): FiltersState {
82
99
  }
83
100
  } else if (raw) {
84
101
  setValuesState(parseFilterValues(raw, schema));
102
+ } else if (defaultValues) {
103
+ // Espelha na URL como qualquer outro filtro: sem isso a tela mostraria
104
+ // um estado que o link não carrega.
105
+ setValuesState(defaultValues);
106
+ writeToUrl(buildFilter(defaultValues, schema));
85
107
  }
86
108
  setHydrated(true);
87
- }, [hydrated, loading, schema]);
109
+ }, [hydrated, loading, schema, defaultValues]);
88
110
 
89
111
  const setValues = useCallback(
90
112
  (next: FilterValues): void => {
@@ -144,5 +166,6 @@ export function useFilters(schemaUrl: string): FiltersState {
144
166
  setTree,
145
167
  validation,
146
168
  filter,
169
+ hydrated,
147
170
  };
148
171
  }