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.
- package/package.json +1 -1
- package/src/_services/api/app-error-code.enum.ts +23 -0
- package/src/_services/api/schema.d.ts +43 -0
- package/src/_services/auth/index.ts +2 -0
- 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 +37 -13
- 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/index.ts +2 -0
- package/src/components/ui/password-requirements.tsx +105 -0
- package/src/components/ui/segmented-control.tsx +7 -6
- 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/color-mode-context.tsx +4 -2
- package/src/contexts/i18n-context.tsx +3 -2
- package/src/contexts/socket-context.tsx +27 -1
- package/src/features/audit/components/audit-diff.tsx +1 -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 +17 -4
- package/src/features/profile/force-password-change.tsx +18 -3
- package/src/features/profile/profile-screen.tsx +8 -1
- package/src/features/rbac/panels/groups-panel.tsx +58 -23
- 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 +49 -2
- package/src/features/users/hooks/use-users.ts +15 -1
- 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-request.ts +21 -2
- package/src/i18n/messages/en.ts +20 -1
- package/src/i18n/messages/pt.ts +23 -1
- package/src/index.ts +3 -0
- 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
|
@@ -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
|
-
|
|
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({
|
|
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,
|
|
110
|
+
? await run(() => rbacService.updateGroup(editing.id, payload), {
|
|
89
111
|
success: RequestOperation.Update,
|
|
90
112
|
})
|
|
91
|
-
: await run(() => rbacService.createGroup(
|
|
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
|
-
<
|
|
267
|
+
<SimpleSelect
|
|
223
268
|
value={form.type}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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)}
|