@hed-hog/core 0.0.321 → 0.0.322

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 (64) hide show
  1. package/dist/auth/auth.controller.d.ts +3 -3
  2. package/dist/auth/auth.service.d.ts +8 -8
  3. package/dist/core.module.d.ts.map +1 -1
  4. package/dist/core.module.js +3 -0
  5. package/dist/core.module.js.map +1 -1
  6. package/dist/dashboard/dashboard-user/dashboard-user.controller.d.ts +3 -3
  7. package/dist/dashboard/dashboard-user/dashboard-user.service.d.ts +3 -3
  8. package/dist/file/file.controller.d.ts +2 -2
  9. package/dist/file/file.service.d.ts +4 -4
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/mail-profile/dto/create.dto.d.ts +23 -0
  15. package/dist/mail-profile/dto/create.dto.d.ts.map +1 -0
  16. package/dist/mail-profile/dto/create.dto.js +139 -0
  17. package/dist/mail-profile/dto/create.dto.js.map +1 -0
  18. package/dist/mail-profile/dto/update.dto.d.ts +6 -0
  19. package/dist/mail-profile/dto/update.dto.d.ts.map +1 -0
  20. package/dist/mail-profile/dto/update.dto.js +9 -0
  21. package/dist/mail-profile/dto/update.dto.js.map +1 -0
  22. package/dist/mail-profile/mail-profile.controller.d.ts +94 -0
  23. package/dist/mail-profile/mail-profile.controller.d.ts.map +1 -0
  24. package/dist/mail-profile/mail-profile.controller.js +95 -0
  25. package/dist/mail-profile/mail-profile.controller.js.map +1 -0
  26. package/dist/mail-profile/mail-profile.module.d.ts +3 -0
  27. package/dist/mail-profile/mail-profile.module.d.ts.map +1 -0
  28. package/dist/mail-profile/mail-profile.module.js +31 -0
  29. package/dist/mail-profile/mail-profile.module.js.map +1 -0
  30. package/dist/mail-profile/mail-profile.service.d.ts +109 -0
  31. package/dist/mail-profile/mail-profile.service.d.ts.map +1 -0
  32. package/dist/mail-profile/mail-profile.service.js +278 -0
  33. package/dist/mail-profile/mail-profile.service.js.map +1 -0
  34. package/dist/session/session.controller.d.ts +1 -1
  35. package/dist/session/session.service.d.ts +3 -3
  36. package/dist/user/user.controller.d.ts +18 -3
  37. package/dist/user/user.controller.d.ts.map +1 -1
  38. package/dist/user/user.controller.js +22 -0
  39. package/dist/user/user.controller.js.map +1 -1
  40. package/dist/user/user.service.d.ts +22 -7
  41. package/dist/user/user.service.d.ts.map +1 -1
  42. package/dist/user/user.service.js +99 -0
  43. package/dist/user/user.service.js.map +1 -1
  44. package/hedhog/data/menu.yaml +35 -3
  45. package/hedhog/data/route.yaml +64 -0
  46. package/hedhog/frontend/app/mail/page.tsx.ejs +5 -0
  47. package/hedhog/frontend/app/mail/profiles/page.tsx.ejs +828 -0
  48. package/hedhog/frontend/app/users/page.tsx.ejs +25 -1383
  49. package/hedhog/frontend/app/users/permissions.tsx.ejs +109 -55
  50. package/hedhog/frontend/app/users/user-access-sheet.tsx.ejs +304 -0
  51. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +1385 -0
  52. package/hedhog/frontend/messages/en.json +91 -0
  53. package/hedhog/frontend/messages/pt.json +91 -0
  54. package/hedhog/table/mail_profile.yaml +75 -0
  55. package/package.json +4 -4
  56. package/src/core.module.ts +3 -0
  57. package/src/index.ts +2 -0
  58. package/src/mail-profile/dto/create.dto.ts +145 -0
  59. package/src/mail-profile/dto/update.dto.ts +4 -0
  60. package/src/mail-profile/mail-profile.controller.ts +56 -0
  61. package/src/mail-profile/mail-profile.module.ts +18 -0
  62. package/src/mail-profile/mail-profile.service.ts +308 -0
  63. package/src/user/user.controller.ts +13 -0
  64. package/src/user/user.service.ts +98 -5
