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,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import Cropper from "react-easy-crop";
|
|
6
|
+
|
|
7
|
+
import { CropAreaPixels, cropImageToFile } from "#core/_utils/crop-image";
|
|
8
|
+
import {
|
|
9
|
+
Button,
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogDescription,
|
|
13
|
+
DialogFooter,
|
|
14
|
+
DialogForm,
|
|
15
|
+
DialogHeader,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
} from "#core/components/ui";
|
|
18
|
+
import { useI18n } from "#core/contexts";
|
|
19
|
+
|
|
20
|
+
const ZOOM_MIN = 1;
|
|
21
|
+
const ZOOM_MAX = 3;
|
|
22
|
+
const ZOOM_STEP = 0.05;
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
/** Arquivo escolhido pelo usuário; `null` mantém o modal fechado. */
|
|
26
|
+
file: File | null;
|
|
27
|
+
loading: boolean;
|
|
28
|
+
onCancel: () => void;
|
|
29
|
+
onConfirm: (cropped: File) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recorte do avatar: arrastar para posicionar, slider para o zoom.
|
|
34
|
+
* O corte é sempre quadrado (1:1) porque o avatar é exibido redondo.
|
|
35
|
+
*/
|
|
36
|
+
export function AvatarCropDialog({
|
|
37
|
+
file,
|
|
38
|
+
loading,
|
|
39
|
+
onCancel,
|
|
40
|
+
onConfirm,
|
|
41
|
+
}: Props): JSX.Element {
|
|
42
|
+
const { t } = useI18n();
|
|
43
|
+
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
|
44
|
+
const [zoom, setZoom] = useState(ZOOM_MIN);
|
|
45
|
+
const [area, setArea] = useState<CropAreaPixels | null>(null);
|
|
46
|
+
|
|
47
|
+
// Derivado do arquivo, não guardado em estado — evita um render extra só para
|
|
48
|
+
// publicar a URL. O efeito abaixo existe apenas para revogá-la.
|
|
49
|
+
const src = useMemo(
|
|
50
|
+
() => (file ? URL.createObjectURL(file) : null),
|
|
51
|
+
[file],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!src) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// A object URL segura o arquivo em memória até ser revogada.
|
|
59
|
+
return () => URL.revokeObjectURL(src);
|
|
60
|
+
}, [src]);
|
|
61
|
+
|
|
62
|
+
/** Volta ao estado inicial: o componente não desmonta ao fechar o modal. */
|
|
63
|
+
const reset = (): void => {
|
|
64
|
+
setCrop({ x: 0, y: 0 });
|
|
65
|
+
setZoom(ZOOM_MIN);
|
|
66
|
+
setArea(null);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const cancel = (): void => {
|
|
70
|
+
reset();
|
|
71
|
+
onCancel();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const confirm = async (): Promise<void> => {
|
|
75
|
+
if (!src || !area) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const cropped = await cropImageToFile(src, area);
|
|
79
|
+
reset();
|
|
80
|
+
onConfirm(cropped);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Dialog open={!!file} onOpenChange={(o) => !o && cancel()}>
|
|
85
|
+
<DialogContent className="max-w-md">
|
|
86
|
+
<DialogHeader>
|
|
87
|
+
<DialogTitle>{t("profile.cropTitle")}</DialogTitle>
|
|
88
|
+
<DialogDescription>{t("profile.cropHint")}</DialogDescription>
|
|
89
|
+
</DialogHeader>
|
|
90
|
+
|
|
91
|
+
<DialogForm onSubmit={() => void confirm()}>
|
|
92
|
+
<div className="relative h-64 w-full overflow-hidden rounded-lg bg-muted">
|
|
93
|
+
{src && (
|
|
94
|
+
<Cropper
|
|
95
|
+
image={src}
|
|
96
|
+
crop={crop}
|
|
97
|
+
zoom={zoom}
|
|
98
|
+
aspect={1}
|
|
99
|
+
cropShape="round"
|
|
100
|
+
showGrid={false}
|
|
101
|
+
onCropChange={setCrop}
|
|
102
|
+
onZoomChange={setZoom}
|
|
103
|
+
onCropComplete={(_area, areaPixels) => setArea(areaPixels)}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<label className="flex items-center gap-3 text-sm text-muted-foreground">
|
|
109
|
+
{t("profile.cropZoom")}
|
|
110
|
+
<input
|
|
111
|
+
type="range"
|
|
112
|
+
className="flex-1 accent-primary"
|
|
113
|
+
min={ZOOM_MIN}
|
|
114
|
+
max={ZOOM_MAX}
|
|
115
|
+
step={ZOOM_STEP}
|
|
116
|
+
value={zoom}
|
|
117
|
+
onChange={(e) => setZoom(Number(e.target.value))}
|
|
118
|
+
/>
|
|
119
|
+
</label>
|
|
120
|
+
|
|
121
|
+
<DialogFooter className="gap-2">
|
|
122
|
+
<Button type="button" variant="outline" onClick={cancel}>
|
|
123
|
+
{t("common.cancel")}
|
|
124
|
+
</Button>
|
|
125
|
+
<Button type="submit" disabled={loading || !area}>
|
|
126
|
+
{t("common.save")}
|
|
127
|
+
</Button>
|
|
128
|
+
</DialogFooter>
|
|
129
|
+
</DialogForm>
|
|
130
|
+
</DialogContent>
|
|
131
|
+
</Dialog>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { yupResolver } from "@hookform/resolvers/yup";
|
|
4
|
+
import type { JSX } from "react";
|
|
5
|
+
import { useForm } from "react-hook-form";
|
|
6
|
+
import * as yup from "yup";
|
|
7
|
+
|
|
8
|
+
import { authService } from "#core/_services/auth";
|
|
9
|
+
import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
|
|
10
|
+
import { PasswordField } from "#core/components";
|
|
11
|
+
import { Alert, Button, Card, Field } from "#core/components/ui";
|
|
12
|
+
import { useAuth, useI18n } from "#core/contexts";
|
|
13
|
+
import { useRequest } from "#core/hooks/use-request";
|
|
14
|
+
|
|
15
|
+
export function ForcePasswordChange({
|
|
16
|
+
reason = "expired",
|
|
17
|
+
}: {
|
|
18
|
+
reason?: "expired" | "provisional";
|
|
19
|
+
}): JSX.Element {
|
|
20
|
+
const { run, loading, error } = useRequest();
|
|
21
|
+
const { logout } = useAuth();
|
|
22
|
+
const { t } = useI18n();
|
|
23
|
+
|
|
24
|
+
const schema = yup.object({
|
|
25
|
+
currentPassword: yup.string().required(t("validation.required")),
|
|
26
|
+
newPassword: yup
|
|
27
|
+
.string()
|
|
28
|
+
.required(t("validation.required"))
|
|
29
|
+
.matches(STRONG_PASSWORD_REGEX, t("validation.passwordStrong")),
|
|
30
|
+
confirm: yup
|
|
31
|
+
.string()
|
|
32
|
+
.required(t("validation.required"))
|
|
33
|
+
.oneOf([yup.ref("newPassword")], t("validation.passwordsNotMatch")),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
register,
|
|
38
|
+
handleSubmit,
|
|
39
|
+
formState: { errors },
|
|
40
|
+
} = useForm({ resolver: yupResolver(schema) });
|
|
41
|
+
|
|
42
|
+
const onSubmit = handleSubmit(async (data) => {
|
|
43
|
+
const res = await run(
|
|
44
|
+
() => authService.changePassword(data.currentPassword, data.newPassword),
|
|
45
|
+
{ success: t("profile.changed") },
|
|
46
|
+
);
|
|
47
|
+
if (res !== null) {
|
|
48
|
+
await logout();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
|
54
|
+
<Card className="w-full max-w-md p-8">
|
|
55
|
+
<h1 className="mb-4 text-xl font-semibold">
|
|
56
|
+
{t("profile.changePassword")}
|
|
57
|
+
</h1>
|
|
58
|
+
<Alert variant="warning" className="mb-4">
|
|
59
|
+
{reason === "provisional"
|
|
60
|
+
? t("profile.forceProvisional")
|
|
61
|
+
: t("profile.forceExpired")}
|
|
62
|
+
</Alert>
|
|
63
|
+
{error && (
|
|
64
|
+
<Alert variant="error" className="mb-4">
|
|
65
|
+
{error}
|
|
66
|
+
</Alert>
|
|
67
|
+
)}
|
|
68
|
+
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
|
69
|
+
<Field
|
|
70
|
+
label={t("profile.currentPassword")}
|
|
71
|
+
error={errors.currentPassword?.message}
|
|
72
|
+
>
|
|
73
|
+
<PasswordField
|
|
74
|
+
error={!!errors.currentPassword}
|
|
75
|
+
{...register("currentPassword")}
|
|
76
|
+
/>
|
|
77
|
+
</Field>
|
|
78
|
+
<Field
|
|
79
|
+
label={t("profile.newPassword")}
|
|
80
|
+
error={errors.newPassword?.message}
|
|
81
|
+
>
|
|
82
|
+
<PasswordField
|
|
83
|
+
error={!!errors.newPassword}
|
|
84
|
+
{...register("newPassword")}
|
|
85
|
+
/>
|
|
86
|
+
</Field>
|
|
87
|
+
<Field
|
|
88
|
+
label={t("profile.confirmPassword")}
|
|
89
|
+
error={errors.confirm?.message}
|
|
90
|
+
>
|
|
91
|
+
<PasswordField error={!!errors.confirm} {...register("confirm")} />
|
|
92
|
+
</Field>
|
|
93
|
+
<Button type="submit" size="lg" disabled={loading}>
|
|
94
|
+
{loading ? t("common.loading") : t("profile.changePassword")}
|
|
95
|
+
</Button>
|
|
96
|
+
{/*
|
|
97
|
+
Saída da tela. Sem isto o usuário fica preso: a tela cobre o app
|
|
98
|
+
inteiro e não há navegação nem menu — só restava limpar os cookies.
|
|
99
|
+
Sair não desarma a exigência: no próximo login ela volta, porque a
|
|
100
|
+
senha continua provisória (ou expirada).
|
|
101
|
+
*/}
|
|
102
|
+
<Button
|
|
103
|
+
type="button"
|
|
104
|
+
variant="ghost"
|
|
105
|
+
onClick={() => void logout()}
|
|
106
|
+
disabled={loading}
|
|
107
|
+
>
|
|
108
|
+
{t("user.logout")}
|
|
109
|
+
</Button>
|
|
110
|
+
</form>
|
|
111
|
+
</Card>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { yupResolver } from "@hookform/resolvers/yup";
|
|
4
|
+
import { Camera, ShieldCheck, Trash2 } from "lucide-react";
|
|
5
|
+
import type { JSX } from "react";
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import * as yup from "yup";
|
|
9
|
+
|
|
10
|
+
import { api } from "#core/_services/api/axios.factory";
|
|
11
|
+
import { authService } from "#core/_services/auth";
|
|
12
|
+
import { maskPhoneBR } from "#core/_utils/format";
|
|
13
|
+
import { STRONG_PASSWORD_REGEX } from "#core/_utils/password";
|
|
14
|
+
import { PasswordField } from "#core/components";
|
|
15
|
+
import { UserAvatar } from "#core/components";
|
|
16
|
+
import {
|
|
17
|
+
Alert,
|
|
18
|
+
Badge,
|
|
19
|
+
Button,
|
|
20
|
+
Card,
|
|
21
|
+
CardContent,
|
|
22
|
+
CardHeader,
|
|
23
|
+
CardTitle,
|
|
24
|
+
Dialog,
|
|
25
|
+
DialogContent,
|
|
26
|
+
DialogFooter,
|
|
27
|
+
DialogForm,
|
|
28
|
+
DialogHeader,
|
|
29
|
+
DialogTitle,
|
|
30
|
+
Field,
|
|
31
|
+
Input,
|
|
32
|
+
} from "#core/components/ui";
|
|
33
|
+
import {
|
|
34
|
+
ColorPreference,
|
|
35
|
+
useAuth,
|
|
36
|
+
useColorMode,
|
|
37
|
+
useI18n,
|
|
38
|
+
} from "#core/contexts";
|
|
39
|
+
import { BackupCodesDialog } from "#core/features/login/components";
|
|
40
|
+
import { AvatarCropDialog } from "#core/features/profile/components";
|
|
41
|
+
import { useRequest } from "#core/hooks/use-request";
|
|
42
|
+
import { cn } from "#core/lib/utils";
|
|
43
|
+
|
|
44
|
+
const THEME_OPTIONS: { value: ColorPreference; key: string }[] = [
|
|
45
|
+
{ value: "light", key: "common.light" },
|
|
46
|
+
{ value: "dark", key: "common.dark" },
|
|
47
|
+
{ value: "system", key: "common.system" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function ProfileScreen(): JSX.Element {
|
|
51
|
+
const { user, logout, refreshProfile } = useAuth();
|
|
52
|
+
const { preference, setPreference } = useColorMode();
|
|
53
|
+
const { run, loading } = useRequest();
|
|
54
|
+
// useRequest próprio da troca de senha: o `run` compartilhado da tela zeraria
|
|
55
|
+
// esse erro a cada outra ação (salvar perfil, trocar avatar).
|
|
56
|
+
const password = useRequest();
|
|
57
|
+
const { t } = useI18n();
|
|
58
|
+
|
|
59
|
+
const [avatar, setAvatar] = useState<string | null>(null);
|
|
60
|
+
const [form, setForm] = useState({ name: "", lastName: "", phone: "" });
|
|
61
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
62
|
+
|
|
63
|
+
// Arquivo escolhido mas ainda não recortado — o upload só sai depois do corte.
|
|
64
|
+
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
|
65
|
+
const [regenOpen, setRegenOpen] = useState(false);
|
|
66
|
+
const [totp, setTotp] = useState("");
|
|
67
|
+
const [newCodes, setNewCodes] = useState<string[] | null>(null);
|
|
68
|
+
|
|
69
|
+
const loadMe = async (): Promise<void> => {
|
|
70
|
+
const me = await run(() => api.get("/users/me").then((r) => r.data));
|
|
71
|
+
if (me) {
|
|
72
|
+
setAvatar(me.avatar ?? null);
|
|
73
|
+
setForm({
|
|
74
|
+
name: me.name ?? "",
|
|
75
|
+
lastName: me.lastName ?? "",
|
|
76
|
+
phone: me.phone ?? "",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
void loadMe();
|
|
82
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const saveProfile = async (): Promise<void> => {
|
|
86
|
+
const res = await run(() => authService.updateProfile(form), {
|
|
87
|
+
success: t("profile.saved"),
|
|
88
|
+
});
|
|
89
|
+
if (res !== null) {
|
|
90
|
+
await refreshProfile();
|
|
91
|
+
await loadMe();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const schema = yup.object({
|
|
96
|
+
currentPassword: yup.string().required(t("validation.required")),
|
|
97
|
+
newPassword: yup
|
|
98
|
+
.string()
|
|
99
|
+
.required(t("validation.required"))
|
|
100
|
+
.matches(STRONG_PASSWORD_REGEX, t("validation.passwordStrong")),
|
|
101
|
+
confirm: yup
|
|
102
|
+
.string()
|
|
103
|
+
.required(t("validation.required"))
|
|
104
|
+
.oneOf([yup.ref("newPassword")], t("validation.passwordsNotMatch")),
|
|
105
|
+
});
|
|
106
|
+
const {
|
|
107
|
+
register,
|
|
108
|
+
handleSubmit,
|
|
109
|
+
formState: { errors },
|
|
110
|
+
} = useForm({ resolver: yupResolver(schema) });
|
|
111
|
+
const onChangePassword = handleSubmit(async (data) => {
|
|
112
|
+
const res = await password.run(
|
|
113
|
+
() => authService.changePassword(data.currentPassword, data.newPassword),
|
|
114
|
+
{ success: t("profile.changed") },
|
|
115
|
+
);
|
|
116
|
+
if (res !== null) {
|
|
117
|
+
await logout();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const onPickFile = (): void | undefined => fileRef.current?.click();
|
|
122
|
+
const onFile = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
|
123
|
+
const file = e.target.files?.[0];
|
|
124
|
+
if (!file) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
setPendingFile(file);
|
|
128
|
+
// Zera o input: sem isso, escolher o mesmo arquivo de novo não dispara change.
|
|
129
|
+
e.target.value = "";
|
|
130
|
+
};
|
|
131
|
+
const onCropped = async (cropped: File): Promise<void> => {
|
|
132
|
+
const res = await run(() => authService.uploadAvatar(cropped), {
|
|
133
|
+
success: t("profile.avatarUpdated"),
|
|
134
|
+
});
|
|
135
|
+
setPendingFile(null);
|
|
136
|
+
if (res) {
|
|
137
|
+
await refreshProfile();
|
|
138
|
+
await loadMe();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const onDeleteAvatar = async (): Promise<void> => {
|
|
142
|
+
const res = await run(() => authService.deleteAvatar(), {
|
|
143
|
+
success: t("profile.avatarRemoved"),
|
|
144
|
+
});
|
|
145
|
+
if (res !== null) {
|
|
146
|
+
setAvatar(null);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const onRegen = async (): Promise<void> => {
|
|
151
|
+
const res = await run(() => authService.regenerateBackup(totp));
|
|
152
|
+
if (res) {
|
|
153
|
+
setRegenOpen(false);
|
|
154
|
+
setTotp("");
|
|
155
|
+
setNewCodes(res.backupCodes);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div>
|
|
161
|
+
<h1 className="mb-6 text-3xl font-bold">{t("profile.title")}</h1>
|
|
162
|
+
|
|
163
|
+
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
|
164
|
+
{/* Dados + Avatar */}
|
|
165
|
+
<Card>
|
|
166
|
+
<CardContent className="flex flex-col gap-4 pt-6">
|
|
167
|
+
<div className="flex items-center gap-4">
|
|
168
|
+
{/* Câmera sobreposta ao avatar no lugar do antigo botão "Editar" */}
|
|
169
|
+
<div className="relative h-16 w-16 shrink-0">
|
|
170
|
+
<UserAvatar
|
|
171
|
+
name={form.name || user?.name}
|
|
172
|
+
lastName={form.lastName || user?.lastName}
|
|
173
|
+
avatar={avatar}
|
|
174
|
+
className="h-16 w-16"
|
|
175
|
+
fallbackClassName="text-lg"
|
|
176
|
+
/>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={onPickFile}
|
|
180
|
+
disabled={loading}
|
|
181
|
+
title={t("profile.changeAvatar")}
|
|
182
|
+
aria-label={t("profile.changeAvatar")}
|
|
183
|
+
className="absolute -bottom-1.5 -right-1.5 flex h-7 w-7 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
|
184
|
+
>
|
|
185
|
+
<Camera className="h-3.5 w-3.5" />
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
{avatar && (
|
|
189
|
+
<Button
|
|
190
|
+
variant="ghost"
|
|
191
|
+
size="sm"
|
|
192
|
+
onClick={onDeleteAvatar}
|
|
193
|
+
disabled={loading}
|
|
194
|
+
className="text-destructive"
|
|
195
|
+
>
|
|
196
|
+
<Trash2 className="h-4 w-4" />
|
|
197
|
+
{t("common.delete")}
|
|
198
|
+
</Button>
|
|
199
|
+
)}
|
|
200
|
+
<input
|
|
201
|
+
ref={fileRef}
|
|
202
|
+
type="file"
|
|
203
|
+
accept="image/*"
|
|
204
|
+
hidden
|
|
205
|
+
onChange={onFile}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<h2 className="text-lg font-semibold">{t("profile.data")}</h2>
|
|
210
|
+
<Field label={t("users.name")}>
|
|
211
|
+
<Input
|
|
212
|
+
value={form.name}
|
|
213
|
+
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
214
|
+
/>
|
|
215
|
+
</Field>
|
|
216
|
+
<Field label={t("users.lastName")}>
|
|
217
|
+
<Input
|
|
218
|
+
value={form.lastName}
|
|
219
|
+
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
|
220
|
+
/>
|
|
221
|
+
</Field>
|
|
222
|
+
<Field label={t("users.phone")}>
|
|
223
|
+
<Input
|
|
224
|
+
placeholder="(11) 90000-0000"
|
|
225
|
+
value={form.phone}
|
|
226
|
+
onChange={(e) =>
|
|
227
|
+
setForm({ ...form, phone: maskPhoneBR(e.target.value) })
|
|
228
|
+
}
|
|
229
|
+
/>
|
|
230
|
+
</Field>
|
|
231
|
+
<p className="text-sm text-muted-foreground">
|
|
232
|
+
<strong>{t("users.email")}:</strong> {user?.email} ·{" "}
|
|
233
|
+
<strong>{t("dashboard.roles")}:</strong> {user?.roles.join(", ")}
|
|
234
|
+
</p>
|
|
235
|
+
<Button
|
|
236
|
+
className="self-start"
|
|
237
|
+
onClick={saveProfile}
|
|
238
|
+
disabled={loading}
|
|
239
|
+
>
|
|
240
|
+
{t("common.save")}
|
|
241
|
+
</Button>
|
|
242
|
+
</CardContent>
|
|
243
|
+
</Card>
|
|
244
|
+
|
|
245
|
+
{/* Aparência + 2FA + Trocar senha */}
|
|
246
|
+
<div className="flex flex-col gap-6">
|
|
247
|
+
<Card>
|
|
248
|
+
<CardHeader>
|
|
249
|
+
<CardTitle>{t("common.theme")}</CardTitle>
|
|
250
|
+
</CardHeader>
|
|
251
|
+
<CardContent>
|
|
252
|
+
<div className="inline-flex rounded-lg border border-border p-1">
|
|
253
|
+
{THEME_OPTIONS.map((o) => (
|
|
254
|
+
<button
|
|
255
|
+
key={o.value}
|
|
256
|
+
onClick={() => setPreference(o.value)}
|
|
257
|
+
className={cn(
|
|
258
|
+
"rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
|
259
|
+
preference === o.value
|
|
260
|
+
? "bg-primary text-primary-foreground"
|
|
261
|
+
: "text-muted-foreground hover:text-foreground",
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
264
|
+
{t(o.key)}
|
|
265
|
+
</button>
|
|
266
|
+
))}
|
|
267
|
+
</div>
|
|
268
|
+
</CardContent>
|
|
269
|
+
</Card>
|
|
270
|
+
|
|
271
|
+
<Card>
|
|
272
|
+
<CardHeader>
|
|
273
|
+
<CardTitle>2FA</CardTitle>
|
|
274
|
+
</CardHeader>
|
|
275
|
+
<CardContent className="flex flex-col items-start gap-3">
|
|
276
|
+
<Badge
|
|
277
|
+
variant={user?.twoFactorEnabled ? "success" : "warning"}
|
|
278
|
+
className="gap-1"
|
|
279
|
+
>
|
|
280
|
+
<ShieldCheck className="h-3.5 w-3.5" />
|
|
281
|
+
{user?.twoFactorEnabled
|
|
282
|
+
? t("dashboard.twoFactorActive")
|
|
283
|
+
: t("dashboard.twoFactorPending")}
|
|
284
|
+
</Badge>
|
|
285
|
+
<Button variant="outline" onClick={() => setRegenOpen(true)}>
|
|
286
|
+
{t("auth.backupTitle")}
|
|
287
|
+
</Button>
|
|
288
|
+
</CardContent>
|
|
289
|
+
</Card>
|
|
290
|
+
|
|
291
|
+
<Card>
|
|
292
|
+
<CardHeader>
|
|
293
|
+
<CardTitle>{t("profile.changePassword")}</CardTitle>
|
|
294
|
+
</CardHeader>
|
|
295
|
+
<CardContent>
|
|
296
|
+
<form onSubmit={onChangePassword} className="flex flex-col gap-4">
|
|
297
|
+
{password.error && (
|
|
298
|
+
<Alert variant="error" role="alert">
|
|
299
|
+
{password.error}
|
|
300
|
+
</Alert>
|
|
301
|
+
)}
|
|
302
|
+
<Field
|
|
303
|
+
label={t("profile.currentPassword")}
|
|
304
|
+
error={errors.currentPassword?.message}
|
|
305
|
+
>
|
|
306
|
+
<PasswordField
|
|
307
|
+
error={!!errors.currentPassword}
|
|
308
|
+
{...register("currentPassword")}
|
|
309
|
+
/>
|
|
310
|
+
</Field>
|
|
311
|
+
<Field
|
|
312
|
+
label={t("profile.newPassword")}
|
|
313
|
+
error={errors.newPassword?.message}
|
|
314
|
+
>
|
|
315
|
+
<PasswordField
|
|
316
|
+
error={!!errors.newPassword}
|
|
317
|
+
{...register("newPassword")}
|
|
318
|
+
/>
|
|
319
|
+
</Field>
|
|
320
|
+
<Field
|
|
321
|
+
label={t("profile.confirmPassword")}
|
|
322
|
+
error={errors.confirm?.message}
|
|
323
|
+
>
|
|
324
|
+
<PasswordField
|
|
325
|
+
error={!!errors.confirm}
|
|
326
|
+
{...register("confirm")}
|
|
327
|
+
/>
|
|
328
|
+
</Field>
|
|
329
|
+
<Button type="submit" disabled={password.loading}>
|
|
330
|
+
{password.loading
|
|
331
|
+
? t("common.loading")
|
|
332
|
+
: t("profile.changePassword")}
|
|
333
|
+
</Button>
|
|
334
|
+
</form>
|
|
335
|
+
</CardContent>
|
|
336
|
+
</Card>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{/* diálogo: pede código TOTP para regenerar backup codes */}
|
|
341
|
+
<Dialog open={regenOpen} onOpenChange={(o) => !o && setRegenOpen(false)}>
|
|
342
|
+
<DialogContent className="max-w-sm">
|
|
343
|
+
<DialogHeader>
|
|
344
|
+
<DialogTitle>{t("auth.backupTitle")}</DialogTitle>
|
|
345
|
+
</DialogHeader>
|
|
346
|
+
<DialogForm onSubmit={() => void onRegen()}>
|
|
347
|
+
<p className="text-sm text-muted-foreground">{t("auth.codeApp")}</p>
|
|
348
|
+
<Input
|
|
349
|
+
autoFocus
|
|
350
|
+
inputMode="numeric"
|
|
351
|
+
maxLength={6}
|
|
352
|
+
value={totp}
|
|
353
|
+
onChange={(e) => setTotp(e.target.value)}
|
|
354
|
+
/>
|
|
355
|
+
<DialogFooter className="gap-2">
|
|
356
|
+
<Button
|
|
357
|
+
type="button"
|
|
358
|
+
variant="outline"
|
|
359
|
+
onClick={() => setRegenOpen(false)}
|
|
360
|
+
>
|
|
361
|
+
{t("common.cancel")}
|
|
362
|
+
</Button>
|
|
363
|
+
<Button type="submit" disabled={loading || totp.length < 6}>
|
|
364
|
+
{t("common.confirm")}
|
|
365
|
+
</Button>
|
|
366
|
+
</DialogFooter>
|
|
367
|
+
</DialogForm>
|
|
368
|
+
</DialogContent>
|
|
369
|
+
</Dialog>
|
|
370
|
+
|
|
371
|
+
<AvatarCropDialog
|
|
372
|
+
file={pendingFile}
|
|
373
|
+
loading={loading}
|
|
374
|
+
onCancel={() => setPendingFile(null)}
|
|
375
|
+
onConfirm={(cropped) => void onCropped(cropped)}
|
|
376
|
+
/>
|
|
377
|
+
|
|
378
|
+
<BackupCodesDialog
|
|
379
|
+
open={!!newCodes}
|
|
380
|
+
codes={newCodes ?? []}
|
|
381
|
+
onClose={() => setNewCodes(null)}
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { CatalogPanel } from "#core/features/rbac/panels/catalog-panel";
|
|
6
|
+
import { rbacService } from "#core/features/rbac/services/rbac.service";
|
|
7
|
+
|
|
8
|
+
export function ActionsPanel(): JSX.Element {
|
|
9
|
+
return (
|
|
10
|
+
<CatalogPanel
|
|
11
|
+
resource="actions"
|
|
12
|
+
storageKey="rbac-actions"
|
|
13
|
+
namePlaceholder="read"
|
|
14
|
+
list={rbacService.listActions}
|
|
15
|
+
create={rbacService.createAction}
|
|
16
|
+
update={rbacService.updateAction}
|
|
17
|
+
remove={rbacService.deleteAction}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|