rl-core-front 0.3.0 → 0.4.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/schema.d.ts +43 -132
- package/src/_utils/filter.ts +15 -34
- package/src/components/app-shell.tsx +51 -1
- 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/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 +1 -1
- package/src/components/ui/segmented-control.tsx +74 -0
- package/src/contexts/brand-context.tsx +15 -0
- package/src/contexts/i18n-context.tsx +69 -4
- 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/login-screen.tsx +86 -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/profile-screen.tsx +2 -1
- package/src/features/rbac/panels/groups-panel.tsx +20 -7
- package/src/features/users/components/user-form-dialog.tsx +16 -7
- package/src/features/users/hooks/use-users.ts +9 -2
- package/src/hooks/use-filters.ts +25 -2
- package/src/hooks/use-list-query.ts +14 -1
- package/src/i18n/messages/en.ts +17 -5
- package/src/i18n/messages/pt.ts +18 -5
- package/src/index.ts +12 -1
- 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
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
4
|
import { useEffect } from "react";
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import {
|
|
7
|
+
BrandHeader,
|
|
8
|
+
BrandPanel,
|
|
9
|
+
LanguageSelector,
|
|
10
|
+
ThemeToggle,
|
|
11
|
+
} from "#core/components";
|
|
12
|
+
import { Card, Separator } from "#core/components/ui";
|
|
13
|
+
import { useBrand, useI18n } from "#core/contexts";
|
|
9
14
|
import {
|
|
10
15
|
BackupCodesDialog,
|
|
11
16
|
LoginForm,
|
|
@@ -13,6 +18,7 @@ import {
|
|
|
13
18
|
TwoFactorVerify,
|
|
14
19
|
} from "#core/features/login/components";
|
|
15
20
|
import { useLoginFlow } from "#core/features/login/hooks/use-login-flow";
|
|
21
|
+
import { cn } from "#core/lib/utils";
|
|
16
22
|
|
|
17
23
|
export function LoginScreen(): JSX.Element {
|
|
18
24
|
const flow = useLoginFlow();
|
|
@@ -30,50 +36,90 @@ export function LoginScreen(): JSX.Element {
|
|
|
30
36
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
37
|
}, [flow.step]);
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<div className="absolute right-3 top-3 flex gap-1">
|
|
36
|
-
<LanguageSelector />
|
|
37
|
-
<ThemeToggle />
|
|
38
|
-
</div>
|
|
39
|
+
const { loginLayout } = useBrand();
|
|
40
|
+
const aoLado = loginLayout !== "top";
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const conteudo = (
|
|
43
|
+
<>
|
|
44
|
+
{/* A marca vem antes do título do passo: é a primeira tela do sistema, e
|
|
45
|
+
quem chega aqui precisa reconhecer onde está antes de logar. Com a
|
|
46
|
+
marca ao lado, só o título — repeti-la aqui seria dizer duas vezes. */}
|
|
47
|
+
<BrandHeader title={titles[flow.step]} showBrand={!aoLado} />
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
{flow.error && (
|
|
50
|
+
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
|
51
|
+
{flow.error}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
{flow.step === "credentials" && (
|
|
56
|
+
<LoginForm loading={flow.loading} onSubmit={flow.submitCredentials} />
|
|
57
|
+
)}
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
{flow.step === "setup" && flow.setup && (
|
|
60
|
+
<TwoFactorSetup
|
|
61
|
+
setup={flow.setup}
|
|
62
|
+
loading={flow.loading}
|
|
63
|
+
onConfirm={(code) => flow.submitCode(code, "totp")}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{flow.step === "verify" && (
|
|
68
|
+
<TwoFactorVerify
|
|
69
|
+
loading={flow.loading}
|
|
70
|
+
onVerify={flow.submitCode}
|
|
71
|
+
onRequestEmail={flow.requestEmailCode}
|
|
72
|
+
/>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
<BackupCodesDialog
|
|
76
|
+
open={flow.step === "backup"}
|
|
77
|
+
codes={flow.backupCodes}
|
|
78
|
+
onClose={flow.finishBackup}
|
|
79
|
+
/>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
const seletores = (
|
|
84
|
+
<div className="absolute right-3 top-3 z-10 flex gap-1">
|
|
85
|
+
<LanguageSelector />
|
|
86
|
+
<ThemeToggle />
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (aoLado) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="relative min-h-screen bg-background">
|
|
93
|
+
{seletores}
|
|
94
|
+
{/* Empilhado em tela estreita, lado a lado a partir do `lg`. No DOM a
|
|
95
|
+
marca vem primeiro — no celular é ela que deve abrir a tela, nos
|
|
96
|
+
dois casos. Quem troca os lados no desktop é o `order`, e o divisor
|
|
97
|
+
precisa do dele para não sobrar na ponta. */}
|
|
98
|
+
<div className="flex min-h-screen flex-col lg:flex-row">
|
|
99
|
+
<BrandPanel
|
|
100
|
+
className={loginLayout === "right" ? "lg:order-3" : "lg:order-1"}
|
|
101
|
+
/>
|
|
102
|
+
<Separator
|
|
103
|
+
orientation="horizontal"
|
|
104
|
+
className="lg:order-2 lg:h-auto lg:w-px lg:self-stretch"
|
|
68
105
|
/>
|
|
69
|
-
|
|
106
|
+
<div
|
|
107
|
+
className={cn(
|
|
108
|
+
"flex flex-1 items-center justify-center p-4",
|
|
109
|
+
loginLayout === "right" ? "lg:order-1" : "lg:order-3",
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
<Card className="w-full max-w-md p-8">{conteudo}</Card>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
70
118
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/>
|
|
76
|
-
</Card>
|
|
119
|
+
return (
|
|
120
|
+
<div className="relative flex min-h-screen items-center justify-center bg-background p-4">
|
|
121
|
+
{seletores}
|
|
122
|
+
<Card className="w-full max-w-md p-8">{conteudo}</Card>
|
|
77
123
|
</div>
|
|
78
124
|
);
|
|
79
125
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Categoria do erro (espelha o ErrorType do backend: request_logs.error_type).
|
|
2
|
+
export enum ErrorType {
|
|
3
|
+
VALIDATION = 0,
|
|
4
|
+
AUTH = 1,
|
|
5
|
+
NOT_FOUND = 2,
|
|
6
|
+
RATE_LIMIT = 3,
|
|
7
|
+
DATABASE = 4,
|
|
8
|
+
INTEGRATION = 5,
|
|
9
|
+
INTERNAL = 6,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Chave de i18n de cada valor — o texto mora em `i18n/messages` (§6). */
|
|
13
|
+
export const ErrorTypeLabelKey: Record<ErrorType, string> = {
|
|
14
|
+
[ErrorType.VALIDATION]: "logs.errorValidation",
|
|
15
|
+
[ErrorType.AUTH]: "logs.errorAuth",
|
|
16
|
+
[ErrorType.NOT_FOUND]: "logs.errorNotFound",
|
|
17
|
+
[ErrorType.RATE_LIMIT]: "logs.errorRateLimit",
|
|
18
|
+
[ErrorType.DATABASE]: "logs.errorDatabase",
|
|
19
|
+
[ErrorType.INTEGRATION]: "logs.errorIntegration",
|
|
20
|
+
[ErrorType.INTERNAL]: "logs.errorInternal",
|
|
21
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Desfecho da requisição (espelha o RequestOutcome do backend:
|
|
2
|
+
// request_logs.outcome).
|
|
3
|
+
export enum RequestOutcome {
|
|
4
|
+
SUCCESS = 0,
|
|
5
|
+
ERROR = 1,
|
|
6
|
+
ABORTED = 2,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Chave de i18n de cada valor — o texto mora em `i18n/messages` (§6). */
|
|
10
|
+
export const RequestOutcomeLabelKey: Record<RequestOutcome, string> = {
|
|
11
|
+
[RequestOutcome.SUCCESS]: "logs.outcomeSuccess",
|
|
12
|
+
[RequestOutcome.ERROR]: "logs.outcomeError",
|
|
13
|
+
[RequestOutcome.ABORTED]: "logs.outcomeAborted",
|
|
14
|
+
};
|
|
@@ -9,11 +9,23 @@ import {
|
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
11
|
|
|
12
|
+
import type { FilterValues } from "#core/_utils/filter";
|
|
13
|
+
import { RequestOutcome } from "#core/features/logs/enums/request-outcome.enum";
|
|
12
14
|
import { logsService, RequestLog } from "#core/features/logs/services/logs.service";
|
|
13
15
|
import type { FiltersState } from "#core/hooks/use-filters";
|
|
14
16
|
import { useListQuery } from "#core/hooks/use-list-query";
|
|
15
17
|
import { useRequest } from "#core/hooks/use-request";
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* A tela abre no sucesso, não em tudo.
|
|
21
|
+
*
|
|
22
|
+
* Fora do módulo para não recriar o objeto a cada render: ele é dependência do
|
|
23
|
+
* efeito de hidratação, e um literal novo por render o refaria sem parar.
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_FILTER: FilterValues = {
|
|
26
|
+
outcome: { values: [String(RequestOutcome.SUCCESS)] },
|
|
27
|
+
};
|
|
28
|
+
|
|
17
29
|
export interface UseRequestLogsResult {
|
|
18
30
|
rows: RequestLog[];
|
|
19
31
|
total: number;
|
|
@@ -32,7 +44,11 @@ export interface UseRequestLogsResult {
|
|
|
32
44
|
export function useRequestLogs(): UseRequestLogsResult {
|
|
33
45
|
const { run, loading } = useRequest();
|
|
34
46
|
|
|
35
|
-
const list = useListQuery(
|
|
47
|
+
const list = useListQuery(
|
|
48
|
+
"/audit/requests/filter-schema",
|
|
49
|
+
20,
|
|
50
|
+
DEFAULT_FILTER,
|
|
51
|
+
);
|
|
36
52
|
|
|
37
53
|
const [rows, setRows] = useState<RequestLog[]>([]);
|
|
38
54
|
const [total, setTotal] = useState(0);
|
|
@@ -45,9 +61,14 @@ export function useRequestLogs(): UseRequestLogsResult {
|
|
|
45
61
|
}
|
|
46
62
|
}, [run, list.query]);
|
|
47
63
|
|
|
64
|
+
// Não busca antes da URL ser lida: a query sairia sem o filtro, e essa
|
|
65
|
+
// resposta pode chegar depois da certa e pintar a tela com tudo.
|
|
48
66
|
useEffect(() => {
|
|
67
|
+
if (!list.ready) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
49
70
|
void fetch();
|
|
50
|
-
}, [fetch]);
|
|
71
|
+
}, [fetch, list.ready]);
|
|
51
72
|
|
|
52
73
|
return {
|
|
53
74
|
rows,
|
|
@@ -55,7 +76,9 @@ export function useRequestLogs(): UseRequestLogsResult {
|
|
|
55
76
|
page: list.page,
|
|
56
77
|
pageSize: list.pageSize,
|
|
57
78
|
sorting: list.sorting,
|
|
58
|
-
|
|
79
|
+
// Esperar o catálogo também é carregar: sem isso a tabela pisca
|
|
80
|
+
// "Nenhum registro" antes da primeira busca sair.
|
|
81
|
+
loading: loading || !list.ready,
|
|
59
82
|
setPage: list.setPage,
|
|
60
83
|
setPageSize: list.setPageSize,
|
|
61
84
|
setSorting: list.setSorting,
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import type { JSX } from "react";
|
|
4
4
|
|
|
5
|
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from "#core/components/ui";
|
|
6
5
|
import { useI18n } from "#core/contexts";
|
|
7
|
-
import {
|
|
6
|
+
import { RequestLogsPanel } from "#core/features/logs/panels";
|
|
8
7
|
|
|
9
8
|
export function LogsScreen(): JSX.Element {
|
|
10
9
|
const { t } = useI18n();
|
|
@@ -14,18 +13,7 @@ export function LogsScreen(): JSX.Element {
|
|
|
14
13
|
<h1 className="mb-1 text-3xl font-bold">{t("logs.title")}</h1>
|
|
15
14
|
<p className="mb-4 text-sm text-muted-foreground">{t("logs.subtitle")}</p>
|
|
16
15
|
|
|
17
|
-
<
|
|
18
|
-
<TabsList>
|
|
19
|
-
<TabsTrigger value="requests">{t("logs.tabRequests")}</TabsTrigger>
|
|
20
|
-
<TabsTrigger value="errors">{t("logs.tabErrors")}</TabsTrigger>
|
|
21
|
-
</TabsList>
|
|
22
|
-
<TabsContent value="requests">
|
|
23
|
-
<RequestLogsPanel />
|
|
24
|
-
</TabsContent>
|
|
25
|
-
<TabsContent value="errors">
|
|
26
|
-
<ErrorLogsPanel />
|
|
27
|
-
</TabsContent>
|
|
28
|
-
</Tabs>
|
|
16
|
+
<RequestLogsPanel />
|
|
29
17
|
</div>
|
|
30
18
|
);
|
|
31
19
|
}
|
|
@@ -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 {
|
|
@@ -339,7 +339,8 @@ export function ProfileScreen(): JSX.Element {
|
|
|
339
339
|
|
|
340
340
|
{/* diálogo: pede código TOTP para regenerar backup codes */}
|
|
341
341
|
<Dialog open={regenOpen} onOpenChange={(o) => !o && setRegenOpen(false)}>
|
|
342
|
-
|
|
342
|
+
{/* Confirmação: um campo de seis dígitos não tem o que ampliar. */}
|
|
343
|
+
<DialogContent className="max-w-sm" expandable={false}>
|
|
343
344
|
<DialogHeader>
|
|
344
345
|
<DialogTitle>{t("auth.backupTitle")}</DialogTitle>
|
|
345
346
|
</DialogHeader>
|