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
|
@@ -4,6 +4,7 @@ import { AlertTriangle } from "lucide-react";
|
|
|
4
4
|
import type { JSX } from "react";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
Alert,
|
|
7
8
|
Button,
|
|
8
9
|
Dialog,
|
|
9
10
|
DialogContent,
|
|
@@ -43,10 +44,9 @@ export function BackupCodesDialog({ open, codes, onClose }: Props): JSX.Element
|
|
|
43
44
|
<DialogTitle>{t("auth.backupTitle")}</DialogTitle>
|
|
44
45
|
</DialogHeader>
|
|
45
46
|
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
</div>
|
|
47
|
+
<Alert variant="warning" icon={<AlertTriangle />}>
|
|
48
|
+
{t("auth.backupWarning")}
|
|
49
|
+
</Alert>
|
|
50
50
|
|
|
51
51
|
<div className="grid grid-cols-2 gap-2 rounded-lg bg-muted p-4 font-mono text-sm text-foreground">
|
|
52
52
|
{codes.map((c) => (
|
|
@@ -17,16 +17,29 @@ import {
|
|
|
17
17
|
interface Props {
|
|
18
18
|
loading: boolean;
|
|
19
19
|
onSubmit: (email: string, password: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Email já preenchido. Serve a quem foi devolvido para cá porque a
|
|
22
|
+
* verificação de 2FA venceu: a senha o sistema não pode guardar, mas fazer a
|
|
23
|
+
* pessoa redigitar o email também seria gratuito.
|
|
24
|
+
*/
|
|
25
|
+
defaultEmail?: string;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
export function LoginForm({
|
|
28
|
+
export function LoginForm({
|
|
29
|
+
loading,
|
|
30
|
+
onSubmit,
|
|
31
|
+
defaultEmail,
|
|
32
|
+
}: Props): JSX.Element {
|
|
23
33
|
const { t } = useI18n();
|
|
24
34
|
const schema = useMemo(() => makeLoginSchema(t), [t]);
|
|
25
35
|
const {
|
|
26
36
|
register,
|
|
27
37
|
handleSubmit,
|
|
28
38
|
formState: { errors },
|
|
29
|
-
} = useForm<LoginFormData>({
|
|
39
|
+
} = useForm<LoginFormData>({
|
|
40
|
+
resolver: yupResolver(schema),
|
|
41
|
+
defaultValues: { email: defaultEmail ?? "", password: "" },
|
|
42
|
+
});
|
|
30
43
|
|
|
31
44
|
return (
|
|
32
45
|
<form
|
|
@@ -37,13 +50,15 @@ export function LoginForm({ loading, onSubmit }: Props): JSX.Element {
|
|
|
37
50
|
<Input
|
|
38
51
|
type="email"
|
|
39
52
|
autoComplete="email"
|
|
40
|
-
|
|
53
|
+
// Com o email já preenchido, o cursor vai direto para o que falta.
|
|
54
|
+
autoFocus={!defaultEmail}
|
|
41
55
|
{...register("email")}
|
|
42
56
|
/>
|
|
43
57
|
</Field>
|
|
44
58
|
<Field label={t("auth.password")} error={errors.password?.message}>
|
|
45
59
|
<PasswordField
|
|
46
60
|
autoComplete="current-password"
|
|
61
|
+
autoFocus={!!defaultEmail}
|
|
47
62
|
error={!!errors.password}
|
|
48
63
|
{...register("password")}
|
|
49
64
|
/>
|
|
@@ -13,15 +13,25 @@ import {
|
|
|
13
13
|
CodeFormData,
|
|
14
14
|
makeCodeSchema,
|
|
15
15
|
} from "#core/features/login/validation/schemas";
|
|
16
|
+
import { useCountdown } from "#core/hooks/use-countdown";
|
|
17
|
+
import { cn } from "#core/lib/utils";
|
|
16
18
|
|
|
17
19
|
interface Props {
|
|
18
20
|
setup: TotpSetup;
|
|
19
21
|
loading: boolean;
|
|
20
22
|
onConfirm: (code: string) => void;
|
|
23
|
+
/** Prazo para confirmar, em segundos. `null` esconde o contador. */
|
|
24
|
+
expiresInSeconds?: number | null;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
export function TwoFactorSetup({
|
|
27
|
+
export function TwoFactorSetup({
|
|
28
|
+
setup,
|
|
29
|
+
loading,
|
|
30
|
+
onConfirm,
|
|
31
|
+
expiresInSeconds = null,
|
|
32
|
+
}: Props): JSX.Element {
|
|
24
33
|
const { t } = useI18n();
|
|
34
|
+
const { formatted, remaining, expired } = useCountdown(expiresInSeconds);
|
|
25
35
|
const schema = useMemo(() => makeCodeSchema(t), [t]);
|
|
26
36
|
const {
|
|
27
37
|
register,
|
|
@@ -62,7 +72,27 @@ export function TwoFactorSetup({ setup, loading, onConfirm }: Props): JSX.Elemen
|
|
|
62
72
|
{...register("code")}
|
|
63
73
|
/>
|
|
64
74
|
</Field>
|
|
65
|
-
|
|
75
|
+
{/*
|
|
76
|
+
O prazo fica à vista porque é justamente aqui que a pessoa demora —
|
|
77
|
+
instalar o app, escanear, digitar. Antes ele vencia em silêncio e o
|
|
78
|
+
código certo voltava como inválido.
|
|
79
|
+
*/}
|
|
80
|
+
{expiresInSeconds !== null && (
|
|
81
|
+
<p
|
|
82
|
+
className={cn(
|
|
83
|
+
"text-center text-xs",
|
|
84
|
+
// Último minuto: deixa de ser informação e passa a ser aviso.
|
|
85
|
+
remaining <= 60 ? "text-warning-strong" : "text-muted-foreground",
|
|
86
|
+
)}
|
|
87
|
+
aria-live="polite"
|
|
88
|
+
>
|
|
89
|
+
{expired
|
|
90
|
+
? t("auth.expiredHint")
|
|
91
|
+
: t("auth.expiresIn", { time: formatted })}
|
|
92
|
+
</p>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
<Button type="submit" size="lg" disabled={loading || expired}>
|
|
66
96
|
{loading ? t("auth.confirming") : t("auth.confirmActivate")}
|
|
67
97
|
</Button>
|
|
68
98
|
</form>
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
|
|
11
|
+
import { AppErrorCode } from "#core/_services/api/app-error-code.enum";
|
|
11
12
|
import { authService, TotpSetup } from "#core/_services/auth";
|
|
12
13
|
import { useAuth, useI18n, useToast } from "#core/contexts";
|
|
13
14
|
import { useRequest } from "#core/hooks/use-request";
|
|
@@ -26,6 +27,15 @@ export interface UseLoginFlowResult {
|
|
|
26
27
|
loading: boolean;
|
|
27
28
|
error: string | null;
|
|
28
29
|
setError: Dispatch<SetStateAction<string | null>>;
|
|
30
|
+
/**
|
|
31
|
+
* Aviso que explica por que a tela voltou ao início. Diferente de `error`,
|
|
32
|
+
* atravessa a troca de passo.
|
|
33
|
+
*/
|
|
34
|
+
notice: string | null;
|
|
35
|
+
/** Último email digitado — repõe o campo quando a verificação vence. */
|
|
36
|
+
email: string;
|
|
37
|
+
/** Prazo da etapa do código, em segundos. `null` fora dessa etapa. */
|
|
38
|
+
expiresInSeconds: number | null;
|
|
29
39
|
submitCredentials: (email: string, password: string) => Promise<void>;
|
|
30
40
|
submitCode: (
|
|
31
41
|
code: string,
|
|
@@ -46,15 +56,52 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
46
56
|
const [pendingToken, setPendingToken] = useState("");
|
|
47
57
|
const [setup, setSetup] = useState<TotpSetup | null>(null);
|
|
48
58
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
59
|
+
const [email, setEmail] = useState("");
|
|
60
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
61
|
+
const [expiresInSeconds, setExpiresInSeconds] = useState<number | null>(null);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A janela entre a senha e o código venceu.
|
|
65
|
+
*
|
|
66
|
+
* Devolve ao primeiro passo em vez de deixar a pessoa presa numa tela cujo
|
|
67
|
+
* botão só responde erro — que era o que acontecia no primeiro acesso: o
|
|
68
|
+
* usuário instalava o autenticador, digitava o código certo e recebia
|
|
69
|
+
* "inválido", sem caminho nenhum. O email volta preenchido, e o QR do próximo
|
|
70
|
+
* login é o mesmo (o backend reaproveita o segredo pendente), então quem já
|
|
71
|
+
* escaneou não precisa escanear de novo.
|
|
72
|
+
*/
|
|
73
|
+
const onExpired = useCallback(
|
|
74
|
+
({ code }: { code: string | null }) => {
|
|
75
|
+
if (code !== AppErrorCode.PENDING_TOKEN_EXPIRED) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
setStep("credentials");
|
|
79
|
+
setSetup(null);
|
|
80
|
+
setPendingToken("");
|
|
81
|
+
setExpiresInSeconds(null);
|
|
82
|
+
/*
|
|
83
|
+
* Vai em `notice`, e não em `error`: a tela limpa o erro a cada troca de
|
|
84
|
+
* passo, e é justamente um passo que estamos trocando aqui — o aviso
|
|
85
|
+
* sumiria no mesmo render em que aparece.
|
|
86
|
+
*/
|
|
87
|
+
setNotice(t("auth.verificationExpired"));
|
|
88
|
+
},
|
|
89
|
+
[t],
|
|
90
|
+
);
|
|
49
91
|
|
|
50
92
|
const submitCredentials = useCallback(
|
|
51
93
|
async (email: string, password: string) => {
|
|
94
|
+
setEmail(email);
|
|
95
|
+
setNotice(null);
|
|
52
96
|
const res = await run(() => authService.login(email, password));
|
|
53
97
|
if (!res) {return;}
|
|
54
98
|
setPendingToken(res.pendingToken);
|
|
99
|
+
setExpiresInSeconds(res.expiresInSeconds);
|
|
55
100
|
|
|
56
101
|
if (res.status === "SETUP_REQUIRED") {
|
|
57
|
-
const s = await run(() => authService.setupTotp(res.pendingToken)
|
|
102
|
+
const s = await run(() => authService.setupTotp(res.pendingToken), {
|
|
103
|
+
onError: onExpired,
|
|
104
|
+
});
|
|
58
105
|
if (!s) {return;}
|
|
59
106
|
setSetup(s);
|
|
60
107
|
setStep("setup");
|
|
@@ -70,13 +117,14 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
70
117
|
.catch(() => {});
|
|
71
118
|
}
|
|
72
119
|
},
|
|
73
|
-
[run, notify, t],
|
|
120
|
+
[run, notify, t, onExpired],
|
|
74
121
|
);
|
|
75
122
|
|
|
76
123
|
const submitCode = useCallback(
|
|
77
124
|
async (code: string, method?: "totp" | "email" | "backup") => {
|
|
78
|
-
const res = await run(
|
|
79
|
-
authService.verify(pendingToken, code, method),
|
|
125
|
+
const res = await run(
|
|
126
|
+
() => authService.verify(pendingToken, code, method),
|
|
127
|
+
{ onError: onExpired },
|
|
80
128
|
);
|
|
81
129
|
if (!res) {return;}
|
|
82
130
|
|
|
@@ -89,13 +137,15 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
89
137
|
router.replace("/dashboard");
|
|
90
138
|
}
|
|
91
139
|
},
|
|
92
|
-
[run, pendingToken, notify, router, t, refreshProfile],
|
|
140
|
+
[run, pendingToken, notify, router, t, refreshProfile, onExpired],
|
|
93
141
|
);
|
|
94
142
|
|
|
95
143
|
const requestEmailCode = useCallback(async () => {
|
|
96
|
-
const res = await run(() => authService.requestEmailCode(pendingToken)
|
|
144
|
+
const res = await run(() => authService.requestEmailCode(pendingToken), {
|
|
145
|
+
onError: onExpired,
|
|
146
|
+
});
|
|
97
147
|
if (res?.sent) {notify(t("auth.codeSent"), "success");}
|
|
98
|
-
}, [run, pendingToken, notify, t]);
|
|
148
|
+
}, [run, pendingToken, notify, t, onExpired]);
|
|
99
149
|
|
|
100
150
|
const finishBackup = useCallback(async () => {
|
|
101
151
|
await refreshProfile();
|
|
@@ -111,6 +161,9 @@ export function useLoginFlow(): UseLoginFlowResult {
|
|
|
111
161
|
loading,
|
|
112
162
|
error,
|
|
113
163
|
setError,
|
|
164
|
+
notice,
|
|
165
|
+
email,
|
|
166
|
+
expiresInSeconds,
|
|
114
167
|
submitCredentials,
|
|
115
168
|
submitCode,
|
|
116
169
|
requestEmailCode,
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Clock } from "lucide-react";
|
|
3
4
|
import type { JSX } from "react";
|
|
4
5
|
import { useEffect } from "react";
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
BrandHeader,
|
|
9
|
+
BrandPanel,
|
|
10
|
+
LanguageSelector,
|
|
11
|
+
ThemeToggle,
|
|
12
|
+
} from "#core/components";
|
|
13
|
+
import { Alert, Card, Separator } from "#core/components/ui";
|
|
14
|
+
import { useBrand, useI18n } from "#core/contexts";
|
|
9
15
|
import {
|
|
10
16
|
BackupCodesDialog,
|
|
11
17
|
LoginForm,
|
|
@@ -13,6 +19,7 @@ import {
|
|
|
13
19
|
TwoFactorVerify,
|
|
14
20
|
} from "#core/features/login/components";
|
|
15
21
|
import { useLoginFlow } from "#core/features/login/hooks/use-login-flow";
|
|
22
|
+
import { cn } from "#core/lib/utils";
|
|
16
23
|
|
|
17
24
|
export function LoginScreen(): JSX.Element {
|
|
18
25
|
const flow = useLoginFlow();
|
|
@@ -30,50 +37,102 @@ export function LoginScreen(): JSX.Element {
|
|
|
30
37
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
31
38
|
}, [flow.step]);
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<div className="absolute right-3 top-3 flex gap-1">
|
|
36
|
-
<LanguageSelector />
|
|
37
|
-
<ThemeToggle />
|
|
38
|
-
</div>
|
|
40
|
+
const { loginLayout } = useBrand();
|
|
41
|
+
const aoLado = loginLayout !== "top";
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
const conteudo = (
|
|
44
|
+
<>
|
|
45
|
+
{/* A marca vem antes do título do passo: é a primeira tela do sistema, e
|
|
46
|
+
quem chega aqui precisa reconhecer onde está antes de logar. Com a
|
|
47
|
+
marca ao lado, só o título — repeti-la aqui seria dizer duas vezes. */}
|
|
48
|
+
<BrandHeader title={titles[flow.step]} showBrand={!aoLado} />
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
{/* Explica por que a tela voltou ao início; não é falha de credencial. */}
|
|
51
|
+
{flow.notice && (
|
|
52
|
+
<Alert variant="warning" icon={<Clock />} className="mb-4">
|
|
53
|
+
{flow.notice}
|
|
54
|
+
</Alert>
|
|
55
|
+
)}
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
{flow.error && (
|
|
58
|
+
<Alert variant="error" className="mb-4">
|
|
59
|
+
{flow.error}
|
|
60
|
+
</Alert>
|
|
61
|
+
)}
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
{flow.step === "credentials" && (
|
|
64
|
+
<LoginForm
|
|
65
|
+
loading={flow.loading}
|
|
66
|
+
onSubmit={flow.submitCredentials}
|
|
67
|
+
defaultEmail={flow.email}
|
|
68
|
+
/>
|
|
69
|
+
)}
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
{flow.step === "setup" && flow.setup && (
|
|
72
|
+
<TwoFactorSetup
|
|
73
|
+
setup={flow.setup}
|
|
74
|
+
loading={flow.loading}
|
|
75
|
+
expiresInSeconds={flow.expiresInSeconds}
|
|
76
|
+
onConfirm={(code) => flow.submitCode(code, "totp")}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
70
79
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
{flow.step === "verify" && (
|
|
81
|
+
<TwoFactorVerify
|
|
82
|
+
loading={flow.loading}
|
|
83
|
+
onVerify={flow.submitCode}
|
|
84
|
+
onRequestEmail={flow.requestEmailCode}
|
|
75
85
|
/>
|
|
76
|
-
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<BackupCodesDialog
|
|
89
|
+
open={flow.step === "backup"}
|
|
90
|
+
codes={flow.backupCodes}
|
|
91
|
+
onClose={flow.finishBackup}
|
|
92
|
+
/>
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const seletores = (
|
|
97
|
+
<div className="absolute right-3 top-3 z-10 flex gap-1">
|
|
98
|
+
<LanguageSelector />
|
|
99
|
+
<ThemeToggle />
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
if (aoLado) {
|
|
104
|
+
return (
|
|
105
|
+
<div className="relative min-h-screen bg-background">
|
|
106
|
+
{seletores}
|
|
107
|
+
{/* Empilhado em tela estreita, lado a lado a partir do `lg`. No DOM a
|
|
108
|
+
marca vem primeiro — no celular é ela que deve abrir a tela, nos
|
|
109
|
+
dois casos. Quem troca os lados no desktop é o `order`, e o divisor
|
|
110
|
+
precisa do dele para não sobrar na ponta. */}
|
|
111
|
+
<div className="flex min-h-screen flex-col lg:flex-row">
|
|
112
|
+
<BrandPanel
|
|
113
|
+
className={loginLayout === "right" ? "lg:order-3" : "lg:order-1"}
|
|
114
|
+
/>
|
|
115
|
+
<Separator
|
|
116
|
+
orientation="horizontal"
|
|
117
|
+
className="lg:order-2 lg:h-auto lg:w-px lg:self-stretch"
|
|
118
|
+
/>
|
|
119
|
+
<div
|
|
120
|
+
className={cn(
|
|
121
|
+
"flex flex-1 items-center justify-center p-4",
|
|
122
|
+
loginLayout === "right" ? "lg:order-1" : "lg:order-3",
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
<Card className="w-full max-w-md p-8">{conteudo}</Card>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="relative flex min-h-screen items-center justify-center bg-background p-4">
|
|
134
|
+
{seletores}
|
|
135
|
+
<Card className="w-full max-w-md p-8">{conteudo}</Card>
|
|
77
136
|
</div>
|
|
78
137
|
);
|
|
79
138
|
}
|
|
@@ -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
|
}
|