@@ -0,0 +1,1385 @@
1
+ 'use client';
2
+
3
+ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from '@/components/ui/alert-dialog';
14
+ import { Button } from '@/components/ui/button';
15
+ import {
16
+ Card,
17
+ CardContent,
18
+ CardDescription,
19
+ CardHeader,
20
+ CardTitle,
21
+ } from '@/components/ui/card';
22
+ import {
23
+ Form,
24
+ FormControl,
25
+ FormField,
26
+ FormItem,
27
+ FormLabel,
28
+ FormMessage,
29
+ } from '@/components/ui/form';
30
+ import { FormActions } from '@/components/ui/form-actions';
31
+ import { Input } from '@/components/ui/input';
32
+ import {
33
+ Sheet,
34
+ SheetContent,
35
+ SheetDescription,
36
+ SheetHeader,
37
+ SheetTitle,
38
+ } from '@/components/ui/sheet';
39
+ import { useFormDraft } from '@/hooks/use-form-draft';
40
+ import { formatDateTime } from '@/lib/format-date';
41
+ import { getPhotoUrl } from '@/lib/get-photo-url';
42
+ import { getUserEmail } from '@/lib/get-user-email';
43
+ import { User, UserMfa } from '@hed-hog/api-types';
44
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
45
+ import { zodResolver } from '@hookform/resolvers/zod';
46
+ import { Avatar, AvatarFallback, AvatarImage } from '@radix-ui/react-avatar';
47
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs';
48
+ import { formatDistanceToNow } from 'date-fns';
49
+ import { enUS, ptBR } from 'date-fns/locale';
50
+ import {
51
+ Activity,
52
+ Camera,
53
+ Clock,
54
+ Eye,
55
+ EyeOff,
56
+ Fingerprint,
57
+ Key,
58
+ KeyRound,
59
+ LogOut,
60
+ LucideIcon,
61
+ Mail,
62
+ Monitor,
63
+ RefreshCcw,
64
+ Save,
65
+ Shield,
66
+ ShieldCheck,
67
+ ShieldOff,
68
+ Trash2,
69
+ UserCircle,
70
+ UserIcon,
71
+ Users,
72
+ } from 'lucide-react';
73
+ import { useTranslations } from 'next-intl';
74
+ import { useEffect, useMemo, useState } from 'react';
75
+ import { useForm, useWatch } from 'react-hook-form';
76
+ import { toast } from 'sonner';
77
+ import { z } from 'zod';
78
+ import { ActiveSessions } from './active-session';
79
+ import { UserIdentifiersSection } from './identifiers';
80
+ import { PermissionsSection } from './permissions';
81
+
82
+ export type UserSheetProps = {
83
+ open: boolean;
84
+ onOpenChange: (open: boolean) => void;
85
+ userId?: number | null;
86
+ onSuccess?: () => void;
87
+ };
88
+
89
+ type UserCreateDraftPayload = {
90
+ values: {
91
+ name: string;
92
+ email: string;
93
+ };
94
+ };
95
+
96
+ type UserEditDraftPayload = {
97
+ userId: number | null;
98
+ values: {
99
+ name: string;
100
+ email: string;
101
+ };
102
+ };
103
+
104
+ const USER_CREATE_DRAFT_STORAGE_KEY = 'core-user-create-draft';
105
+
106
+ const accountProviders = [
107
+ { name: 'Google', icon: '/google.svg' },
108
+ { name: 'GitHub', icon: '/github.svg' },
109
+ { name: 'Facebook', icon: '/facebook.svg' },
110
+ ];
111
+
112
+ function generateRandomPassword(length = 16) {
113
+ const lowercase = 'abcdefghijkmnopqrstuvwxyz';
114
+ const uppercase = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
115
+ const numbers = '23456789';
116
+ const symbols = '@#$%&*!?-_+';
117
+ const allChars = `${lowercase}${uppercase}${numbers}${symbols}`;
118
+
119
+ const groups = [lowercase, uppercase, numbers, symbols];
120
+ const randomValues = new Uint32Array(length + groups.length);
121
+ globalThis.crypto.getRandomValues(randomValues);
122
+
123
+ const chars: string[] = [];
124
+
125
+ groups.forEach((group, index) => {
126
+ const randomValue = randomValues[index] ?? 0;
127
+ chars.push(group.charAt(randomValue % group.length));
128
+ });
129
+
130
+ for (let i = groups.length; i < randomValues.length; i++) {
131
+ const randomValue = randomValues[i] ?? 0;
132
+ chars.push(allChars.charAt(randomValue % allChars.length));
133
+ }
134
+
135
+ for (let i = chars.length - 1; i > 0; i--) {
136
+ const randomValue = randomValues[i] ?? 0;
137
+ const j = randomValue % (i + 1);
138
+ const currentChar = chars[i]!;
139
+ const swapChar = chars[j]!;
140
+ chars[i] = swapChar;
141
+ chars[j] = currentChar;
142
+ }
143
+
144
+ return chars.slice(0, length).join('');
145
+ }
146
+
147
+ // ─── Create Sheet ────────────────────────────────────────────────────────────
148
+
149
+ function UserCreateSheet({
150
+ open,
151
+ onOpenChange,
152
+ onSuccess,
153
+ }: {
154
+ open: boolean;
155
+ onOpenChange: (open: boolean) => void;
156
+ onSuccess?: () => void;
157
+ }) {
158
+ const t = useTranslations('core.UserPage');
159
+ const { request, currentLocaleCode, getSettingValue } = useApp();
160
+ const minPasswordLength = Number(getSettingValue('password-min-length')) || 6;
161
+
162
+ const [showPassword, setShowPassword] = useState(false);
163
+ const [formError, setFormError] = useState<string | null>(null);
164
+
165
+ const addUserSchema = z.object({
166
+ name: z.string().min(2, t('errorName')),
167
+ email: z.string().email(t('errorEmail')),
168
+ password: z
169
+ .string()
170
+ .min(minPasswordLength, t('errorPassword'))
171
+ .optional()
172
+ .or(z.literal('')),
173
+ });
174
+
175
+ const form = useForm<z.infer<typeof addUserSchema>>({
176
+ resolver: zodResolver(addUserSchema),
177
+ defaultValues: { name: '', email: '', password: '' },
178
+ });
179
+
180
+ const watchedValues = useWatch({ control: form.control });
181
+
182
+ const {
183
+ clearDraft,
184
+ loadDraft,
185
+ hasDraft,
186
+ savedAt: draftSavedAt,
187
+ } = useFormDraft<UserCreateDraftPayload>({
188
+ storageKey: USER_CREATE_DRAFT_STORAGE_KEY,
189
+ value: {
190
+ values: {
191
+ name: watchedValues.name ?? '',
192
+ email: watchedValues.email ?? '',
193
+ },
194
+ },
195
+ hasData: Boolean(
196
+ (watchedValues.name ?? '').trim() || (watchedValues.email ?? '').trim()
197
+ ),
198
+ enabled: open,
199
+ });
200
+
201
+ const draftStatusContent = useMemo(() => {
202
+ if (!hasDraft || !draftSavedAt) return null;
203
+ const savedDate = new Date(draftSavedAt);
204
+ if (Number.isNaN(savedDate.getTime())) return null;
205
+ const locale = currentLocaleCode === 'en' ? enUS : ptBR;
206
+ const relativeLabel = formatDistanceToNow(savedDate, {
207
+ addSuffix: true,
208
+ locale,
209
+ });
210
+ const absoluteLabel = formatDateTime(
211
+ savedDate,
212
+ getSettingValue,
213
+ currentLocaleCode
214
+ );
215
+ return currentLocaleCode === 'en'
216
+ ? `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`
217
+ : `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`;
218
+ }, [draftSavedAt, currentLocaleCode, getSettingValue, hasDraft]);
219
+
220
+ useEffect(() => {
221
+ if (!open) return;
222
+ const storedDraft = loadDraft();
223
+ form.reset({
224
+ name: storedDraft?.payload.values.name ?? '',
225
+ email: storedDraft?.payload.values.email ?? '',
226
+ password: '',
227
+ });
228
+ }, [open]); // eslint-disable-line react-hooks/exhaustive-deps
229
+
230
+ const onSubmit = async (values: z.infer<typeof addUserSchema>) => {
231
+ try {
232
+ await request({ url: '/user', method: 'POST', data: values });
233
+ clearDraft();
234
+ form.reset();
235
+ onOpenChange(false);
236
+ setFormError(null);
237
+ onSuccess?.();
238
+ } catch (err) {
239
+ const e: any = err;
240
+ const msg =
241
+ e?.response?.data?.message ||
242
+ e?.response?.data?.error ||
243
+ e?.message ||
244
+ 'Server error';
245
+ setFormError(String(msg));
246
+ }
247
+ };
248
+
249
+ return (
250
+ <Sheet open={open} onOpenChange={onOpenChange}>
251
+ <SheetContent className="w-full sm:max-w-lg overflow-y-auto gap-0">
252
+ <SheetHeader>
253
+ <SheetTitle>{t('dialogAddUserTitle')}</SheetTitle>
254
+ <SheetDescription>{t('description')}</SheetDescription>
255
+ </SheetHeader>
256
+ <div className="w-full border-t pt-1 mt-1" />
257
+ <Form {...form}>
258
+ <form
259
+ onSubmit={form.handleSubmit(onSubmit)}
260
+ className="px-4 gap-4 w-full flex flex-col pt-2"
261
+ >
262
+ <FormField
263
+ control={form.control}
264
+ name="name"
265
+ render={({ field }) => (
266
+ <FormItem>
267
+ <FormLabel>{t('formNameLabel')}</FormLabel>
268
+ <FormControl>
269
+ <Input {...field} />
270
+ </FormControl>
271
+ <FormMessage />
272
+ </FormItem>
273
+ )}
274
+ />
275
+ <FormField
276
+ control={form.control}
277
+ name="email"
278
+ render={({ field }) => (
279
+ <FormItem>
280
+ <FormLabel>{t('formEmailLabel')}</FormLabel>
281
+ <FormControl>
282
+ <Input type="email" {...field} />
283
+ </FormControl>
284
+ <FormMessage />
285
+ </FormItem>
286
+ )}
287
+ />
288
+ <FormField
289
+ control={form.control}
290
+ name="password"
291
+ render={({ field }) => (
292
+ <FormItem>
293
+ <FormLabel>{t('formPasswordLabel')}</FormLabel>
294
+ <FormControl>
295
+ <div className="relative">
296
+ <Input
297
+ type={showPassword ? 'text' : 'password'}
298
+ {...field}
299
+ className="pr-10"
300
+ />
301
+ <button
302
+ type="button"
303
+ onClick={() => setShowPassword((s) => !s)}
304
+ className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded p-1 text-sm text-muted-foreground hover:bg-muted/50"
305
+ aria-label={
306
+ showPassword ? t('hidePassword') : t('showPassword')
307
+ }
308
+ >
309
+ {showPassword ? (
310
+ <EyeOff className="h-4 w-4" />
311
+ ) : (
312
+ <Eye className="h-4 w-4" />
313
+ )}
314
+ </button>
315
+ </div>
316
+ </FormControl>
317
+ <FormMessage />
318
+ </FormItem>
319
+ )}
320
+ />
321
+
322
+ {formError && (
323
+ <Alert
324
+ variant="destructive"
325
+ className="border-red-300 bg-red-50 rounded-md p-4"
326
+ >
327
+ <AlertTitle className="text-sm">
328
+ {t('verifyYourInput')}
329
+ </AlertTitle>
330
+ <AlertDescription className="text-sm">
331
+ {formError}
332
+ </AlertDescription>
333
+ </Alert>
334
+ )}
335
+
336
+ <FormActions
337
+ sheet
338
+ statusContent={draftStatusContent}
339
+ submitLabel={t('buttonAddUser')}
340
+ submitType="submit"
341
+ sheetClassName="px-0 pb-0"
342
+ />
343
+ </form>
344
+ </Form>
345
+ </SheetContent>
346
+ </Sheet>
347
+ );
348
+ }
349
+
350
+ // ─── Edit Sheet ───────────────────────────────────────────────────────────────
351
+
352
+ function UserEditSheet({
353
+ open,
354
+ onOpenChange,
355
+ userId,
356
+ onSuccess,
357
+ }: {
358
+ open: boolean;
359
+ onOpenChange: (open: boolean) => void;
360
+ userId: number;
361
+ onSuccess?: () => void;
362
+ }) {
363
+ const t = useTranslations('core.UserPage');
364
+ const userActivityT = useTranslations('core.UserActivity');
365
+ const { request, currentLocaleCode, getSettingValue } = useApp();
366
+ const minPasswordLength = Number(getSettingValue('password-min-length')) || 6;
367
+
368
+ const [activeTab, setActiveTab] = useState('overview');
369
+ const [photo, setPhoto] = useState<number | null | undefined>(null);
370
+ const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
371
+ const [editFormError, setEditFormError] = useState<string | null>(null);
372
+ const [resetFormError, setResetFormError] = useState<string | null>(null);
373
+ const [isResetPasswordDialogOpen, setIsResetPasswordDialogOpen] =
374
+ useState(false);
375
+ const [isResetResultDialogOpen, setIsResetResultDialogOpen] = useState(false);
376
+ const [isResettingPassword, setIsResettingPassword] = useState(false);
377
+ const [showResetPassword, setShowResetPassword] = useState(false);
378
+ const [showResetPasswordResult, setShowResetPasswordResult] = useState(false);
379
+ const [resetPasswordResult, setResetPasswordResult] = useState('');
380
+ const [openDeleteModal, setOpenDeleteModal] = useState(false);
381
+
382
+ const {
383
+ data: editingUser,
384
+ isLoading,
385
+ refetch: refetchUser,
386
+ } = useQuery<User>({
387
+ queryKey: ['user', userId],
388
+ queryFn: async () => {
389
+ const response = await request<User>({
390
+ url: `/user/${userId}`,
391
+ method: 'GET',
392
+ });
393
+ return response.data;
394
+ },
395
+ enabled: open && !!userId,
396
+ });
397
+
398
+ const editUserSchema = z.object({
399
+ name: z.string().min(2, t('errorName')),
400
+ email: z.string().email(t('errorEmail')),
401
+ });
402
+
403
+ const editForm = useForm({
404
+ resolver: zodResolver(editUserSchema),
405
+ defaultValues: { name: '', email: '' },
406
+ });
407
+
408
+ const resetPasswordSchema = z.object({
409
+ password: z.string().min(minPasswordLength, t('errorPassword')),
410
+ });
411
+
412
+ const resetPasswordForm = useForm<z.infer<typeof resetPasswordSchema>>({
413
+ resolver: zodResolver(resetPasswordSchema),
414
+ defaultValues: { password: '' },
415
+ });
416
+
417
+ const watchedEditValues = useWatch({ control: editForm.control });
418
+
419
+ const editDraftStorageKey = `core-user-edit-draft-${userId}`;
420
+
421
+ const {
422
+ clearDraft: clearEditDraft,
423
+ loadDraft: loadEditDraft,
424
+ hasDraft: hasEditDraft,
425
+ savedAt: editDraftSavedAt,
426
+ } = useFormDraft<UserEditDraftPayload>({
427
+ storageKey: editDraftStorageKey,
428
+ value: {
429
+ userId: userId ?? null,
430
+ values: {
431
+ name: watchedEditValues.name ?? '',
432
+ email: watchedEditValues.email ?? '',
433
+ },
434
+ },
435
+ hasData: Boolean(
436
+ (watchedEditValues.name ?? '').trim() ||
437
+ (watchedEditValues.email ?? '').trim()
438
+ ),
439
+ enabled: open && !!editingUser,
440
+ });
441
+
442
+ const editDraftStatusContent = useMemo(() => {
443
+ if (!hasEditDraft || !editDraftSavedAt) return null;
444
+ const savedDate = new Date(editDraftSavedAt);
445
+ if (Number.isNaN(savedDate.getTime())) return null;
446
+ const locale = currentLocaleCode === 'en' ? enUS : ptBR;
447
+ const relativeLabel = formatDistanceToNow(savedDate, {
448
+ addSuffix: true,
449
+ locale,
450
+ });
451
+ const absoluteLabel = formatDateTime(
452
+ savedDate,
453
+ getSettingValue,
454
+ currentLocaleCode
455
+ );
456
+ return currentLocaleCode === 'en'
457
+ ? `Draft saved ${relativeLabel} • Last saved: ${absoluteLabel}`
458
+ : `Rascunho salvo ${relativeLabel} • Último salvamento: ${absoluteLabel}`;
459
+ }, [editDraftSavedAt, currentLocaleCode, getSettingValue, hasEditDraft]);
460
+
461
+ useEffect(() => {
462
+ if (!editingUser) return;
463
+ const storedDraft = loadEditDraft();
464
+ const shouldRestoreDraft =
465
+ storedDraft?.payload.userId === Number(editingUser.id);
466
+ editForm.reset(
467
+ shouldRestoreDraft
468
+ ? storedDraft.payload.values
469
+ : {
470
+ name: editingUser.name || '',
471
+ email: getUserEmail(editingUser),
472
+ }
473
+ );
474
+ setActiveTab('overview');
475
+ }, [editingUser]); // eslint-disable-line react-hooks/exhaustive-deps
476
+
477
+ const handleClose = (open: boolean) => {
478
+ if (!open) {
479
+ setPhoto(null);
480
+ setIsResetPasswordDialogOpen(false);
481
+ setIsResetResultDialogOpen(false);
482
+ setResetPasswordResult('');
483
+ }
484
+ onOpenChange(open);
485
+ };
486
+
487
+ const handleRefreshUser = async () => {
488
+ await refetchUser();
489
+ };
490
+
491
+ const onEditSubmit = async (values: z.infer<typeof editUserSchema>) => {
492
+ try {
493
+ await request({
494
+ url: `/user/${userId}`,
495
+ method: 'PATCH',
496
+ data: values,
497
+ });
498
+ clearEditDraft();
499
+ await refetchUser();
500
+ setEditFormError(null);
501
+ toast.success(t('userUpdatedSuccess'));
502
+ onSuccess?.();
503
+ } catch (err) {
504
+ const e: any = err;
505
+ const msg =
506
+ e?.response?.data?.message ||
507
+ e?.response?.data?.error ||
508
+ e?.message ||
509
+ t('serverError');
510
+ setEditFormError(String(msg));
511
+ }
512
+ };
513
+
514
+ const onDelete = async () => {
515
+ try {
516
+ await request({
517
+ url: `/user`,
518
+ method: 'DELETE',
519
+ data: { ids: [Number(userId)] },
520
+ });
521
+ setOpenDeleteModal(false);
522
+ handleClose(false);
523
+ toast.success(t('userDeletedSuccess'));
524
+ onSuccess?.();
525
+ } catch (err) {
526
+ const e: any = err;
527
+ const msg =
528
+ e?.response?.data?.message ||
529
+ e?.response?.data?.error ||
530
+ e?.message ||
531
+ t('serverError');
532
+ setEditFormError(String(msg));
533
+ }
534
+ };
535
+
536
+ const openResetPasswordDialog = () => {
537
+ const generatedPassword = generateRandomPassword();
538
+ resetPasswordForm.reset({ password: generatedPassword });
539
+ setResetFormError(null);
540
+ setShowResetPassword(false);
541
+ setIsResetPasswordDialogOpen(true);
542
+ };
543
+
544
+ const onResetPasswordSubmit = async () => {
545
+ setIsResettingPassword(true);
546
+ setResetFormError(null);
547
+ try {
548
+ const { password } = resetPasswordForm.getValues();
549
+ const response = await request<{ password: string }>({
550
+ url: `/user/${userId}/reset-password`,
551
+ method: 'PATCH',
552
+ data: { password },
553
+ });
554
+ setResetPasswordResult(response.data.password);
555
+ setIsResetPasswordDialogOpen(false);
556
+ setIsResetResultDialogOpen(true);
557
+ await refetchUser();
558
+ toast.success(t('passwordResetSuccess'));
559
+ } catch (err) {
560
+ const e: any = err;
561
+ const msg =
562
+ e?.response?.data?.message ||
563
+ e?.response?.data?.error ||
564
+ e?.message ||
565
+ t('serverError');
566
+ setResetFormError(String(msg));
567
+ } finally {
568
+ setIsResettingPassword(false);
569
+ }
570
+ };
571
+
572
+ const copyResetPassword = async () => {
573
+ try {
574
+ await navigator.clipboard.writeText(resetPasswordResult);
575
+ toast.success(t('passwordCopied'));
576
+ } catch {
577
+ toast.error(t('passwordCopyError'));
578
+ }
579
+ };
580
+
581
+ const handleAvatarClick = () => {
582
+ const input = document.createElement('input');
583
+ input.type = 'file';
584
+ input.accept = 'image/*';
585
+ input.onchange = async (e: any) => {
586
+ const file = e.target?.files?.[0];
587
+ if (file) {
588
+ setIsUploadingAvatar(true);
589
+ try {
590
+ const formData = new FormData();
591
+ formData.append('avatar', file);
592
+ const response: any = await request({
593
+ url: `/user/${userId}/avatar`,
594
+ method: 'POST',
595
+ data: formData,
596
+ headers: { 'Content-Type': 'multipart/form-data' },
597
+ });
598
+ setPhoto(response.data.id);
599
+ toast.success(t('pictureUpdatedSuccess'));
600
+ await refetchUser();
601
+ onSuccess?.();
602
+ } catch (err) {
603
+ console.error(err);
604
+ } finally {
605
+ setIsUploadingAvatar(false);
606
+ }
607
+ }
608
+ };
609
+ input.click();
610
+ };
611
+
612
+ if (!open) return null;
613
+
614
+ if (isLoading || !editingUser) {
615
+ return (
616
+ <Sheet open={open} onOpenChange={handleClose}>
617
+ <SheetContent className="w-full sm:max-w-4xl overflow-y-auto gap-0">
618
+ <SheetHeader>
619
+ <SheetTitle>{t('titleEditUser')}</SheetTitle>
620
+ <SheetDescription>{t('description')}</SheetDescription>
621
+ </SheetHeader>
622
+ <div className="flex items-center justify-center h-40">
623
+ <div className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
624
+ </div>
625
+ </SheetContent>
626
+ </Sheet>
627
+ );
628
+ }
629
+
630
+ return (
631
+ <Sheet open={open} onOpenChange={handleClose}>
632
+ <SheetContent className="w-full sm:max-w-4xl overflow-y-auto gap-0">
633
+ <SheetHeader>
634
+ <SheetTitle>{t('titleEditUser')}</SheetTitle>
635
+ <SheetDescription>{t('description')}</SheetDescription>
636
+ </SheetHeader>
637
+
638
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
639
+ <TabsList className="grid w-full grid-cols-7 bg-muted rounded-md text-muted-foreground text-sm">
640
+ <TabsTrigger
641
+ value="overview"
642
+ className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
643
+ >
644
+ <UserCircle className="h-4 w-4 min-h-4 min-w-4" />
645
+ <span className="hidden md:inline">{t('tabOverview')}</span>
646
+ </TabsTrigger>
647
+ <TabsTrigger
648
+ value="edit"
649
+ className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
650
+ >
651
+ <Save className="h-4 w-4 min-h-4 min-w-4" />
652
+ <span className="hidden md:inline">{t('tabEdit')}</span>
653
+ </TabsTrigger>
654
+ <TabsTrigger
655
+ value="credentials"
656
+ className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
657
+ >
658
+ <Key className="h-4 w-4 min-h-4 min-w-4" />
659
+ <span className="hidden md:inline">{t('tabCredentials')}</span>
660
+ </TabsTrigger>
661
+ <TabsTrigger
662
+ value="identifiers"
663
+ className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
664
+ >
665
+ <Fingerprint className="h-4 w-4 min-h-4 min-w-4" />
666
+ <span className="hidden md:inline">{t('tabIdentifiers')}</span>
667
+ </TabsTrigger>
668
+ <TabsTrigger
669
+ value="permissions"
670
+ className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
671
+ >
672
+ <ShieldCheck className="h-4 w-4 min-h-4 min-w-4" />
673
+ <span className="hidden md:inline">{t('tabPermissions')}</span>
674
+ </TabsTrigger>
675
+ <TabsTrigger
676
+ value="mfa"
677
+ className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
678
+ >
679
+ <Shield className="h-4 w-4 min-h-4 min-w-4" />
680
+ <span className="hidden md:inline">{t('tabMfa')}</span>
681
+ </TabsTrigger>
682
+ <TabsTrigger
683
+ value="sessions"
684
+ className="flex items-center justify-center gap-2 px-3 py-4 data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-medium"
685
+ >
686
+ <Monitor className="h-4 w-4 min-h-4 min-w-4" />
687
+ <span className="hidden md:inline">{t('tabSessions')}</span>
688
+ </TabsTrigger>
689
+ </TabsList>
690
+
691
+ {/* Overview Tab */}
692
+ <TabsContent value="overview" className="space-y-4 mt-4 p-4 pt-0">
693
+ <div className="flex flex-col items-center gap-3 py-4">
694
+ <div className="relative group">
695
+ <Avatar className="h-24 w-24 shadow-md">
696
+ <AvatarImage
697
+ src={getPhotoUrl(photo || editingUser.photo_id)}
698
+ alt={editingUser.name}
699
+ className="object-cover h-24 w-24 rounded-full"
700
+ />
701
+ <AvatarFallback className="text-2xl font-semibold bg-linear-to-br from-purple-500 to-pink-500 text-white">
702
+ {editingUser.name?.charAt(0)?.toUpperCase() || 'U'}
703
+ </AvatarFallback>
704
+ </Avatar>
705
+ <button
706
+ onClick={handleAvatarClick}
707
+ disabled={isUploadingAvatar}
708
+ className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed"
709
+ >
710
+ {isUploadingAvatar ? (
711
+ <div className="h-5 w-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
712
+ ) : (
713
+ <Camera className="h-6 w-6 text-white" />
714
+ )}
715
+ </button>
716
+ <div className="absolute -bottom-1 -right-1">
717
+ {editingUser.suspended_until ? (
718
+ <div className="rounded-full bg-rose-500 p-1.5 shadow-md">
719
+ <ShieldCheck className="h-3.5 w-3.5 text-white" />
720
+ </div>
721
+ ) : (
722
+ <div className="rounded-full bg-green-500 p-1.5 shadow-md">
723
+ <Users className="h-3.5 w-3.5 text-white" />
724
+ </div>
725
+ )}
726
+ </div>
727
+ </div>
728
+ <div className="text-center space-y-0.5">
729
+ <h3 className="text-xl font-bold bg-linear-to-r from-purple-600 to-pink-600 bg-clip-text text-transparent">
730
+ {editingUser.name}
731
+ </h3>
732
+ <p className="text-sm text-muted-foreground flex items-center gap-2 justify-center">
733
+ <Mail className="h-4 w-4" />
734
+ {getUserEmail(editingUser)}
735
+ </p>
736
+ </div>
737
+ </div>
738
+
739
+ <div className="grid grid-cols-2 gap-2">
740
+ <Card
741
+ className={`border-l-4 ${editingUser.suspended_until ? 'border-l-red-500' : 'border-l-green-500'} py-2`}
742
+ >
743
+ <CardHeader className="p-2">
744
+ <div className="flex items-center justify-between">
745
+ <div>
746
+ <CardDescription className="text-xs">
747
+ {t('status')}
748
+ </CardDescription>
749
+ <CardTitle className="text-sm">
750
+ {editingUser.suspended_until ? (
751
+ <span className="text-rose-600">{t('blocked')}</span>
752
+ ) : (
753
+ <span className="text-green-600">{t('active')}</span>
754
+ )}
755
+ </CardTitle>
756
+ </div>
757
+ <div
758
+ className={`rounded-full p-1 ${editingUser.suspended_until ? 'bg-rose-100' : 'bg-green-100'}`}
759
+ >
760
+ <Activity
761
+ className={`h-6 w-6 ${editingUser.suspended_until ? 'text-rose-600' : 'text-green-600'}`}
762
+ />
763
+ </div>
764
+ </div>
765
+ </CardHeader>
766
+ </Card>
767
+
768
+ <Card className="border-l-4 border-l-primary-500 py-2">
769
+ <CardHeader className="p-2">
770
+ <div className="flex items-center justify-between">
771
+ <div>
772
+ <CardDescription className="text-xs">
773
+ {t('cardLastLoginDescription')}
774
+ </CardDescription>
775
+ {Boolean(editingUser.user_session?.length) ? (
776
+ <CardTitle className="text-sm truncate">
777
+ {editingUser.user_session?.[0]?.created_at &&
778
+ formatDistanceToNow(
779
+ new Date(
780
+ editingUser.user_session?.[0]?.created_at
781
+ ),
782
+ {
783
+ addSuffix: true,
784
+ locale:
785
+ currentLocaleCode === 'en' ? enUS : ptBR,
786
+ }
787
+ )}
788
+ <small className="text-xs ml-2 text-muted-foreground">
789
+ {formatDateTime(
790
+ String(editingUser.user_session?.[0]?.created_at),
791
+ getSettingValue,
792
+ currentLocaleCode
793
+ )}
794
+ </small>
795
+ </CardTitle>
796
+ ) : (
797
+ <CardTitle>{t('cardLastLoginTitle')}</CardTitle>
798
+ )}
799
+ </div>
800
+ <div className="rounded-full bg-primary-100 p-1">
801
+ <Clock className="h-6 w-6 text-primary-600" />
802
+ </div>
803
+ </div>
804
+ </CardHeader>
805
+ </Card>
806
+ </div>
807
+
808
+ <div className="space-y-3">
809
+ <div className="flex items-center justify-between">
810
+ <h4 className="text-sm font-semibold flex items-center gap-2">
811
+ <Shield className="h-4 w-4" />
812
+ {t('securityTitle')}
813
+ </h4>
814
+ </div>
815
+
816
+ <div className="grid grid-cols-3 gap-2">
817
+ <Card className="border-l-4 border-l-amber-500 py-2">
818
+ <CardHeader className="p-2">
819
+ <div className="flex flex-col items-center text-center">
820
+ <div className="rounded-full bg-amber-100 p-1 mb-1">
821
+ <Shield className="h-4 w-4 text-amber-600" />
822
+ </div>
823
+ <CardDescription className="text-xs">
824
+ {t('securityDescription')}
825
+ </CardDescription>
826
+ <CardTitle className="text-sm font-bold">
827
+ {editingUser.user_mfa &&
828
+ editingUser.user_mfa.length > 0 ? (
829
+ <span className="text-amber-600">
830
+ {t('securityLevelHight')}
831
+ </span>
832
+ ) : (
833
+ <span className="text-orange-600">
834
+ {t('securityLevelMedium')}
835
+ </span>
836
+ )}
837
+ </CardTitle>
838
+ </div>
839
+ </CardHeader>
840
+ </Card>
841
+
842
+ <Card className="border-l-4 border-l-purple-500 py-2">
843
+ <CardHeader className="p-2">
844
+ <div className="flex flex-col items-center text-center">
845
+ <div className="rounded-full bg-purple-100 p-1 mb-1">
846
+ <Shield className="h-4 w-4 text-purple-600" />
847
+ </div>
848
+ <CardDescription className="text-xs">
849
+ {t('cardMfaMethods')}
850
+ </CardDescription>
851
+ <CardTitle className="text-sm font-bold text-purple-600">
852
+ {editingUser.user_mfa?.length || 0}
853
+ </CardTitle>
854
+ </div>
855
+ </CardHeader>
856
+ </Card>
857
+
858
+ <Card className="border-l-4 border-l-blue-500 py-2">
859
+ <CardHeader className="p-2">
860
+ <div className="flex flex-col items-center text-center">
861
+ <div className="rounded-full bg-blue-100 p-1 mb-1">
862
+ <Fingerprint className="h-4 w-4 text-blue-600" />
863
+ </div>
864
+ <CardDescription className="text-xs">
865
+ {t('cardUserId')}
866
+ </CardDescription>
867
+ <CardTitle className="text-xs font-bold text-blue-600">
868
+ #{editingUser.id}
869
+ </CardTitle>
870
+ </div>
871
+ </CardHeader>
872
+ </Card>
873
+ </div>
874
+ </div>
875
+
876
+ {editingUser.suspended_until && (
877
+ <Card className="border-rose-200 bg-rose-50/50">
878
+ <CardHeader className="p-2">
879
+ <div className="flex items-start gap-2">
880
+ <div className="rounded-full bg-rose-100 p-1">
881
+ <ShieldCheck className="h-3 w-3 text-rose-600" />
882
+ </div>
883
+ <div className="flex-1 space-y-2">
884
+ <div>
885
+ <p className="text-sm font-semibold text-rose-900">
886
+ {t('accountSuspended')}
887
+ </p>
888
+ <p className="text-xs text-rose-700 flex items-center gap-1 mt-1">
889
+ <Clock className="h-3 w-3" />
890
+ {t('until')}: {editingUser.suspended_until}
891
+ </p>
892
+ </div>
893
+ {editingUser.suspended_reason && (
894
+ <div className="rounded-md bg-rose-100 p-2">
895
+ <p className="text-xs font-medium text-rose-900">
896
+ {t('reason')}
897
+ </p>
898
+ <p className="text-xs text-rose-700">
899
+ {editingUser.suspended_reason}
900
+ </p>
901
+ </div>
902
+ )}
903
+ </div>
904
+ </div>
905
+ </CardHeader>
906
+ </Card>
907
+ )}
908
+
909
+ <div className="space-y-3">
910
+ <div className="flex items-center justify-between">
911
+ <h4 className="text-sm font-semibold flex items-center gap-2">
912
+ <Shield className="h-4 w-4" />
913
+ {t('recentActivityTitle')}
914
+ </h4>
915
+ </div>
916
+
917
+ <Card className="rounded-md p-0">
918
+ <CardContent className="p-4 gap-2 flex flex-col">
919
+ {editingUser.user_activity?.length ? (
920
+ <div className="space-y-3">
921
+ {editingUser.user_activity
922
+ ?.slice(0, 5)
923
+ .map((activity, index) => {
924
+ const actionIcons: Record<string, LucideIcon> = {
925
+ login: UserIcon,
926
+ forgotPassword: KeyRound,
927
+ logout: LogOut,
928
+ resetPassword: RefreshCcw,
929
+ revokeAllSessions: ShieldOff,
930
+ revokeSession: Shield,
931
+ };
932
+ const Icon = actionIcons[activity.action] || UserIcon;
933
+
934
+ return (
935
+ <div
936
+ key={index}
937
+ className="flex items-start gap-3 border-b pb-3 last:border-none"
938
+ >
939
+ <div className="rounded-md bg-blue-50 p-2">
940
+ <Icon className="w-4 h-4 text-gray-600" />
941
+ </div>
942
+ <div className="flex-1">
943
+ <p className="text-sm font-medium">
944
+ {userActivityT(activity.action)}
945
+ </p>
946
+ <p className="text-[11px] text-gray-500 mt-1">
947
+ {activity.created_at &&
948
+ formatDistanceToNow(
949
+ new Date(activity.created_at),
950
+ {
951
+ addSuffix: true,
952
+ locale:
953
+ currentLocaleCode === 'en'
954
+ ? enUS
955
+ : ptBR,
956
+ }
957
+ )}
958
+ </p>
959
+ </div>
960
+ </div>
961
+ );
962
+ })}
963
+ </div>
964
+ ) : (
965
+ <span className="text-sm text-muted-foreground">
966
+ {t('noRecentActivity')}
967
+ </span>
968
+ )}
969
+ </CardContent>
970
+ </Card>
971
+ </div>
972
+
973
+ <div className="flex justify-end">
974
+ <Button
975
+ className="cursor-pointer"
976
+ variant="destructive"
977
+ onClick={() => setOpenDeleteModal(true)}
978
+ >
979
+ <Trash2 className="w-4 h-4" />
980
+ <span>{t('buttonDeleteUser')}</span>
981
+ </Button>
982
+ </div>
983
+
984
+ <AlertDialog
985
+ open={openDeleteModal}
986
+ onOpenChange={setOpenDeleteModal}
987
+ >
988
+ <AlertDialogContent>
989
+ <AlertDialogHeader>
990
+ <AlertDialogTitle>
991
+ {t('dialogDeleteUserTitle')}
992
+ </AlertDialogTitle>
993
+ <AlertDialogDescription>
994
+ {t('dialogDeleteUserDescription')}
995
+ </AlertDialogDescription>
996
+ </AlertDialogHeader>
997
+ <AlertDialogFooter>
998
+ <AlertDialogCancel>{t('deleteUserCancel')}</AlertDialogCancel>
999
+ <AlertDialogAction onClick={onDelete}>
1000
+ {t('deleteUserConfirm')}
1001
+ </AlertDialogAction>
1002
+ </AlertDialogFooter>
1003
+ </AlertDialogContent>
1004
+ </AlertDialog>
1005
+ </TabsContent>
1006
+
1007
+ {/* Edit Tab */}
1008
+ <TabsContent value="edit" className="space-y-4 mt-4 p-4 pt-0">
1009
+ <Form {...editForm}>
1010
+ <form
1011
+ onSubmit={editForm.handleSubmit(onEditSubmit)}
1012
+ className="space-y-4"
1013
+ >
1014
+ <FormField
1015
+ control={editForm.control}
1016
+ name="name"
1017
+ render={({ field }) => (
1018
+ <FormItem>
1019
+ <FormLabel>{t('editNameLabel')}</FormLabel>
1020
+ <FormControl>
1021
+ <Input
1022
+ placeholder={t('editNamePlaceholder')}
1023
+ {...field}
1024
+ />
1025
+ </FormControl>
1026
+ <FormMessage />
1027
+ </FormItem>
1028
+ )}
1029
+ />
1030
+ <FormActions
1031
+ statusContent={editDraftStatusContent}
1032
+ cancelLabel={t('cancel')}
1033
+ onCancel={() => onOpenChange(false)}
1034
+ submitLabel={t('saveChanges')}
1035
+ submitType="submit"
1036
+ className="pt-2"
1037
+ />
1038
+ </form>
1039
+ </Form>
1040
+ </TabsContent>
1041
+
1042
+ {/* Credentials Tab */}
1043
+ <TabsContent value="credentials" className="space-y-4 mt-4 p-4 pt-0">
1044
+ <div className="space-y-3 rounded-lg border p-4">
1045
+ <div>
1046
+ <h4 className="text-sm font-semibold">
1047
+ {t('passwordResetTitle')}
1048
+ </h4>
1049
+ <p className="text-xs text-muted-foreground mt-1">
1050
+ {t('passwordResetDescription')}
1051
+ </p>
1052
+ </div>
1053
+ <p className="text-xs text-muted-foreground">
1054
+ {t('passwordResetNotice')}
1055
+ </p>
1056
+ <div className="flex flex-col gap-2">
1057
+ <Button
1058
+ type="button"
1059
+ onClick={openResetPasswordDialog}
1060
+ className="w-full sm:w-fit"
1061
+ >
1062
+ <RefreshCcw className="h-4 w-4 mr-2" />
1063
+ {t('buttonResetPassword')}
1064
+ </Button>
1065
+ </div>
1066
+ </div>
1067
+
1068
+ <div className="space-y-3">
1069
+ <h4 className="text-sm font-medium">
1070
+ {t('connectedAccountsTitle')}
1071
+ </h4>
1072
+ <div className="space-y-2">
1073
+ {accountProviders.map((provider) => {
1074
+ const isConnected = editingUser.user_account?.some(
1075
+ (account) =>
1076
+ account.provider?.toLowerCase() ===
1077
+ provider.name.toLowerCase()
1078
+ );
1079
+ return (
1080
+ <div
1081
+ key={provider.name}
1082
+ className={`flex items-center justify-between rounded-lg border p-3 ${isConnected ? '' : 'border-dashed'}`}
1083
+ >
1084
+ <div className="flex items-center gap-3">
1085
+ <div className="rounded-md bg-slate-50 p-2">
1086
+ <img
1087
+ src={provider.icon}
1088
+ alt={`${provider.name} icon`}
1089
+ className={`h-5 w-5 ${isConnected ? 'opacity-100' : 'opacity-50'}`}
1090
+ />
1091
+ </div>
1092
+ <div>
1093
+ <p
1094
+ className={`text-sm font-medium ${isConnected ? '' : 'text-muted-foreground'}`}
1095
+ >
1096
+ {provider.name}
1097
+ </p>
1098
+ <p className="text-xs text-muted-foreground">
1099
+ {isConnected ? t('connected') : t('notConnected')}
1100
+ </p>
1101
+ </div>
1102
+ </div>
1103
+ </div>
1104
+ );
1105
+ })}
1106
+ </div>
1107
+ </div>
1108
+
1109
+ <AlertDialog
1110
+ open={isResetPasswordDialogOpen}
1111
+ onOpenChange={(open) => {
1112
+ setIsResetPasswordDialogOpen(open);
1113
+ if (!open) setResetFormError(null);
1114
+ }}
1115
+ >
1116
+ <AlertDialogContent>
1117
+ <AlertDialogHeader>
1118
+ <AlertDialogTitle>
1119
+ {t('passwordResetDialogTitle')}
1120
+ </AlertDialogTitle>
1121
+ <AlertDialogDescription>
1122
+ {t('passwordResetDialogDescription')}
1123
+ </AlertDialogDescription>
1124
+ </AlertDialogHeader>
1125
+
1126
+ <Form {...resetPasswordForm}>
1127
+ <form className="space-y-4">
1128
+ <FormField
1129
+ control={resetPasswordForm.control}
1130
+ name="password"
1131
+ render={({ field }) => (
1132
+ <FormItem>
1133
+ <FormLabel>{t('passwordResetFieldLabel')}</FormLabel>
1134
+ <FormControl>
1135
+ <div className="space-y-2">
1136
+ <div className="relative">
1137
+ <Input
1138
+ type={showResetPassword ? 'text' : 'password'}
1139
+ {...field}
1140
+ className="pr-10"
1141
+ />
1142
+ <button
1143
+ type="button"
1144
+ onClick={() =>
1145
+ setShowResetPassword((s) => !s)
1146
+ }
1147
+ className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded p-1 text-sm text-muted-foreground hover:bg-muted/50"
1148
+ aria-label={
1149
+ showResetPassword
1150
+ ? t('hidePassword')
1151
+ : t('showPassword')
1152
+ }
1153
+ >
1154
+ {showResetPassword ? (
1155
+ <EyeOff className="h-4 w-4" />
1156
+ ) : (
1157
+ <Eye className="h-4 w-4" />
1158
+ )}
1159
+ </button>
1160
+ </div>
1161
+ <Button
1162
+ type="button"
1163
+ variant="outline"
1164
+ onClick={() => {
1165
+ resetPasswordForm.setValue(
1166
+ 'password',
1167
+ generateRandomPassword(),
1168
+ {
1169
+ shouldValidate: true,
1170
+ shouldDirty: true,
1171
+ }
1172
+ );
1173
+ }}
1174
+ >
1175
+ <RefreshCcw className="h-4 w-4 mr-2" />
1176
+ {t('buttonRegeneratePassword')}
1177
+ </Button>
1178
+ </div>
1179
+ </FormControl>
1180
+ <FormMessage />
1181
+ </FormItem>
1182
+ )}
1183
+ />
1184
+
1185
+ {resetFormError && (
1186
+ <Alert variant="destructive">
1187
+ <AlertTitle>{t('verifyYourInput')}</AlertTitle>
1188
+ <AlertDescription>{resetFormError}</AlertDescription>
1189
+ </Alert>
1190
+ )}
1191
+ </form>
1192
+ </Form>
1193
+
1194
+ <AlertDialogFooter>
1195
+ <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
1196
+ <AlertDialogAction
1197
+ disabled={isResettingPassword}
1198
+ onClick={async (event) => {
1199
+ event.preventDefault();
1200
+ const valid = await resetPasswordForm.trigger();
1201
+ if (!valid) return;
1202
+ await onResetPasswordSubmit();
1203
+ }}
1204
+ >
1205
+ {isResettingPassword
1206
+ ? t('passwordResetSubmitting')
1207
+ : t('passwordResetConfirm')}
1208
+ </AlertDialogAction>
1209
+ </AlertDialogFooter>
1210
+ </AlertDialogContent>
1211
+ </AlertDialog>
1212
+
1213
+ <AlertDialog
1214
+ open={isResetResultDialogOpen}
1215
+ onOpenChange={(open) => {
1216
+ setIsResetResultDialogOpen(open);
1217
+ if (!open) {
1218
+ setResetPasswordResult('');
1219
+ setShowResetPasswordResult(false);
1220
+ }
1221
+ }}
1222
+ >
1223
+ <AlertDialogContent>
1224
+ <AlertDialogHeader>
1225
+ <AlertDialogTitle>
1226
+ {t('passwordResultTitle')}
1227
+ </AlertDialogTitle>
1228
+ <AlertDialogDescription>
1229
+ {t('passwordResultDescription')}
1230
+ </AlertDialogDescription>
1231
+ </AlertDialogHeader>
1232
+
1233
+ <div className="space-y-3">
1234
+ <div className="relative">
1235
+ <Input
1236
+ readOnly
1237
+ value={resetPasswordResult}
1238
+ type={showResetPasswordResult ? 'text' : 'password'}
1239
+ className="pr-10"
1240
+ />
1241
+ <button
1242
+ type="button"
1243
+ onClick={() => setShowResetPasswordResult((s) => !s)}
1244
+ className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded p-1 text-sm text-muted-foreground hover:bg-muted/50"
1245
+ aria-label={
1246
+ showResetPasswordResult
1247
+ ? t('hidePassword')
1248
+ : t('showPassword')
1249
+ }
1250
+ >
1251
+ {showResetPasswordResult ? (
1252
+ <EyeOff className="h-4 w-4" />
1253
+ ) : (
1254
+ <Eye className="h-4 w-4" />
1255
+ )}
1256
+ </button>
1257
+ </div>
1258
+ <div className="flex justify-end gap-2">
1259
+ <Button
1260
+ type="button"
1261
+ variant="outline"
1262
+ onClick={copyResetPassword}
1263
+ >
1264
+ {t('buttonCopyPassword')}
1265
+ </Button>
1266
+ <Button
1267
+ type="button"
1268
+ onClick={() => setIsResetResultDialogOpen(false)}
1269
+ >
1270
+ {t('close')}
1271
+ </Button>
1272
+ </div>
1273
+ </div>
1274
+ </AlertDialogContent>
1275
+ </AlertDialog>
1276
+ </TabsContent>
1277
+
1278
+ {/* Identifiers Tab */}
1279
+ <TabsContent value="identifiers" className="space-y-4 mt-4 p-4 pt-0">
1280
+ <UserIdentifiersSection editingUser={editingUser} />
1281
+ </TabsContent>
1282
+
1283
+ {/* Permissions Tab */}
1284
+ <TabsContent value="permissions" className="space-y-4 mt-4 p-4 pt-0">
1285
+ <div className="space-y-3">
1286
+ <div className="flex items-center justify-between">
1287
+ <div>
1288
+ <h4 className="text-sm font-semibold flex items-center gap-2">
1289
+ <ShieldCheck className="h-4 w-4" />
1290
+ {t('permissionsTitle')}
1291
+ </h4>
1292
+ <p className="text-xs text-muted-foreground mt-1">
1293
+ {t('permissionsDescription')}
1294
+ </p>
1295
+ </div>
1296
+ </div>
1297
+ <PermissionsSection
1298
+ userId={editingUser.id!}
1299
+ userName={editingUser.name ?? undefined}
1300
+ onRoleChange={handleRefreshUser}
1301
+ />
1302
+ </div>
1303
+ </TabsContent>
1304
+
1305
+ {/* MFA Tab */}
1306
+ <TabsContent value="mfa" className="space-y-4 mt-4 p-4 pt-0">
1307
+ <div className="space-y-3">
1308
+ <h4 className="text-sm font-medium">{t('mfaTitle')}</h4>
1309
+ {editingUser.user_mfa && editingUser.user_mfa.length > 0 ? (
1310
+ <div className="space-y-2">
1311
+ {editingUser.user_mfa.map((mfa: UserMfa) => (
1312
+ <div
1313
+ key={String(mfa.id)}
1314
+ className="flex items-center justify-between rounded-lg border p-3"
1315
+ >
1316
+ <div className="flex items-center gap-3">
1317
+ <div className="rounded-md bg-amber-50 p-2">
1318
+ <Shield className="h-5 w-5 text-amber-600" />
1319
+ </div>
1320
+ <div>
1321
+ <p className="text-sm font-medium">{mfa.name}</p>
1322
+ <p className="text-xs text-muted-foreground">
1323
+ {t('mfaEnabled')}
1324
+ </p>
1325
+ </div>
1326
+ </div>
1327
+ </div>
1328
+ ))}
1329
+ </div>
1330
+ ) : (
1331
+ <div className="rounded-lg border border-dashed p-6 text-center">
1332
+ <Shield className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
1333
+ <p className="text-sm font-medium mb-1">
1334
+ {t('noMfaEnabled')}
1335
+ </p>
1336
+ <p className="text-xs text-muted-foreground mb-4">
1337
+ {t('enhanceSecurity')}
1338
+ </p>
1339
+ </div>
1340
+ )}
1341
+ </div>
1342
+ </TabsContent>
1343
+
1344
+ {/* Sessions Tab */}
1345
+ <TabsContent value="sessions" className="space-y-4 mt-4 p-4 pt-0">
1346
+ <ActiveSessions
1347
+ editingUser={editingUser}
1348
+ refetch={handleRefreshUser}
1349
+ />
1350
+ </TabsContent>
1351
+ </Tabs>
1352
+ </SheetContent>
1353
+ </Sheet>
1354
+ );
1355
+ }
1356
+
1357
+ // ─── Public component ─────────────────────────────────────────────────────────
1358
+
1359
+ export function UserSheet({
1360
+ open,
1361
+ onOpenChange,
1362
+ userId,
1363
+ onSuccess,
1364
+ }: UserSheetProps) {
1365
+ if (!open) return null;
1366
+
1367
+ if (userId != null) {
1368
+ return (
1369
+ <UserEditSheet
1370
+ open={open}
1371
+ onOpenChange={onOpenChange}
1372
+ userId={userId}
1373
+ onSuccess={onSuccess}
1374
+ />
1375
+ );
1376
+ }
1377
+
1378
+ return (
1379
+ <UserCreateSheet
1380
+ open={open}
1381
+ onOpenChange={onOpenChange}
1382
+ onSuccess={onSuccess}
1383
+ />
1384
+ );
1385
+ }