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
|
@@ -6,11 +6,29 @@ import type {
|
|
|
6
6
|
PaginationState,
|
|
7
7
|
SortingState,
|
|
8
8
|
} from "@tanstack/react-table";
|
|
9
|
+
import { Eye } from "lucide-react";
|
|
9
10
|
import type { JSX } from "react";
|
|
10
|
-
import { useMemo } from "react";
|
|
11
|
+
import { useMemo, useState } from "react";
|
|
11
12
|
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Badge,
|
|
15
|
+
Button,
|
|
16
|
+
DataTable,
|
|
17
|
+
DataTableFeatures,
|
|
18
|
+
Dialog,
|
|
19
|
+
DialogContent,
|
|
20
|
+
DialogHeader,
|
|
21
|
+
DialogTitle,
|
|
22
|
+
RowActions,
|
|
23
|
+
SegmentedControl,
|
|
24
|
+
SegmentedOption,
|
|
25
|
+
} from "#core/components/ui";
|
|
13
26
|
import { useI18n } from "#core/contexts";
|
|
27
|
+
import {
|
|
28
|
+
ErrorType,
|
|
29
|
+
ErrorTypeLabelKey,
|
|
30
|
+
} from "#core/features/logs/enums/error-type.enum";
|
|
31
|
+
import { RequestOutcomeLabelKey } from "#core/features/logs/enums/request-outcome.enum";
|
|
14
32
|
import { useRequestLogs } from "#core/features/logs/hooks/use-request-logs";
|
|
15
33
|
import { RequestLog } from "#core/features/logs/services/logs.service";
|
|
16
34
|
|
|
@@ -24,9 +42,46 @@ function statusVariant(
|
|
|
24
42
|
return "secondary";
|
|
25
43
|
}
|
|
26
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Nenhum segmento aceso.
|
|
47
|
+
*
|
|
48
|
+
* Não é opção do controle — some do filtro só quem limpar o desfecho pelo
|
|
49
|
+
* painel lateral, e aí nenhum segmento fica marcado.
|
|
50
|
+
*/
|
|
51
|
+
const NO_OUTCOME = "";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Log de requisição — um painel só.
|
|
55
|
+
*
|
|
56
|
+
* Eram dois (requisições e erros) enquanto eram duas tabelas. Hoje é uma linha
|
|
57
|
+
* por requisição, e quem separa sucesso de erro é o desfecho na barra.
|
|
58
|
+
*/
|
|
27
59
|
export function RequestLogsPanel(): JSX.Element {
|
|
28
60
|
const { t } = useI18n();
|
|
29
61
|
const logs = useRequestLogs();
|
|
62
|
+
const [detail, setDetail] = useState<RequestLog | null>(null);
|
|
63
|
+
|
|
64
|
+
// O atalho não é um segundo caminho de filtro (§11): ele escreve no mesmo
|
|
65
|
+
// estado do painel lateral, então o chip aparece, a URL guarda e o painel
|
|
66
|
+
// mostra o mesmo valor. É atalho para o campo do catálogo, não um filtro
|
|
67
|
+
// paralelo.
|
|
68
|
+
const outcome = logs.filters.values.outcome?.values?.[0] ?? NO_OUTCOME;
|
|
69
|
+
const setOutcome = (value: string): void => {
|
|
70
|
+
logs.filters.setValues({
|
|
71
|
+
...logs.filters.values,
|
|
72
|
+
outcome: { values: [value] },
|
|
73
|
+
});
|
|
74
|
+
logs.setPage(0);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const outcomeOptions = useMemo<SegmentedOption[]>(
|
|
78
|
+
() =>
|
|
79
|
+
Object.entries(RequestOutcomeLabelKey).map(([value, key]) => ({
|
|
80
|
+
value,
|
|
81
|
+
label: t(key),
|
|
82
|
+
})),
|
|
83
|
+
[t],
|
|
84
|
+
);
|
|
30
85
|
|
|
31
86
|
const columns = useMemo<ColumnDef<DataTableFeatures, RequestLog, unknown>[]>(
|
|
32
87
|
() => [
|
|
@@ -37,12 +92,17 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
37
92
|
cell: ({ getValue }) => new Date(String(getValue())).toLocaleString(),
|
|
38
93
|
},
|
|
39
94
|
{
|
|
40
|
-
|
|
95
|
+
id: "route",
|
|
41
96
|
header: t("logs.route"),
|
|
42
97
|
meta: { label: t("logs.route") },
|
|
43
98
|
enableSorting: false,
|
|
44
|
-
|
|
45
|
-
|
|
99
|
+
// A ação nomeada (`@Auditable("Login")`) diz mais que o caminho; sem
|
|
100
|
+
// ela, o caminho é o que identifica a requisição.
|
|
101
|
+
cell: ({ row }) => (
|
|
102
|
+
<span className="font-mono text-xs">
|
|
103
|
+
{row.original.action ??
|
|
104
|
+
`${row.original.method ?? ""} ${row.original.path ?? "—"}`}
|
|
105
|
+
</span>
|
|
46
106
|
),
|
|
47
107
|
},
|
|
48
108
|
{
|
|
@@ -54,6 +114,16 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
54
114
|
return <Badge variant={statusVariant(status)}>{status ?? "—"}</Badge>;
|
|
55
115
|
},
|
|
56
116
|
},
|
|
117
|
+
{
|
|
118
|
+
accessorKey: "errorType",
|
|
119
|
+
header: t("logs.type"),
|
|
120
|
+
meta: { label: t("logs.type") },
|
|
121
|
+
enableSorting: false,
|
|
122
|
+
cell: ({ getValue }) => {
|
|
123
|
+
const type = getValue() as ErrorType | null;
|
|
124
|
+
return type == null ? "—" : t(ErrorTypeLabelKey[type]);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
57
127
|
{
|
|
58
128
|
accessorKey: "durationMs",
|
|
59
129
|
header: t("logs.duration"),
|
|
@@ -71,16 +141,26 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
71
141
|
cell: ({ row }) => row.original.user?.name ?? t("logs.systemUser"),
|
|
72
142
|
},
|
|
73
143
|
{
|
|
74
|
-
|
|
75
|
-
header: t("
|
|
76
|
-
meta: {
|
|
144
|
+
id: "actions",
|
|
145
|
+
header: () => <div className="text-center">{t("common.actions")}</div>,
|
|
146
|
+
meta: { shrink: true },
|
|
147
|
+
enableHiding: false,
|
|
77
148
|
enableSorting: false,
|
|
78
|
-
cell: ({
|
|
79
|
-
<
|
|
149
|
+
cell: ({ row }) => (
|
|
150
|
+
<RowActions>
|
|
151
|
+
<Button
|
|
152
|
+
variant="ghost"
|
|
153
|
+
size="sm"
|
|
154
|
+
onClick={() => setDetail(row.original)}
|
|
155
|
+
>
|
|
156
|
+
<Eye className="h-4 w-4" />
|
|
157
|
+
{t("logs.viewDetails")}
|
|
158
|
+
</Button>
|
|
159
|
+
</RowActions>
|
|
80
160
|
),
|
|
81
161
|
},
|
|
82
162
|
],
|
|
83
|
-
|
|
163
|
+
|
|
84
164
|
[t],
|
|
85
165
|
);
|
|
86
166
|
|
|
@@ -105,20 +185,92 @@ export function RequestLogsPanel(): JSX.Element {
|
|
|
105
185
|
};
|
|
106
186
|
|
|
107
187
|
return (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
188
|
+
<>
|
|
189
|
+
<DataTable
|
|
190
|
+
columns={columns}
|
|
191
|
+
data={logs.rows}
|
|
192
|
+
getRowId={(r) => r.id}
|
|
193
|
+
storageKey="logs-requests"
|
|
194
|
+
loading={logs.loading}
|
|
195
|
+
manualPagination
|
|
196
|
+
rowCount={logs.total}
|
|
197
|
+
pagination={pagination}
|
|
198
|
+
onPaginationChange={onPaginationChange}
|
|
199
|
+
manualSorting
|
|
200
|
+
sorting={logs.sorting}
|
|
201
|
+
onSortingChange={onSortingChange}
|
|
121
202
|
filters={logs.filters}
|
|
122
|
-
|
|
203
|
+
// O desfecho é escolhido na barra: no painel e no chip seria a mesma
|
|
204
|
+
// coisa em três lugares. Continua no catálogo, que é quem serializa.
|
|
205
|
+
hiddenFilterFields={["outcome"]}
|
|
206
|
+
toolbar={
|
|
207
|
+
<SegmentedControl
|
|
208
|
+
// Altura dos botões `sm` de Filtros/Colunas, que dividem a linha.
|
|
209
|
+
className="h-9"
|
|
210
|
+
label={t("logs.outcome")}
|
|
211
|
+
options={outcomeOptions}
|
|
212
|
+
value={outcome}
|
|
213
|
+
onValueChange={setOutcome}
|
|
214
|
+
/>
|
|
215
|
+
}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<Dialog open={!!detail} onOpenChange={(o) => !o && setDetail(null)}>
|
|
219
|
+
<DialogContent className="max-w-2xl">
|
|
220
|
+
<DialogHeader>
|
|
221
|
+
<DialogTitle>
|
|
222
|
+
{t("logs.detailsTitle")}
|
|
223
|
+
{detail?.statusCode != null && ` (${detail.statusCode})`}
|
|
224
|
+
</DialogTitle>
|
|
225
|
+
</DialogHeader>
|
|
226
|
+
{detail && (
|
|
227
|
+
<div className="flex min-w-0 flex-col gap-3 text-sm">
|
|
228
|
+
<div>
|
|
229
|
+
<span className="font-medium">{t("logs.route")}: </span>
|
|
230
|
+
<span className="font-mono text-xs">
|
|
231
|
+
{detail.method} {detail.path}
|
|
232
|
+
</span>
|
|
233
|
+
</div>
|
|
234
|
+
{detail.errorMessage && (
|
|
235
|
+
<div>
|
|
236
|
+
<span className="font-medium">{t("logs.message")}: </span>
|
|
237
|
+
{detail.errorMessage}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
{detail.errorCode && (
|
|
241
|
+
<div>
|
|
242
|
+
<span className="font-medium">{t("logs.errorCode")}: </span>
|
|
243
|
+
<span className="font-mono text-xs">{detail.errorCode}</span>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
<div>
|
|
247
|
+
<span className="font-medium">{t("logs.user")}: </span>
|
|
248
|
+
{detail.user?.name ?? t("logs.systemUser")}
|
|
249
|
+
</div>
|
|
250
|
+
<div>
|
|
251
|
+
<span className="font-medium">{t("logs.requestId")}: </span>
|
|
252
|
+
<span className="font-mono text-xs">
|
|
253
|
+
{detail.requestId ?? "—"}
|
|
254
|
+
</span>
|
|
255
|
+
</div>
|
|
256
|
+
{detail.userAgent && (
|
|
257
|
+
<div className="min-w-0">
|
|
258
|
+
<span className="font-medium">{t("logs.userAgent")}: </span>
|
|
259
|
+
<span className="font-mono text-xs">{detail.userAgent}</span>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
{detail.errorStack && (
|
|
263
|
+
<div className="min-w-0">
|
|
264
|
+
<p className="mb-1 font-medium">{t("logs.stackTrace")}</p>
|
|
265
|
+
<pre className="max-h-64 min-w-0 max-w-full overflow-auto rounded-md bg-muted p-3 text-xs">
|
|
266
|
+
{detail.errorStack}
|
|
267
|
+
</pre>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
</DialogContent>
|
|
273
|
+
</Dialog>
|
|
274
|
+
</>
|
|
123
275
|
);
|
|
124
276
|
}
|
|
@@ -1,68 +1,30 @@
|
|
|
1
1
|
import { api } from "#core/_services/api/axios.factory";
|
|
2
2
|
import type { ListQuery } from "#core/_services/api/list-query";
|
|
3
3
|
import { toListParams } from "#core/_services/api/list-query";
|
|
4
|
+
import type { components } from "#core/_services/api/schema";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
| "NOT_FOUND"
|
|
9
|
-
| "RATE_LIMIT"
|
|
10
|
-
| "DATABASE"
|
|
11
|
-
| "INTEGRATION"
|
|
12
|
-
| "INTERNAL";
|
|
13
|
-
|
|
14
|
-
export interface LogUserRef {
|
|
15
|
-
id: string;
|
|
16
|
-
name: string;
|
|
17
|
-
email: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface RequestLog {
|
|
21
|
-
id: string;
|
|
22
|
-
createdAt: string;
|
|
23
|
-
userId?: string | null;
|
|
24
|
-
action: string;
|
|
25
|
-
method?: string | null;
|
|
26
|
-
path?: string | null;
|
|
27
|
-
ipAddress?: string | null;
|
|
28
|
-
statusCode?: number | null;
|
|
29
|
-
durationMs?: number | null;
|
|
30
|
-
requestId?: string | null;
|
|
31
|
-
user?: LogUserRef | null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface ErrorLog {
|
|
35
|
-
id: string;
|
|
36
|
-
createdAt: string;
|
|
37
|
-
statusCode?: number | null;
|
|
38
|
-
type: ErrorType;
|
|
39
|
-
message: string;
|
|
40
|
-
stack?: string | null;
|
|
41
|
-
method?: string | null;
|
|
42
|
-
path?: string | null;
|
|
43
|
-
userId?: string | null;
|
|
44
|
-
ipAddress?: string | null;
|
|
45
|
-
requestId?: string | null;
|
|
46
|
-
user?: LogUserRef | null;
|
|
47
|
-
}
|
|
6
|
+
/** Tipos derivados do `openapi.json` (§5) — não redigitar a forma da resposta. */
|
|
7
|
+
export type RequestLog = components["schemas"]["RequestLogResponse"];
|
|
8
|
+
export type LogUserRef = components["schemas"]["AuditUserRefResponse"];
|
|
48
9
|
|
|
49
10
|
export interface Paginated<T> {
|
|
50
11
|
items: T[];
|
|
51
|
-
meta:
|
|
12
|
+
meta: components["schemas"]["PaginationMetaResponse"];
|
|
52
13
|
}
|
|
53
14
|
|
|
54
15
|
export const logsService = {
|
|
16
|
+
/**
|
|
17
|
+
* Log de requisição — uma rota só.
|
|
18
|
+
*
|
|
19
|
+
* Antes eram `/audit/changes` e `/audit/errors`, duas tabelas com nove
|
|
20
|
+
* colunas iguais. Agora é uma linha por requisição, e a distinção entre
|
|
21
|
+
* sucesso e erro é o filtro `outcome`.
|
|
22
|
+
*/
|
|
55
23
|
listRequests(query: ListQuery) {
|
|
56
24
|
return api
|
|
57
|
-
.get<Paginated<RequestLog>>("/audit/
|
|
25
|
+
.get<Paginated<RequestLog>>("/audit/requests", {
|
|
58
26
|
params: toListParams(query),
|
|
59
27
|
})
|
|
60
28
|
.then((r) => r.data);
|
|
61
29
|
},
|
|
62
|
-
|
|
63
|
-
listErrors(query: ListQuery) {
|
|
64
|
-
return api
|
|
65
|
-
.get<Paginated<ErrorLog>>("/audit/errors", { params: toListParams(query) })
|
|
66
|
-
.then((r) => r.data);
|
|
67
|
-
},
|
|
68
30
|
};
|
|
@@ -5,17 +5,19 @@ import type { JSX } from "react";
|
|
|
5
5
|
|
|
6
6
|
import { formatDateTime } from "#core/_utils/format";
|
|
7
7
|
import { useI18n } from "#core/contexts";
|
|
8
|
-
import type
|
|
9
|
-
|
|
10
|
-
UserNotification,
|
|
11
|
-
} from "#core/features/notifications/services/notifications.service";
|
|
8
|
+
import { NotificationType } from "#core/features/notifications/enums/notification-type.enum";
|
|
9
|
+
import type { UserNotification } from "#core/features/notifications/services/notifications.service";
|
|
12
10
|
import { cn } from "#core/lib/utils";
|
|
13
11
|
|
|
14
12
|
const ICONS: Record<NotificationType, JSX.Element> = {
|
|
15
|
-
INFO: <Info className="h-4 w-4 text-sky-500" />,
|
|
16
|
-
SUCCESS:
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
[NotificationType.INFO]: <Info className="h-4 w-4 text-sky-500" />,
|
|
14
|
+
[NotificationType.SUCCESS]: (
|
|
15
|
+
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
16
|
+
),
|
|
17
|
+
[NotificationType.WARNING]: (
|
|
18
|
+
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
|
19
|
+
),
|
|
20
|
+
[NotificationType.ERROR]: <XCircle className="h-4 w-4 text-destructive" />,
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
export interface NotificationItemProps {
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { yupResolver } from "@hookform/resolvers/yup";
|
|
4
4
|
import type { JSX } from "react";
|
|
5
|
-
import { useForm } from "react-hook-form";
|
|
5
|
+
import { useForm, useWatch } from "react-hook-form";
|
|
6
6
|
import * as yup from "yup";
|
|
7
7
|
|
|
8
8
|
import { authService } from "#core/_services/auth";
|
|
9
9
|
import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
|
|
10
10
|
import { PasswordField } from "#core/components";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
Alert,
|
|
13
|
+
Button,
|
|
14
|
+
Card,
|
|
15
|
+
Field,
|
|
16
|
+
} from "#core/components/ui";
|
|
12
17
|
import { useAuth, useI18n } from "#core/contexts";
|
|
13
18
|
import { useRequest } from "#core/hooks/use-request";
|
|
14
19
|
|
|
@@ -36,8 +41,13 @@ export function ForcePasswordChange({
|
|
|
36
41
|
const {
|
|
37
42
|
register,
|
|
38
43
|
handleSubmit,
|
|
44
|
+
control,
|
|
39
45
|
formState: { errors },
|
|
40
46
|
} = useForm({ resolver: yupResolver(schema) });
|
|
47
|
+
const [newPassword = "", confirm = ""] = useWatch({
|
|
48
|
+
control,
|
|
49
|
+
name: ["newPassword", "confirm"],
|
|
50
|
+
});
|
|
41
51
|
|
|
42
52
|
const onSubmit = handleSubmit(async (data) => {
|
|
43
53
|
const res = await run(
|
|
@@ -81,6 +91,7 @@ export function ForcePasswordChange({
|
|
|
81
91
|
>
|
|
82
92
|
<PasswordField
|
|
83
93
|
error={!!errors.newPassword}
|
|
94
|
+
rulesFor={newPassword}
|
|
84
95
|
{...register("newPassword")}
|
|
85
96
|
/>
|
|
86
97
|
</Field>
|
|
@@ -88,7 +99,11 @@ export function ForcePasswordChange({
|
|
|
88
99
|
label={t("profile.confirmPassword")}
|
|
89
100
|
error={errors.confirm?.message}
|
|
90
101
|
>
|
|
91
|
-
<PasswordField
|
|
102
|
+
<PasswordField
|
|
103
|
+
error={!!errors.confirm}
|
|
104
|
+
rulesFor={confirm}
|
|
105
|
+
{...register("confirm")}
|
|
106
|
+
/>
|
|
92
107
|
</Field>
|
|
93
108
|
<Button type="submit" size="lg" disabled={loading}>
|
|
94
109
|
{loading ? t("common.loading") : t("profile.changePassword")}
|
|
@@ -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>
|
|
@@ -339,7 +346,8 @@ export function ProfileScreen(): JSX.Element {
|
|
|
339
346
|
|
|
340
347
|
{/* diálogo: pede código TOTP para regenerar backup codes */}
|
|
341
348
|
<Dialog open={regenOpen} onOpenChange={(o) => !o && setRegenOpen(false)}>
|
|
342
|
-
|
|
349
|
+
{/* Confirmação: um campo de seis dígitos não tem o que ampliar. */}
|
|
350
|
+
<DialogContent className="max-w-sm" expandable={false}>
|
|
343
351
|
<DialogHeader>
|
|
344
352
|
<DialogTitle>{t("auth.backupTitle")}</DialogTitle>
|
|
345
353
|
</DialogHeader>
|
|
@@ -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,23 +21,28 @@ 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";
|
|
27
|
+
import { RecordAuditButton } from "#core/features/audit/components";
|
|
30
28
|
import {
|
|
31
29
|
Group,
|
|
32
30
|
rbacService,
|
|
33
31
|
Role,
|
|
34
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";
|
|
35
35
|
import { useConfirm } from "#core/hooks/use-confirm";
|
|
36
36
|
import { RequestOperation, useRequest } from "#core/hooks/use-request";
|
|
37
37
|
|
|
38
38
|
const TYPES = ["company", "department", "team"];
|
|
39
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
|
+
|
|
40
46
|
export function GroupsPanel(): JSX.Element {
|
|
41
47
|
const { run, loading } = useRequest();
|
|
42
48
|
const { t } = useI18n();
|
|
@@ -44,22 +50,29 @@ export function GroupsPanel(): JSX.Element {
|
|
|
44
50
|
const { hasPermission } = useAuth();
|
|
45
51
|
const [rows, setRows] = useState<Group[]>([]);
|
|
46
52
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
53
|
+
const [candidates, setCandidates] = useState<User[]>([]);
|
|
47
54
|
const [open, setOpen] = useState(false);
|
|
48
55
|
const [editing, setEditing] = useState<Group | null>(null);
|
|
49
56
|
const [form, setForm] = useState({
|
|
50
57
|
name: "",
|
|
51
58
|
description: "",
|
|
52
59
|
type: "team",
|
|
60
|
+
managerId: "",
|
|
53
61
|
roleIds: [] as string[],
|
|
54
62
|
});
|
|
55
63
|
|
|
56
64
|
const load = async (): Promise<void> => {
|
|
57
|
-
const [g, r] = await Promise.all([
|
|
65
|
+
const [g, r, u] = await Promise.all([
|
|
58
66
|
run(() => rbacService.listGroups()),
|
|
59
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 })),
|
|
60
72
|
]);
|
|
61
73
|
if (g) {setRows(g);}
|
|
62
74
|
if (r) {setRoles(r);}
|
|
75
|
+
if (u) {setCandidates(u.items);}
|
|
63
76
|
};
|
|
64
77
|
useEffect(() => {
|
|
65
78
|
void load();
|
|
@@ -68,7 +81,13 @@ export function GroupsPanel(): JSX.Element {
|
|
|
68
81
|
|
|
69
82
|
const openNew = (): void => {
|
|
70
83
|
setEditing(null);
|
|
71
|
-
setForm({
|
|
84
|
+
setForm({
|
|
85
|
+
name: "",
|
|
86
|
+
description: "",
|
|
87
|
+
type: "team",
|
|
88
|
+
managerId: "",
|
|
89
|
+
roleIds: [],
|
|
90
|
+
});
|
|
72
91
|
setOpen(true);
|
|
73
92
|
};
|
|
74
93
|
const openEdit = (g: Group): void => {
|
|
@@ -77,17 +96,21 @@ export function GroupsPanel(): JSX.Element {
|
|
|
77
96
|
name: g.name,
|
|
78
97
|
description: g.description ?? "",
|
|
79
98
|
type: g.type,
|
|
99
|
+
managerId: g.managerId ?? "",
|
|
80
100
|
roleIds: g.roles?.map((r) => r.id) ?? [],
|
|
81
101
|
});
|
|
82
102
|
setOpen(true);
|
|
83
103
|
};
|
|
84
104
|
|
|
85
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 };
|
|
86
109
|
const res = editing
|
|
87
|
-
? await run(() => rbacService.updateGroup(editing.id,
|
|
110
|
+
? await run(() => rbacService.updateGroup(editing.id, payload), {
|
|
88
111
|
success: RequestOperation.Update,
|
|
89
112
|
})
|
|
90
|
-
: await run(() => rbacService.createGroup(
|
|
113
|
+
: await run(() => rbacService.createGroup(payload), {
|
|
91
114
|
success: RequestOperation.Create,
|
|
92
115
|
});
|
|
93
116
|
if (res) {
|
|
@@ -134,6 +157,18 @@ export function GroupsPanel(): JSX.Element {
|
|
|
134
157
|
meta: { label: t("users.name") },
|
|
135
158
|
},
|
|
136
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
|
+
},
|
|
137
172
|
{
|
|
138
173
|
accessorKey: "description",
|
|
139
174
|
header: "Descrição",
|
|
@@ -217,22 +252,23 @@ export function GroupsPanel(): JSX.Element {
|
|
|
217
252
|
}
|
|
218
253
|
/>
|
|
219
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>
|
|
220
266
|
<Field label="Tipo">
|
|
221
|
-
<
|
|
267
|
+
<SimpleSelect
|
|
222
268
|
value={form.type}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<SelectValue />
|
|
227
|
-
</SelectTrigger>
|
|
228
|
-
<SelectContent>
|
|
229
|
-
{TYPES.map((ty) => (
|
|
230
|
-
<SelectItem key={ty} value={ty}>
|
|
231
|
-
{ty}
|
|
232
|
-
</SelectItem>
|
|
233
|
-
))}
|
|
234
|
-
</SelectContent>
|
|
235
|
-
</Select>
|
|
269
|
+
onChange={(type) => setForm({ ...form, type })}
|
|
270
|
+
options={TYPES.map((ty) => ({ value: ty, label: ty }))}
|
|
271
|
+
/>
|
|
236
272
|
</Field>
|
|
237
273
|
<div className="space-y-2">
|
|
238
274
|
<Label>Papéis</Label>
|
|
@@ -251,13 +287,25 @@ export function GroupsPanel(): JSX.Element {
|
|
|
251
287
|
))}
|
|
252
288
|
</div>
|
|
253
289
|
</div>
|
|
254
|
-
<DialogFooter className="gap-2">
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
</
|
|
290
|
+
<DialogFooter className="gap-2 sm:justify-between">
|
|
291
|
+
{/* Só na edição: registro que ainda não existe não tem histórico. */}
|
|
292
|
+
<div>
|
|
293
|
+
{editing && (
|
|
294
|
+
<RecordAuditButton entity="Group" entityId={editing.id} />
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
<div className="flex gap-2">
|
|
298
|
+
<Button
|
|
299
|
+
type="button"
|
|
300
|
+
variant="outline"
|
|
301
|
+
onClick={() => setOpen(false)}
|
|
302
|
+
>
|
|
303
|
+
{t("common.cancel")}
|
|
304
|
+
</Button>
|
|
305
|
+
<Button type="submit" disabled={loading}>
|
|
306
|
+
{t("common.save")}
|
|
307
|
+
</Button>
|
|
308
|
+
</div>
|
|
261
309
|
</DialogFooter>
|
|
262
310
|
</DialogForm>
|
|
263
311
|
</DialogContent>
|