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.
Files changed (112) hide show
  1. package/README.md +129 -0
  2. package/package.json +113 -0
  3. package/src/_services/api/axios.factory.ts +97 -0
  4. package/src/_services/api/list-query.ts +26 -0
  5. package/src/_services/api/schema.d.ts +2527 -0
  6. package/src/_services/auth/index.ts +127 -0
  7. package/src/_utils/advanced-filter.ts +386 -0
  8. package/src/_utils/calendar.ts +103 -0
  9. package/src/_utils/crop-image.ts +65 -0
  10. package/src/_utils/filter.ts +313 -0
  11. package/src/_utils/format.ts +35 -0
  12. package/src/_utils/initials.ts +24 -0
  13. package/src/_utils/password.ts +7 -0
  14. package/src/components/app-shell.tsx +314 -0
  15. package/src/components/breadcrumbs.tsx +55 -0
  16. package/src/components/index.ts +7 -0
  17. package/src/components/language-selector.tsx +51 -0
  18. package/src/components/password-field.tsx +40 -0
  19. package/src/components/theme-toggle.tsx +27 -0
  20. package/src/components/ui/alert.tsx +27 -0
  21. package/src/components/ui/avatar.tsx +50 -0
  22. package/src/components/ui/badge.tsx +36 -0
  23. package/src/components/ui/button.tsx +62 -0
  24. package/src/components/ui/card.tsx +86 -0
  25. package/src/components/ui/checkbox.tsx +30 -0
  26. package/src/components/ui/column-visibility-modal.tsx +110 -0
  27. package/src/components/ui/confirm-dialog.tsx +69 -0
  28. package/src/components/ui/data-table.tsx +401 -0
  29. package/src/components/ui/date-range-picker.tsx +250 -0
  30. package/src/components/ui/dialog.tsx +138 -0
  31. package/src/components/ui/dropdown-menu.tsx +108 -0
  32. package/src/components/ui/field.tsx +20 -0
  33. package/src/components/ui/filter-chips.tsx +88 -0
  34. package/src/components/ui/filter-field.tsx +137 -0
  35. package/src/components/ui/filter-group.tsx +155 -0
  36. package/src/components/ui/filter-rule.tsx +221 -0
  37. package/src/components/ui/filter-sheet.tsx +201 -0
  38. package/src/components/ui/index.ts +30 -0
  39. package/src/components/ui/input.tsx +25 -0
  40. package/src/components/ui/label.tsx +23 -0
  41. package/src/components/ui/row-actions.tsx +32 -0
  42. package/src/components/ui/select.tsx +97 -0
  43. package/src/components/ui/separator.tsx +31 -0
  44. package/src/components/ui/sheet.tsx +119 -0
  45. package/src/components/ui/sonner.tsx +28 -0
  46. package/src/components/ui/spinner.tsx +12 -0
  47. package/src/components/ui/switch.tsx +29 -0
  48. package/src/components/ui/table.tsx +84 -0
  49. package/src/components/ui/tabs.tsx +52 -0
  50. package/src/components/user-avatar.tsx +54 -0
  51. package/src/contexts/auth-context.tsx +90 -0
  52. package/src/contexts/color-mode-context.tsx +105 -0
  53. package/src/contexts/i18n-context.tsx +76 -0
  54. package/src/contexts/index.ts +6 -0
  55. package/src/contexts/socket-context.tsx +153 -0
  56. package/src/contexts/toast-context.tsx +40 -0
  57. package/src/features/audit/audit-screen.tsx +299 -0
  58. package/src/features/audit/hooks/use-audit-trail.ts +68 -0
  59. package/src/features/audit/services/audit.service.ts +24 -0
  60. package/src/features/dashboard/dashboard-screen.tsx +73 -0
  61. package/src/features/login/components/backup-codes-dialog.tsx +66 -0
  62. package/src/features/login/components/index.ts +5 -0
  63. package/src/features/login/components/login-form.tsx +62 -0
  64. package/src/features/login/components/two-factor-setup.tsx +71 -0
  65. package/src/features/login/components/two-factor-verify.tsx +56 -0
  66. package/src/features/login/hooks/use-login-flow.ts +119 -0
  67. package/src/features/login/login-screen.tsx +83 -0
  68. package/src/features/login/validation/schemas.ts +30 -0
  69. package/src/features/logs/hooks/use-error-logs.ts +65 -0
  70. package/src/features/logs/hooks/use-request-logs.ts +65 -0
  71. package/src/features/logs/logs-screen.tsx +31 -0
  72. package/src/features/logs/panels/error-logs-panel.tsx +183 -0
  73. package/src/features/logs/panels/index.ts +3 -0
  74. package/src/features/logs/panels/request-logs-panel.tsx +124 -0
  75. package/src/features/logs/services/logs.service.ts +68 -0
  76. package/src/features/notifications/components/notification-item.tsx +67 -0
  77. package/src/features/notifications/hooks/use-notifications.ts +78 -0
  78. package/src/features/notifications/notifications-center.tsx +119 -0
  79. package/src/features/notifications/services/notifications.service.ts +38 -0
  80. package/src/features/profile/components/avatar-crop-dialog.tsx +133 -0
  81. package/src/features/profile/components/index.ts +2 -0
  82. package/src/features/profile/force-password-change.tsx +114 -0
  83. package/src/features/profile/profile-screen.tsx +385 -0
  84. package/src/features/rbac/panels/actions-panel.tsx +20 -0
  85. package/src/features/rbac/panels/catalog-panel.tsx +243 -0
  86. package/src/features/rbac/panels/groups-panel.tsx +269 -0
  87. package/src/features/rbac/panels/index.ts +8 -0
  88. package/src/features/rbac/panels/permissions-panel.tsx +204 -0
  89. package/src/features/rbac/panels/resources-panel.tsx +20 -0
  90. package/src/features/rbac/panels/roles-panel.tsx +224 -0
  91. package/src/features/rbac/panels/scopes-panel.tsx +20 -0
  92. package/src/features/rbac/rbac-screen.tsx +80 -0
  93. package/src/features/rbac/services/rbac.service.ts +110 -0
  94. package/src/features/recovery/forgot-password-screen.tsx +69 -0
  95. package/src/features/recovery/reset-password-screen.tsx +163 -0
  96. package/src/features/users/components/user-form-dialog.tsx +187 -0
  97. package/src/features/users/hooks/use-users.ts +181 -0
  98. package/src/features/users/services/users.service.ts +83 -0
  99. package/src/features/users/users-screen.tsx +331 -0
  100. package/src/hooks/use-column-visibility.ts +54 -0
  101. package/src/hooks/use-confirm.tsx +70 -0
  102. package/src/hooks/use-filter-schema.ts +85 -0
  103. package/src/hooks/use-filters.ts +148 -0
  104. package/src/hooks/use-list-query.ts +69 -0
  105. package/src/hooks/use-request.ts +114 -0
  106. package/src/i18n/messages/en.ts +296 -0
  107. package/src/i18n/messages/pt.ts +305 -0
  108. package/src/index.ts +60 -0
  109. package/src/lib/utils.ts +6 -0
  110. package/src/middleware.ts +42 -0
  111. package/src/styles/core.css +142 -0
  112. 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,2 @@
1
+ // Barrel da pasta — importe daqui de fora; dentro da pasta use o caminho direto.
2
+ export * from "./avatar-crop-dialog";
@@ -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
+ }