rl-core-front 0.1.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/README.md +129 -0
- package/package.json +113 -0
- package/src/_services/api/axios.factory.ts +97 -0
- package/src/_services/api/list-query.ts +26 -0
- package/src/_services/api/schema.d.ts +2527 -0
- package/src/_services/auth/index.ts +127 -0
- package/src/_utils/advanced-filter.ts +386 -0
- package/src/_utils/calendar.ts +103 -0
- package/src/_utils/crop-image.ts +65 -0
- package/src/_utils/filter.ts +313 -0
- package/src/_utils/format.ts +35 -0
- package/src/_utils/initials.ts +24 -0
- package/src/_utils/password.ts +7 -0
- package/src/components/app-shell.tsx +314 -0
- package/src/components/breadcrumbs.tsx +55 -0
- package/src/components/index.ts +7 -0
- package/src/components/language-selector.tsx +51 -0
- package/src/components/password-field.tsx +40 -0
- package/src/components/theme-toggle.tsx +27 -0
- package/src/components/ui/alert.tsx +27 -0
- package/src/components/ui/avatar.tsx +50 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +86 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/column-visibility-modal.tsx +110 -0
- package/src/components/ui/confirm-dialog.tsx +69 -0
- package/src/components/ui/data-table.tsx +401 -0
- package/src/components/ui/date-range-picker.tsx +250 -0
- package/src/components/ui/dialog.tsx +138 -0
- package/src/components/ui/dropdown-menu.tsx +108 -0
- package/src/components/ui/field.tsx +20 -0
- package/src/components/ui/filter-chips.tsx +88 -0
- package/src/components/ui/filter-field.tsx +137 -0
- package/src/components/ui/filter-group.tsx +155 -0
- package/src/components/ui/filter-rule.tsx +221 -0
- package/src/components/ui/filter-sheet.tsx +201 -0
- package/src/components/ui/index.ts +30 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +23 -0
- package/src/components/ui/row-actions.tsx +32 -0
- package/src/components/ui/select.tsx +97 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/sheet.tsx +119 -0
- package/src/components/ui/sonner.tsx +28 -0
- package/src/components/ui/spinner.tsx +12 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/table.tsx +84 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/user-avatar.tsx +54 -0
- package/src/contexts/auth-context.tsx +90 -0
- package/src/contexts/color-mode-context.tsx +105 -0
- package/src/contexts/i18n-context.tsx +76 -0
- package/src/contexts/index.ts +6 -0
- package/src/contexts/socket-context.tsx +153 -0
- package/src/contexts/toast-context.tsx +40 -0
- package/src/features/audit/audit-screen.tsx +299 -0
- package/src/features/audit/hooks/use-audit-trail.ts +68 -0
- package/src/features/audit/services/audit.service.ts +24 -0
- package/src/features/dashboard/dashboard-screen.tsx +73 -0
- package/src/features/login/components/backup-codes-dialog.tsx +66 -0
- package/src/features/login/components/index.ts +5 -0
- package/src/features/login/components/login-form.tsx +62 -0
- package/src/features/login/components/two-factor-setup.tsx +71 -0
- package/src/features/login/components/two-factor-verify.tsx +56 -0
- package/src/features/login/hooks/use-login-flow.ts +119 -0
- package/src/features/login/login-screen.tsx +83 -0
- package/src/features/login/validation/schemas.ts +30 -0
- package/src/features/logs/hooks/use-error-logs.ts +65 -0
- package/src/features/logs/hooks/use-request-logs.ts +65 -0
- package/src/features/logs/logs-screen.tsx +31 -0
- package/src/features/logs/panels/error-logs-panel.tsx +183 -0
- package/src/features/logs/panels/index.ts +3 -0
- package/src/features/logs/panels/request-logs-panel.tsx +124 -0
- package/src/features/logs/services/logs.service.ts +68 -0
- package/src/features/notifications/components/notification-item.tsx +67 -0
- package/src/features/notifications/hooks/use-notifications.ts +78 -0
- package/src/features/notifications/notifications-center.tsx +119 -0
- package/src/features/notifications/services/notifications.service.ts +38 -0
- package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
- package/src/features/profile/components/index.ts +2 -0
- package/src/features/profile/force-password-change.tsx +114 -0
- package/src/features/profile/profile-screen.tsx +385 -0
- package/src/features/rbac/panels/actions-panel.tsx +20 -0
- package/src/features/rbac/panels/catalog-panel.tsx +243 -0
- package/src/features/rbac/panels/groups-panel.tsx +269 -0
- package/src/features/rbac/panels/index.ts +8 -0
- package/src/features/rbac/panels/permissions-panel.tsx +204 -0
- package/src/features/rbac/panels/resources-panel.tsx +20 -0
- package/src/features/rbac/panels/roles-panel.tsx +224 -0
- package/src/features/rbac/panels/scopes-panel.tsx +20 -0
- package/src/features/rbac/rbac-screen.tsx +80 -0
- package/src/features/rbac/services/rbac.service.ts +110 -0
- package/src/features/recovery/forgot-password-screen.tsx +69 -0
- package/src/features/recovery/reset-password-screen.tsx +163 -0
- package/src/features/users/components/user-form-dialog.tsx +187 -0
- package/src/features/users/hooks/use-users.ts +181 -0
- package/src/features/users/services/users.service.ts +83 -0
- package/src/features/users/users-screen.tsx +331 -0
- package/src/hooks/use-column-visibility.ts +54 -0
- package/src/hooks/use-confirm.tsx +70 -0
- package/src/hooks/use-filter-schema.ts +85 -0
- package/src/hooks/use-filters.ts +148 -0
- package/src/hooks/use-list-query.ts +69 -0
- package/src/hooks/use-request.ts +114 -0
- package/src/i18n/messages/en.ts +296 -0
- package/src/i18n/messages/pt.ts +305 -0
- package/src/index.ts +60 -0
- package/src/lib/utils.ts +6 -0
- package/src/middleware.ts +42 -0
- package/src/styles/core.css +142 -0
- package/tailwind-preset.ts +103 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { yupResolver } from "@hookform/resolvers/yup";
|
|
4
|
+
import NextLink from "next/link";
|
|
5
|
+
import type { JSX } from "react";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import * as yup from "yup";
|
|
9
|
+
|
|
10
|
+
import { authService } from "#core/_services/auth";
|
|
11
|
+
import { Alert, Button, Card, Field, Input } from "#core/components/ui";
|
|
12
|
+
import { useI18n } from "#core/contexts";
|
|
13
|
+
import { useRequest } from "#core/hooks/use-request";
|
|
14
|
+
|
|
15
|
+
export function ForgotPasswordScreen(): JSX.Element {
|
|
16
|
+
const { run, loading } = useRequest();
|
|
17
|
+
const { t } = useI18n();
|
|
18
|
+
const [sent, setSent] = useState(false);
|
|
19
|
+
const schema = yup.object({
|
|
20
|
+
email: yup
|
|
21
|
+
.string()
|
|
22
|
+
.required(t("validation.required"))
|
|
23
|
+
.email(t("validation.email")),
|
|
24
|
+
});
|
|
25
|
+
const {
|
|
26
|
+
register,
|
|
27
|
+
handleSubmit,
|
|
28
|
+
formState: { errors },
|
|
29
|
+
} = useForm<{ email: string }>({ resolver: yupResolver(schema) });
|
|
30
|
+
|
|
31
|
+
const onSubmit = async (data: { email: string }): Promise<void> => {
|
|
32
|
+
await run(() => authService.forgotPassword(data.email));
|
|
33
|
+
setSent(true);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
38
|
+
<Card className="w-full max-w-md p-8">
|
|
39
|
+
<h1 className="mb-4 text-xl font-semibold">
|
|
40
|
+
{t("recovery.forgotTitle")}
|
|
41
|
+
</h1>
|
|
42
|
+
{sent ? (
|
|
43
|
+
<Alert variant="success">{t("recovery.forgotSuccess")}</Alert>
|
|
44
|
+
) : (
|
|
45
|
+
<form
|
|
46
|
+
onSubmit={handleSubmit(onSubmit)}
|
|
47
|
+
className="flex flex-col gap-4"
|
|
48
|
+
>
|
|
49
|
+
<p className="text-sm text-muted-foreground">
|
|
50
|
+
{t("recovery.forgotHint")}
|
|
51
|
+
</p>
|
|
52
|
+
<Field label={t("auth.email")} error={errors.email?.message}>
|
|
53
|
+
<Input type="email" autoFocus {...register("email")} />
|
|
54
|
+
</Field>
|
|
55
|
+
<Button type="submit" size="lg" disabled={loading}>
|
|
56
|
+
{loading ? t("recovery.sending") : t("recovery.sendLink")}
|
|
57
|
+
</Button>
|
|
58
|
+
</form>
|
|
59
|
+
)}
|
|
60
|
+
<NextLink
|
|
61
|
+
href="/login"
|
|
62
|
+
className="mt-4 block text-center text-sm text-primary hover:underline"
|
|
63
|
+
>
|
|
64
|
+
{t("recovery.backToLogin")}
|
|
65
|
+
</NextLink>
|
|
66
|
+
</Card>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { yupResolver } from "@hookform/resolvers/yup";
|
|
4
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
5
|
+
import type { JSX } from "react";
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import * as yup from "yup";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
authService,
|
|
12
|
+
PasswordTokenCheck,
|
|
13
|
+
PasswordTokenStatus,
|
|
14
|
+
} from "#core/_services/auth";
|
|
15
|
+
import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
|
|
16
|
+
import { PasswordField } from "#core/components";
|
|
17
|
+
import { Alert, Button, Card, Field, Spinner } from "#core/components/ui";
|
|
18
|
+
import { useI18n } from "#core/contexts";
|
|
19
|
+
import { useRequest } from "#core/hooks/use-request";
|
|
20
|
+
|
|
21
|
+
const INVALID_MESSAGE_KEY: Record<PasswordTokenStatus, string> = {
|
|
22
|
+
valid: "recovery.linkInvalid",
|
|
23
|
+
invalid: "recovery.linkInvalid",
|
|
24
|
+
expired: "recovery.linkExpired",
|
|
25
|
+
used: "recovery.linkUsed",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function ResetPasswordScreen(): JSX.Element {
|
|
29
|
+
const router = useRouter();
|
|
30
|
+
const params = useSearchParams();
|
|
31
|
+
const token = params.get("token") ?? "";
|
|
32
|
+
const { run, loading, error } = useRequest();
|
|
33
|
+
const { t } = useI18n();
|
|
34
|
+
const [done, setDone] = useState(false);
|
|
35
|
+
const [check, setCheck] = useState<PasswordTokenCheck | null>(null);
|
|
36
|
+
const [checking, setChecking] = useState(true);
|
|
37
|
+
|
|
38
|
+
// Valida o link ANTES de exibir o formulário — token ausente, expirado,
|
|
39
|
+
// já usado ou inexistente nunca chega a renderizar os campos de senha.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!token) {
|
|
42
|
+
setCheck({ valid: false, status: "invalid" });
|
|
43
|
+
setChecking(false);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let alive = true;
|
|
47
|
+
authService
|
|
48
|
+
.inspectPasswordToken(token)
|
|
49
|
+
.then((res) => alive && setCheck(res))
|
|
50
|
+
.catch(() => alive && setCheck({ valid: false, status: "invalid" }))
|
|
51
|
+
.finally(() => alive && setChecking(false));
|
|
52
|
+
return () => {
|
|
53
|
+
alive = false;
|
|
54
|
+
};
|
|
55
|
+
}, [token]);
|
|
56
|
+
|
|
57
|
+
const schema = yup.object({
|
|
58
|
+
newPassword: yup
|
|
59
|
+
.string()
|
|
60
|
+
.required(t("validation.required"))
|
|
61
|
+
.matches(STRONG_PASSWORD_REGEX, t("validation.passwordStrong")),
|
|
62
|
+
confirm: yup
|
|
63
|
+
.string()
|
|
64
|
+
.required(t("validation.required"))
|
|
65
|
+
.oneOf([yup.ref("newPassword")], t("validation.passwordsNotMatch")),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const {
|
|
69
|
+
register,
|
|
70
|
+
handleSubmit,
|
|
71
|
+
formState: { errors },
|
|
72
|
+
} = useForm<{ newPassword: string; confirm: string }>({
|
|
73
|
+
resolver: yupResolver(schema),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const onSubmit = async (data: { newPassword: string; confirm: string }): Promise<void> => {
|
|
77
|
+
const res = await run(
|
|
78
|
+
() => authService.resetPassword(token, data.newPassword),
|
|
79
|
+
{ success: t("recovery.resetSuccess") },
|
|
80
|
+
);
|
|
81
|
+
if (res !== null) {
|
|
82
|
+
setDone(true);
|
|
83
|
+
setTimeout(() => router.replace("/login"), 1500);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isFirstAccess = check?.purpose === "first_access";
|
|
88
|
+
const title = isFirstAccess
|
|
89
|
+
? t("recovery.firstAccessTitle")
|
|
90
|
+
: t("recovery.resetTitle");
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
94
|
+
<Card className="w-full max-w-md p-8">
|
|
95
|
+
<h1 className="mb-4 text-xl font-semibold">{title}</h1>
|
|
96
|
+
|
|
97
|
+
{checking && (
|
|
98
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
99
|
+
<Spinner className="h-4 w-4" />
|
|
100
|
+
{t("recovery.checking")}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{!checking && check && !check.valid && (
|
|
105
|
+
<div className="flex flex-col gap-4">
|
|
106
|
+
<Alert variant="error">{t(INVALID_MESSAGE_KEY[check.status])}</Alert>
|
|
107
|
+
<Button size="lg" onClick={() => router.push("/forgot-password")}>
|
|
108
|
+
{t("recovery.requestNewLink")}
|
|
109
|
+
</Button>
|
|
110
|
+
<Button variant="ghost" onClick={() => router.push("/login")}>
|
|
111
|
+
{t("recovery.backToLogin")}
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{!checking && check?.valid && (
|
|
117
|
+
<>
|
|
118
|
+
{isFirstAccess && (
|
|
119
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
120
|
+
{t("recovery.firstAccessHint")}
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
123
|
+
{error && (
|
|
124
|
+
<Alert variant="error" className="mb-2">
|
|
125
|
+
{error}
|
|
126
|
+
</Alert>
|
|
127
|
+
)}
|
|
128
|
+
{done ? (
|
|
129
|
+
<Alert variant="success">{t("recovery.resetSuccess")}</Alert>
|
|
130
|
+
) : (
|
|
131
|
+
<form
|
|
132
|
+
onSubmit={handleSubmit(onSubmit)}
|
|
133
|
+
className="flex flex-col gap-4"
|
|
134
|
+
>
|
|
135
|
+
<Field
|
|
136
|
+
label={t("recovery.newPassword")}
|
|
137
|
+
error={errors.newPassword?.message}
|
|
138
|
+
>
|
|
139
|
+
<PasswordField
|
|
140
|
+
error={!!errors.newPassword}
|
|
141
|
+
{...register("newPassword")}
|
|
142
|
+
/>
|
|
143
|
+
</Field>
|
|
144
|
+
<Field
|
|
145
|
+
label={t("recovery.confirmPassword")}
|
|
146
|
+
error={errors.confirm?.message}
|
|
147
|
+
>
|
|
148
|
+
<PasswordField
|
|
149
|
+
error={!!errors.confirm}
|
|
150
|
+
{...register("confirm")}
|
|
151
|
+
/>
|
|
152
|
+
</Field>
|
|
153
|
+
<Button type="submit" size="lg" disabled={loading}>
|
|
154
|
+
{loading ? t("recovery.sending") : t("recovery.resetCta")}
|
|
155
|
+
</Button>
|
|
156
|
+
</form>
|
|
157
|
+
)}
|
|
158
|
+
</>
|
|
159
|
+
)}
|
|
160
|
+
</Card>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { yupResolver } from "@hookform/resolvers/yup";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { Controller, useForm } from "react-hook-form";
|
|
7
|
+
import * as yup from "yup";
|
|
8
|
+
|
|
9
|
+
import { maskPhoneBR } from "#core/_utils/format";
|
|
10
|
+
import {
|
|
11
|
+
Alert,
|
|
12
|
+
Button,
|
|
13
|
+
Checkbox,
|
|
14
|
+
Dialog,
|
|
15
|
+
DialogContent,
|
|
16
|
+
DialogFooter,
|
|
17
|
+
DialogForm,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
Field,
|
|
21
|
+
Input,
|
|
22
|
+
Label,
|
|
23
|
+
} from "#core/components/ui";
|
|
24
|
+
import { useI18n } from "#core/contexts";
|
|
25
|
+
import {
|
|
26
|
+
CreateUserPayload,
|
|
27
|
+
Role,
|
|
28
|
+
User,
|
|
29
|
+
} from "#core/features/users/services/users.service";
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
open: boolean;
|
|
33
|
+
loading: boolean;
|
|
34
|
+
roles: Role[];
|
|
35
|
+
editing?: User | null;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
onSubmit: (data: CreateUserPayload, id?: string) => Promise<boolean>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function UserFormDialog({
|
|
41
|
+
open,
|
|
42
|
+
loading,
|
|
43
|
+
roles,
|
|
44
|
+
editing,
|
|
45
|
+
onClose,
|
|
46
|
+
onSubmit,
|
|
47
|
+
}: Props): JSX.Element {
|
|
48
|
+
const { t } = useI18n();
|
|
49
|
+
const isEdit = !!editing;
|
|
50
|
+
|
|
51
|
+
const schema = yup.object({
|
|
52
|
+
name: yup.string().required(t("validation.required")).min(2),
|
|
53
|
+
lastName: yup.string().optional(),
|
|
54
|
+
phone: yup.string().optional(),
|
|
55
|
+
email: yup
|
|
56
|
+
.string()
|
|
57
|
+
.required(t("validation.required"))
|
|
58
|
+
.email(t("validation.email")),
|
|
59
|
+
roleIds: yup.array().of(yup.string()).optional(),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const {
|
|
63
|
+
register,
|
|
64
|
+
handleSubmit,
|
|
65
|
+
reset,
|
|
66
|
+
control,
|
|
67
|
+
formState: { errors },
|
|
68
|
+
} = useForm({
|
|
69
|
+
resolver: yupResolver(schema),
|
|
70
|
+
defaultValues: {
|
|
71
|
+
name: "",
|
|
72
|
+
lastName: "",
|
|
73
|
+
phone: "",
|
|
74
|
+
email: "",
|
|
75
|
+
roleIds: [] as string[],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (open) {
|
|
81
|
+
reset({
|
|
82
|
+
name: editing?.name ?? "",
|
|
83
|
+
lastName: editing?.lastName ?? "",
|
|
84
|
+
phone: editing?.phone ?? "",
|
|
85
|
+
email: editing?.email ?? "",
|
|
86
|
+
roleIds: editing?.roles?.map((r) => r.id) ?? [],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}, [open, editing, reset]);
|
|
90
|
+
|
|
91
|
+
const submit = handleSubmit(async (data) => {
|
|
92
|
+
const payload: CreateUserPayload = {
|
|
93
|
+
name: data.name,
|
|
94
|
+
lastName: data.lastName || undefined,
|
|
95
|
+
phone: data.phone || undefined,
|
|
96
|
+
email: data.email,
|
|
97
|
+
// o schema Yup infere (string | undefined)[]; aqui estreitamos sem cast
|
|
98
|
+
roleIds: data.roleIds?.filter((id): id is string => !!id),
|
|
99
|
+
};
|
|
100
|
+
const ok = await onSubmit(payload, editing?.id);
|
|
101
|
+
if (ok) {onClose();}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
106
|
+
<DialogContent className="max-w-lg">
|
|
107
|
+
<DialogHeader>
|
|
108
|
+
<DialogTitle>
|
|
109
|
+
{isEdit ? t("common.edit") : t("users.newUser")}
|
|
110
|
+
</DialogTitle>
|
|
111
|
+
</DialogHeader>
|
|
112
|
+
|
|
113
|
+
<DialogForm onSubmit={submit}>
|
|
114
|
+
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto px-1">
|
|
115
|
+
<Field label={t("users.name")} error={errors.name?.message}>
|
|
116
|
+
<Input {...register("name")} />
|
|
117
|
+
</Field>
|
|
118
|
+
<Field label={t("users.lastName")} error={errors.lastName?.message}>
|
|
119
|
+
<Input {...register("lastName")} />
|
|
120
|
+
</Field>
|
|
121
|
+
<Controller
|
|
122
|
+
name="phone"
|
|
123
|
+
control={control}
|
|
124
|
+
render={({ field }) => (
|
|
125
|
+
<Field label={t("users.phone")}>
|
|
126
|
+
<Input
|
|
127
|
+
placeholder="(11) 90000-0000"
|
|
128
|
+
value={field.value ?? ""}
|
|
129
|
+
onChange={(e) => field.onChange(maskPhoneBR(e.target.value))}
|
|
130
|
+
/>
|
|
131
|
+
</Field>
|
|
132
|
+
)}
|
|
133
|
+
/>
|
|
134
|
+
<Field label={t("users.email")} error={errors.email?.message}>
|
|
135
|
+
<Input type="email" {...register("email")} />
|
|
136
|
+
</Field>
|
|
137
|
+
{!isEdit && (
|
|
138
|
+
<Alert variant="info">{t("users.passwordByEmailHint")}</Alert>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
<Controller
|
|
142
|
+
name="roleIds"
|
|
143
|
+
control={control}
|
|
144
|
+
render={({ field }) => {
|
|
145
|
+
const selected = (field.value ?? []) as string[];
|
|
146
|
+
const toggle = (id: string): void =>
|
|
147
|
+
field.onChange(
|
|
148
|
+
selected.includes(id)
|
|
149
|
+
? selected.filter((x) => x !== id)
|
|
150
|
+
: [...selected, id],
|
|
151
|
+
);
|
|
152
|
+
return (
|
|
153
|
+
<div className="space-y-2">
|
|
154
|
+
<Label>{t("users.roles")}</Label>
|
|
155
|
+
<div className="flex flex-col gap-2 rounded-md border border-border p-3">
|
|
156
|
+
{roles.map((r) => (
|
|
157
|
+
<label
|
|
158
|
+
key={r.id}
|
|
159
|
+
className="flex cursor-pointer items-center gap-2 text-sm"
|
|
160
|
+
>
|
|
161
|
+
<Checkbox
|
|
162
|
+
checked={selected.includes(r.id)}
|
|
163
|
+
onCheckedChange={() => toggle(r.id)}
|
|
164
|
+
/>
|
|
165
|
+
{r.name}
|
|
166
|
+
</label>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<DialogFooter className="gap-2">
|
|
176
|
+
<Button type="button" variant="outline" onClick={onClose}>
|
|
177
|
+
{t("common.cancel")}
|
|
178
|
+
</Button>
|
|
179
|
+
<Button type="submit" disabled={loading}>
|
|
180
|
+
{isEdit ? t("common.save") : t("common.create")}
|
|
181
|
+
</Button>
|
|
182
|
+
</DialogFooter>
|
|
183
|
+
</DialogForm>
|
|
184
|
+
</DialogContent>
|
|
185
|
+
</Dialog>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { SortingState } from "@tanstack/react-table";
|
|
4
|
+
import {
|
|
5
|
+
Dispatch,
|
|
6
|
+
SetStateAction,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useState,
|
|
10
|
+
} from "react";
|
|
11
|
+
|
|
12
|
+
import { useI18n } from "#core/contexts";
|
|
13
|
+
import {
|
|
14
|
+
CreateUserPayload,
|
|
15
|
+
Role,
|
|
16
|
+
UpdateUserPayload,
|
|
17
|
+
User,
|
|
18
|
+
usersService,
|
|
19
|
+
} from "#core/features/users/services/users.service";
|
|
20
|
+
import type { FiltersState } from "#core/hooks/use-filters";
|
|
21
|
+
import { useListQuery } from "#core/hooks/use-list-query";
|
|
22
|
+
import { RequestOperation, useRequest } from "#core/hooks/use-request";
|
|
23
|
+
|
|
24
|
+
export interface UseUsersResult {
|
|
25
|
+
rows: User[];
|
|
26
|
+
roles: Role[];
|
|
27
|
+
total: number;
|
|
28
|
+
page: number;
|
|
29
|
+
pageSize: number;
|
|
30
|
+
sorting: SortingState;
|
|
31
|
+
/** Carregando a listagem — é o spinner da tabela. */
|
|
32
|
+
loading: boolean;
|
|
33
|
+
/** Executando uma ação de linha (enviar link, resetar 2FA, excluir). */
|
|
34
|
+
acting: boolean;
|
|
35
|
+
setPage: Dispatch<SetStateAction<number>>;
|
|
36
|
+
setPageSize: Dispatch<SetStateAction<number>>;
|
|
37
|
+
setSorting: Dispatch<SetStateAction<SortingState>>;
|
|
38
|
+
/** Filtros dinâmicos — repassar para o `DataTable`. */
|
|
39
|
+
filters: FiltersState;
|
|
40
|
+
refresh: () => Promise<void>;
|
|
41
|
+
create: (payload: CreateUserPayload) => Promise<boolean>;
|
|
42
|
+
update: (id: string, payload: UpdateUserPayload) => Promise<boolean>;
|
|
43
|
+
setActive: (id: string, isActive: boolean) => Promise<void>;
|
|
44
|
+
sendPasswordResetLink: (id: string) => Promise<void>;
|
|
45
|
+
resetTwoFactor: (id: string) => Promise<void>;
|
|
46
|
+
sendFirstAccessLink: (id: string) => Promise<void>;
|
|
47
|
+
remove: (id: string) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useUsers(): UseUsersResult {
|
|
51
|
+
const { run, loading } = useRequest();
|
|
52
|
+
// `run` separado para as ações: com um só, qualquer clique de linha ligava o
|
|
53
|
+
// spinner da tabela — que não recarrega quando a ação não muda nenhuma coluna.
|
|
54
|
+
const { run: runAction, loading: acting } = useRequest();
|
|
55
|
+
const { t } = useI18n();
|
|
56
|
+
|
|
57
|
+
const list = useListQuery("/users/filter-schema");
|
|
58
|
+
|
|
59
|
+
const [rows, setRows] = useState<User[]>([]);
|
|
60
|
+
const [roles, setRoles] = useState<Role[]>([]);
|
|
61
|
+
const [total, setTotal] = useState(0);
|
|
62
|
+
|
|
63
|
+
const fetch = useCallback(async () => {
|
|
64
|
+
const res = await run(() => usersService.list(list.query));
|
|
65
|
+
if (res) {
|
|
66
|
+
setRows(res.items);
|
|
67
|
+
setTotal(res.meta.total);
|
|
68
|
+
}
|
|
69
|
+
}, [run, list.query]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
void fetch();
|
|
73
|
+
}, [fetch]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
void run(() => usersService.listRoles()).then((r) => r && setRoles(r));
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const create = useCallback(
|
|
81
|
+
async (payload: CreateUserPayload) => {
|
|
82
|
+
const res = await runAction(() => usersService.create(payload), {
|
|
83
|
+
success: RequestOperation.Create,
|
|
84
|
+
});
|
|
85
|
+
if (res) {
|
|
86
|
+
await fetch();
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
},
|
|
91
|
+
[runAction, fetch],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const update = useCallback(
|
|
95
|
+
async (id: string, payload: UpdateUserPayload) => {
|
|
96
|
+
const res = await runAction(() => usersService.update(id, payload), {
|
|
97
|
+
success: RequestOperation.Update,
|
|
98
|
+
});
|
|
99
|
+
if (res) {
|
|
100
|
+
await fetch();
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
},
|
|
105
|
+
[runAction, fetch],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const setActive = useCallback(
|
|
109
|
+
async (id: string, isActive: boolean) => {
|
|
110
|
+
const res = await runAction(() => usersService.setActive(id, isActive));
|
|
111
|
+
if (res) {await fetch();}
|
|
112
|
+
},
|
|
113
|
+
[runAction, fetch],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const sendPasswordResetLink = useCallback(
|
|
117
|
+
async (id: string): Promise<void> => {
|
|
118
|
+
await runAction(() => usersService.sendPasswordResetLink(id), {
|
|
119
|
+
success: t("users.passwordResetLinkSent"),
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
[runAction, t],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const remove = useCallback(
|
|
126
|
+
async (id: string) => {
|
|
127
|
+
const res = await runAction(() => usersService.remove(id), {
|
|
128
|
+
success: RequestOperation.Delete,
|
|
129
|
+
});
|
|
130
|
+
if (res !== null) {
|
|
131
|
+
await fetch();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[runAction, fetch],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const sendFirstAccessLink = useCallback(
|
|
138
|
+
async (id: string): Promise<void> => {
|
|
139
|
+
// nenhuma coluna da tabela muda com o envio — não precisa refazer o fetch
|
|
140
|
+
await runAction(() => usersService.sendFirstAccessLink(id), {
|
|
141
|
+
success: t("users.firstAccessLinkSent"),
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
[runAction, t],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const resetTwoFactor = useCallback(
|
|
148
|
+
async (id: string) => {
|
|
149
|
+
const res = await runAction(() => usersService.resetTwoFactor(id), {
|
|
150
|
+
success: t("users.twoFactorReset"),
|
|
151
|
+
});
|
|
152
|
+
if (res !== null) {
|
|
153
|
+
await fetch();
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
[runAction, fetch, t],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
rows,
|
|
161
|
+
roles,
|
|
162
|
+
total,
|
|
163
|
+
page: list.page,
|
|
164
|
+
pageSize: list.pageSize,
|
|
165
|
+
sorting: list.sorting,
|
|
166
|
+
loading,
|
|
167
|
+
acting,
|
|
168
|
+
setPage: list.setPage,
|
|
169
|
+
setPageSize: list.setPageSize,
|
|
170
|
+
setSorting: list.setSorting,
|
|
171
|
+
filters: list.filters,
|
|
172
|
+
refresh: fetch,
|
|
173
|
+
create,
|
|
174
|
+
update,
|
|
175
|
+
setActive,
|
|
176
|
+
sendPasswordResetLink,
|
|
177
|
+
resetTwoFactor,
|
|
178
|
+
sendFirstAccessLink,
|
|
179
|
+
remove,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { api } from "#core/_services/api/axios.factory";
|
|
2
|
+
import type { ListQuery } from "#core/_services/api/list-query";
|
|
3
|
+
import { toListParams } from "#core/_services/api/list-query";
|
|
4
|
+
import type { components } from "#core/_services/api/schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tipos derivados do `openapi.json` (§5): campo que mudar no backend quebra o
|
|
8
|
+
* build, não a tela. Nada de redigitar a forma da resposta aqui.
|
|
9
|
+
*/
|
|
10
|
+
export type Role = components["schemas"]["UserRoleResponse"];
|
|
11
|
+
export type User = components["schemas"]["UserResponse"];
|
|
12
|
+
export type PaginationMeta = components["schemas"]["PaginationMetaResponse"];
|
|
13
|
+
|
|
14
|
+
export interface Paginated<T> {
|
|
15
|
+
items: T[];
|
|
16
|
+
meta: PaginationMeta;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sem senha: o usuário recebe um link de primeiro acesso por email
|
|
21
|
+
* e define a própria senha.
|
|
22
|
+
*/
|
|
23
|
+
export interface CreateUserPayload {
|
|
24
|
+
name: string;
|
|
25
|
+
lastName?: string;
|
|
26
|
+
phone?: string;
|
|
27
|
+
email: string;
|
|
28
|
+
roleIds?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UpdateUserPayload {
|
|
32
|
+
name?: string;
|
|
33
|
+
lastName?: string;
|
|
34
|
+
phone?: string;
|
|
35
|
+
email?: string;
|
|
36
|
+
roleIds?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const usersService = {
|
|
40
|
+
list(query: ListQuery) {
|
|
41
|
+
return api
|
|
42
|
+
.get<Paginated<User>>("/users", { params: toListParams(query) })
|
|
43
|
+
.then((r) => r.data);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
get(id: string) {
|
|
47
|
+
return api.get<User>(`/users/${id}`).then((r) => r.data);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
create(payload: CreateUserPayload) {
|
|
51
|
+
return api.post<User>("/users", payload).then((r) => r.data);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
update(id: string, payload: UpdateUserPayload) {
|
|
55
|
+
return api.patch<User>(`/users/${id}`, payload).then((r) => r.data);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
setActive(id: string, isActive: boolean) {
|
|
59
|
+
return api
|
|
60
|
+
.patch<User>(`/users/${id}/active/${isActive}`)
|
|
61
|
+
.then((r) => r.data);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
sendPasswordResetLink(id: string) {
|
|
65
|
+
return api.post(`/users/${id}/reset-password`).then((r) => r.data);
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
sendFirstAccessLink(id: string) {
|
|
69
|
+
return api.post(`/users/${id}/first-access-link`).then((r) => r.data);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
resetTwoFactor(id: string) {
|
|
73
|
+
return api.post(`/users/${id}/reset-2fa`).then((r) => r.data);
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
remove(id: string) {
|
|
77
|
+
return api.delete(`/users/${id}`).then((r) => r.data);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
listRoles() {
|
|
81
|
+
return api.get<Role[]>("/rbac/roles").then((r) => r.data);
|
|
82
|
+
},
|
|
83
|
+
};
|