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.
- package/package.json +1 -1
- package/src/_services/api/app-error-code.enum.ts +23 -0
- package/src/_services/api/schema.d.ts +86 -132
- package/src/_services/auth/index.ts +2 -0
- package/src/_utils/filter.ts +15 -34
- package/src/_utils/password.ts +40 -0
- package/src/_utils/permission.ts +14 -0
- package/src/_utils/storage.ts +64 -0
- package/src/_utils/user.ts +19 -0
- package/src/components/app-shell.tsx +85 -11
- package/src/components/brand-header.tsx +23 -8
- package/src/components/brand-panel.tsx +41 -0
- package/src/components/index.ts +1 -0
- package/src/components/password-field.tsx +49 -22
- package/src/components/ui/alert.tsx +34 -5
- package/src/components/ui/badge.tsx +2 -2
- package/src/components/ui/button.tsx +5 -0
- package/src/components/ui/confirm-dialog.tsx +3 -1
- package/src/components/ui/data-table.tsx +13 -4
- package/src/components/ui/dialog.tsx +120 -21
- package/src/components/ui/filter-sheet.tsx +47 -25
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/password-requirements.tsx +105 -0
- package/src/components/ui/segmented-control.tsx +75 -0
- package/src/components/ui/simple-select.tsx +74 -0
- package/src/components/ui/switch.tsx +3 -1
- package/src/components/ui/tabs.tsx +4 -8
- package/src/components/ui/track.styles.ts +31 -0
- package/src/contexts/brand-context.tsx +15 -0
- package/src/contexts/color-mode-context.tsx +4 -2
- package/src/contexts/i18n-context.tsx +72 -6
- package/src/contexts/socket-context.tsx +27 -1
- package/src/features/audit/audit-screen.tsx +13 -140
- package/src/features/audit/components/audit-diff.tsx +156 -0
- package/src/features/audit/components/index.ts +3 -0
- package/src/features/audit/components/record-audit-button.tsx +176 -0
- package/src/features/audit/enums/audit-data-action.enum.ts +20 -0
- package/src/features/audit/hooks/use-audit-trail.ts +9 -2
- package/src/features/audit/services/audit.service.ts +43 -1
- package/src/features/login/components/backup-codes-dialog.tsx +4 -4
- package/src/features/login/components/login-form.tsx +18 -3
- package/src/features/login/components/two-factor-setup.tsx +32 -2
- package/src/features/login/hooks/use-login-flow.ts +60 -7
- package/src/features/login/login-screen.tsx +99 -40
- package/src/features/logs/enums/error-type.enum.ts +21 -0
- package/src/features/logs/enums/request-outcome.enum.ts +14 -0
- package/src/features/logs/hooks/use-request-logs.ts +26 -3
- package/src/features/logs/logs-screen.tsx +2 -14
- package/src/features/logs/panels/index.ts +0 -1
- package/src/features/logs/panels/request-logs-panel.tsx +177 -25
- package/src/features/logs/services/logs.service.ts +13 -51
- package/src/features/notifications/components/notification-item.tsx +10 -8
- package/src/features/notifications/enums/notification-type.enum.ts +8 -0
- package/src/features/profile/force-password-change.tsx +18 -3
- package/src/features/profile/profile-screen.tsx +10 -2
- package/src/features/rbac/panels/groups-panel.tsx +78 -30
- package/src/features/rbac/panels/roles-panel.tsx +2 -2
- package/src/features/rbac/services/rbac.service.ts +23 -27
- package/src/features/recovery/reset-password-screen.tsx +16 -2
- package/src/features/users/components/user-form-dialog.tsx +65 -9
- package/src/features/users/hooks/use-users.ts +24 -3
- package/src/features/users/services/users.service.ts +16 -0
- package/src/features/users/users-screen.tsx +17 -12
- package/src/hooks/use-column-visibility.ts +13 -12
- package/src/hooks/use-countdown.ts +70 -0
- package/src/hooks/use-filters.ts +25 -2
- package/src/hooks/use-list-query.ts +14 -1
- package/src/hooks/use-request.ts +21 -2
- package/src/i18n/messages/en.ts +37 -6
- package/src/i18n/messages/pt.ts +41 -6
- package/src/index.ts +15 -1
- package/src/styles/core.css +99 -2
- package/src/styles/fonts/poppins-latin-400.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-500.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-600.woff2 +0 -0
- package/src/styles/fonts/poppins-latin-700.woff2 +0 -0
- package/tailwind-preset.ts +17 -0
- package/src/components/ui/filter-chips.tsx +0 -88
- package/src/features/logs/hooks/use-error-logs.ts +0 -65
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
</
|
|
223
|
+
<DialogFooter className="gap-2 sm:justify-between">
|
|
224
|
+
{/* Só 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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
25
|
+
if (!storageKey) {return;}
|
|
26
|
+
const raw = safeStorage.get(`${STORAGE_PREFIX}${storageKey}`);
|
|
27
|
+
if (!raw) {return;}
|
|
24
28
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
}
|
package/src/hooks/use-filters.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|