@hed-hog/core 0.0.185 → 0.0.190

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 (55) hide show
  1. package/hedhog/frontend/app/account/2fa/page.tsx.ejs +5 -0
  2. package/hedhog/frontend/app/account/accounts/page.tsx.ejs +5 -0
  3. package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +356 -0
  4. package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +379 -0
  5. package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +184 -0
  6. package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +144 -0
  7. package/hedhog/frontend/app/account/components/email-request-dialog.tsx.ejs +96 -0
  8. package/hedhog/frontend/app/account/components/mfa-add-buttons.tsx.ejs +43 -0
  9. package/hedhog/frontend/app/account/components/mfa-method-card.tsx.ejs +115 -0
  10. package/hedhog/frontend/app/account/components/mfa-setup-dialog.tsx.ejs +236 -0
  11. package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +209 -0
  12. package/hedhog/frontend/app/account/components/recovery-codes-dialog.tsx.ejs +192 -0
  13. package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +372 -0
  14. package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +337 -0
  15. package/hedhog/frontend/app/account/components/two-factor-auth.tsx.ejs +393 -0
  16. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +332 -0
  17. package/hedhog/frontend/app/account/email/page.tsx.ejs +5 -0
  18. package/hedhog/frontend/app/account/hooks/use-mfa-methods.ts.ejs +27 -0
  19. package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +461 -0
  20. package/hedhog/frontend/app/account/layout.tsx.ejs +105 -0
  21. package/hedhog/frontend/app/account/lib/mfa-utils.tsx.ejs +37 -0
  22. package/hedhog/frontend/app/account/page.tsx.ejs +5 -0
  23. package/hedhog/frontend/app/account/password/page.tsx.ejs +5 -0
  24. package/hedhog/frontend/app/account/profile/page.tsx.ejs +5 -0
  25. package/hedhog/frontend/app/account/sessions/page.tsx.ejs +5 -0
  26. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +490 -0
  27. package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +62 -0
  28. package/hedhog/frontend/app/configurations/layout.tsx.ejs +316 -0
  29. package/hedhog/frontend/app/configurations/page.tsx.ejs +35 -0
  30. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +351 -0
  31. package/hedhog/frontend/app/dashboard/[slug]/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +62 -0
  33. package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +45 -0
  34. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +196 -0
  35. package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +63 -0
  36. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +516 -0
  37. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +753 -0
  38. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +516 -0
  39. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +489 -0
  40. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +621 -0
  41. package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -0
  42. package/hedhog/frontend/app/mail/log/page.tsx.ejs +312 -0
  43. package/hedhog/frontend/app/mail/template/page.tsx.ejs +1177 -0
  44. package/hedhog/frontend/app/preferences/page.tsx.ejs +448 -0
  45. package/hedhog/frontend/app/roles/menus.tsx.ejs +504 -0
  46. package/hedhog/frontend/app/roles/page.tsx.ejs +814 -0
  47. package/hedhog/frontend/app/roles/routes.tsx.ejs +397 -0
  48. package/hedhog/frontend/app/roles/users.tsx.ejs +306 -0
  49. package/hedhog/frontend/app/users/active-session.tsx.ejs +159 -0
  50. package/hedhog/frontend/app/users/identifiers.tsx.ejs +279 -0
  51. package/hedhog/frontend/app/users/page.tsx.ejs +1257 -0
  52. package/hedhog/frontend/app/users/permissions.tsx.ejs +155 -0
  53. package/hedhog/frontend/messages/en.json +1080 -0
  54. package/hedhog/frontend/messages/pt.json +1135 -0
  55. package/package.json +4 -4
@@ -0,0 +1,96 @@
1
+ import { Button } from '@/components/ui/button';
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from '@/components/ui/dialog';
10
+ import { Input } from '@/components/ui/input';
11
+ import { Label } from '@/components/ui/label';
12
+ import { Mail } from 'lucide-react';
13
+ import { useTranslations } from 'next-intl';
14
+ import { useState } from 'react';
15
+
16
+ interface EmailRequestDialogProps {
17
+ open: boolean;
18
+ onOpenChange: (open: boolean) => void;
19
+ onSubmit: (email: string) => Promise<void>;
20
+ }
21
+
22
+ export function EmailRequestDialog({
23
+ open,
24
+ onOpenChange,
25
+ onSubmit,
26
+ }: EmailRequestDialogProps) {
27
+ const t = useTranslations('core.EmailRequestDialog');
28
+ const [email, setEmail] = useState('');
29
+ const [loading, setLoading] = useState(false);
30
+
31
+ const handleSubmit = async () => {
32
+ if (!email.trim() || !email.includes('@')) {
33
+ return;
34
+ }
35
+
36
+ setLoading(true);
37
+ try {
38
+ await onSubmit(email);
39
+ setEmail('');
40
+ } catch (error) {
41
+ console.error('Failed to send code:', error);
42
+ } finally {
43
+ setLoading(false);
44
+ }
45
+ };
46
+
47
+ const handleOpenChange = (open: boolean) => {
48
+ if (!open) {
49
+ setEmail('');
50
+ }
51
+ onOpenChange(open);
52
+ };
53
+
54
+ return (
55
+ <Dialog open={open} onOpenChange={handleOpenChange}>
56
+ <DialogContent className="sm:max-w-md">
57
+ <DialogHeader>
58
+ <DialogTitle className="flex items-center gap-2">
59
+ <Mail className="w-5 h-5" />
60
+ {t('title')}
61
+ </DialogTitle>
62
+ <DialogDescription>{t('description')}</DialogDescription>
63
+ </DialogHeader>
64
+ <div className="space-y-4 py-4">
65
+ <div className="space-y-2">
66
+ <Label htmlFor="email-address">{t('emailLabel')}</Label>
67
+ <Input
68
+ id="email-address"
69
+ type="email"
70
+ placeholder={t('emailPlaceholder')}
71
+ value={email}
72
+ onChange={(e) => setEmail(e.target.value)}
73
+ onKeyDown={(e) => {
74
+ if (e.key === 'Enter') {
75
+ handleSubmit();
76
+ }
77
+ }}
78
+ autoFocus
79
+ />
80
+ </div>
81
+ </div>
82
+ <DialogFooter>
83
+ <Button variant="outline" onClick={() => handleOpenChange(false)}>
84
+ {t('cancelButton')}
85
+ </Button>
86
+ <Button
87
+ onClick={handleSubmit}
88
+ disabled={!email.trim() || !email.includes('@') || loading}
89
+ >
90
+ {loading ? t('sending') : t('sendButton')}
91
+ </Button>
92
+ </DialogFooter>
93
+ </DialogContent>
94
+ </Dialog>
95
+ );
96
+ }
@@ -0,0 +1,43 @@
1
+ import { Button } from '@/components/ui/button';
2
+ import { UserMfaTypeEnum } from '@hed-hog/api-types';
3
+ import { Plus } from 'lucide-react';
4
+ import { useTranslations } from 'next-intl';
5
+
6
+ interface MfaAddButtonsProps {
7
+ onAddMethod: (type: UserMfaTypeEnum) => void;
8
+ }
9
+
10
+ export function MfaAddButtons({ onAddMethod }: MfaAddButtonsProps) {
11
+ const t = useTranslations('core.MfaAddButtons');
12
+
13
+ return (
14
+ <div className="grid grid-cols-3 gap-2 pt-4">
15
+ <Button
16
+ variant="outline"
17
+ onClick={() => onAddMethod(UserMfaTypeEnum.EMAIL)}
18
+ className="justify-start"
19
+ >
20
+ <Plus className="mr-2 size-4" />
21
+ {t('addEmail')}
22
+ </Button>
23
+
24
+ <Button
25
+ variant="outline"
26
+ onClick={() => onAddMethod(UserMfaTypeEnum.TOTP)}
27
+ className="justify-start"
28
+ >
29
+ <Plus className="mr-2 size-4" />
30
+ {t('addAuthenticator')}
31
+ </Button>
32
+
33
+ <Button
34
+ variant="outline"
35
+ onClick={() => onAddMethod(UserMfaTypeEnum.WEBAUTHN)}
36
+ className="justify-start"
37
+ >
38
+ <Plus className="mr-2 size-4" />
39
+ {t('addSecurityKey')}
40
+ </Button>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,115 @@
1
+ import { Badge } from '@/components/ui/badge';
2
+ import { Button } from '@/components/ui/button';
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogHeader,
7
+ DialogTitle,
8
+ } from '@/components/ui/dialog';
9
+ import { Input } from '@/components/ui/input';
10
+ import { Label } from '@/components/ui/label';
11
+ import { UserMfa } from '@hed-hog/api-types';
12
+ import { Edit2, Trash2 } from 'lucide-react';
13
+ import { useTranslations } from 'next-intl';
14
+ import { FormEvent, useState } from 'react';
15
+ import { getMethodIcon } from '../lib/mfa-utils';
16
+
17
+ interface MfaMethodCardProps {
18
+ method: UserMfa;
19
+ onUpdate: (methodId: number, name: string) => void;
20
+ onRemove: (method: any) => void;
21
+ refetch: () => void;
22
+ }
23
+
24
+ export function MfaMethodCard({
25
+ method,
26
+ onRemove,
27
+ onUpdate,
28
+ refetch,
29
+ }: MfaMethodCardProps) {
30
+ const t = useTranslations('core.TwoFactorAuth');
31
+ const [isEditOpen, setIsEditOpen] = useState(false);
32
+ const [editName, setEditName] = useState<string>((method as any).name ?? '');
33
+
34
+ const handleSubmitUpdate = async (e: FormEvent<HTMLFormElement>) => {
35
+ e.preventDefault();
36
+ const name = editName.trim();
37
+ if (!name) return;
38
+
39
+ const id = Number(method.id);
40
+ await Promise.resolve(onUpdate(id, name));
41
+ setIsEditOpen(false);
42
+ refetch();
43
+ };
44
+
45
+ return (
46
+ <div className="flex items-center justify-between rounded-lg border p-4">
47
+ <div className="flex items-center gap-4">
48
+ {getMethodIcon(method.type)}
49
+ <div>
50
+ <div className="flex items-center gap-2">
51
+ <p className="font-medium">{method.name}</p>
52
+ {method.verified_at ? (
53
+ <Badge variant="default" className="text-xs">
54
+ {t('verified')}
55
+ </Badge>
56
+ ) : (
57
+ <Badge variant="outline" className="text-xs">
58
+ {t('notVerified')}
59
+ </Badge>
60
+ )}
61
+ </div>
62
+ {method.type === 'email' &&
63
+ (method as any).user_mfa_email?.[0]?.email && (
64
+ <p className="text-sm text-muted-foreground mt-1">
65
+ {(method as any).user_mfa_email[0].email}
66
+ </p>
67
+ )}
68
+ </div>
69
+ </div>
70
+ <div className="flex items-center">
71
+ <Button
72
+ variant="ghost"
73
+ size="icon"
74
+ onClick={() => {
75
+ setEditName(method.name);
76
+ setIsEditOpen(true);
77
+ }}
78
+ >
79
+ <Edit2 className="w-4 h-4" />
80
+ </Button>
81
+
82
+ <Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
83
+ <DialogContent className="sm:max-w-md">
84
+ <DialogHeader>
85
+ <DialogTitle>{t('editMfaName')}</DialogTitle>
86
+ </DialogHeader>
87
+
88
+ <form onSubmit={handleSubmitUpdate} className="mt-2 flex flex-col">
89
+ <Label className="mb-2 block text-sm font-medium">
90
+ {t('nameLabel')}
91
+ </Label>
92
+ <Input
93
+ value={editName}
94
+ onChange={(e) => setEditName(e.target.value)}
95
+ className="mb-4 w-full rounded-md border px-3 py-2 text-sm"
96
+ required
97
+ aria-label={t('mfaNameAriaLabel')}
98
+ />
99
+
100
+ <div className="flex flex-row gap-2 justify-end">
101
+ <Button type="button" variant="outline">
102
+ {t('cancel')}
103
+ </Button>
104
+ <Button type="submit">{t('save')}</Button>
105
+ </div>
106
+ </form>
107
+ </DialogContent>
108
+ </Dialog>
109
+ <Button variant="ghost" size="icon" onClick={() => onRemove(method)}>
110
+ <Trash2 className="size-4 text-destructive" />
111
+ </Button>
112
+ </div>
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,236 @@
1
+ import { Button } from '@/components/ui/button';
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from '@/components/ui/dialog';
10
+ import { Input } from '@/components/ui/input';
11
+ import {
12
+ InputOTP,
13
+ InputOTPGroup,
14
+ InputOTPSeparator,
15
+ InputOTPSlot,
16
+ } from '@/components/ui/input-otp';
17
+ import { Label } from '@/components/ui/label';
18
+ import { UserMfaTypeEnum } from '@hed-hog/api-types/UserMfaTypeEnum';
19
+ import { useApp } from '@hed-hog/next-app-provider';
20
+ import { useTranslations } from 'next-intl';
21
+ import { getMethodName } from '../lib/mfa-utils';
22
+
23
+ interface MfaSetupDialogProps {
24
+ open: boolean;
25
+ selectedMethod: UserMfaTypeEnum | null;
26
+ qrCode: string;
27
+ verificationCode: string;
28
+ name: string;
29
+ onNameChange: (name: string) => void;
30
+ onOpenChange: (open: boolean) => void;
31
+ onVerificationCodeChange: (code: string) => void;
32
+ onVerify: () => void;
33
+ isRemoval?: boolean;
34
+ useRecoveryCode?: boolean;
35
+ onToggleRecoveryCode?: () => void;
36
+ resendLoading?: boolean;
37
+ resendCooldown?: number;
38
+ onResendCode?: () => void;
39
+ }
40
+
41
+ export function MfaSetupDialog({
42
+ open,
43
+ selectedMethod,
44
+ qrCode,
45
+ verificationCode,
46
+ name,
47
+ onNameChange,
48
+ onOpenChange,
49
+ onVerificationCodeChange,
50
+ onVerify,
51
+ isRemoval = false,
52
+ useRecoveryCode = false,
53
+ onToggleRecoveryCode,
54
+ resendLoading = false,
55
+ resendCooldown = 0,
56
+ onResendCode,
57
+ }: MfaSetupDialogProps) {
58
+ const t = useTranslations('core.MfaSetupDialog');
59
+ const { getSettingValue } = useApp();
60
+ const pinCodeLength = getSettingValue('mfa-email-code-length') || 6;
61
+ const methodName = getMethodName(String(selectedMethod));
62
+
63
+ return (
64
+ <Dialog open={open} onOpenChange={onOpenChange}>
65
+ <DialogContent className="sm:max-w-lg">
66
+ <DialogHeader>
67
+ <DialogTitle>
68
+ {isRemoval ? t('titleRemove') : t('titleSetup', { methodName })}
69
+ </DialogTitle>
70
+ <DialogDescription>
71
+ {isRemoval ? t('descriptionRemove') : t('descriptionSetup')}
72
+ </DialogDescription>
73
+ </DialogHeader>
74
+ <div className="space-y-4 py-4">
75
+ {selectedMethod === UserMfaTypeEnum.TOTP && !isRemoval && (
76
+ <div className="space-y-2">
77
+ <p className="text-sm">{t('scanQrCode')}</p>
78
+ <div className="bg-muted p-4 rounded-lg flex justify-center">
79
+ <img src={qrCode} alt="QR Code" />
80
+ </div>
81
+ </div>
82
+ )}
83
+ <div className="space-y-4">
84
+ {!isRemoval && selectedMethod !== UserMfaTypeEnum.WEBAUTHN && (
85
+ <div className="space-y-2">
86
+ <Label htmlFor="method-name">{t('methodNameLabel')}</Label>
87
+ <Input
88
+ id="method-name"
89
+ placeholder={t('methodNamePlaceholder', {
90
+ methodLabel: methodName,
91
+ example:
92
+ selectedMethod === UserMfaTypeEnum.EMAIL
93
+ ? 'Hotmail'
94
+ : 'Main',
95
+ })}
96
+ value={name}
97
+ onChange={(e) => onNameChange(e.target.value)}
98
+ autoFocus
99
+ />
100
+ </div>
101
+ )}
102
+
103
+ {!isRemoval && selectedMethod === UserMfaTypeEnum.WEBAUTHN && (
104
+ <>
105
+ <div className="space-y-2">
106
+ <Label htmlFor="method-name">
107
+ {t('securityKeyNameLabel')}
108
+ </Label>
109
+ <Input
110
+ id="method-name"
111
+ placeholder={t('securityKeyNamePlaceholder')}
112
+ value={name}
113
+ onChange={(e) => onNameChange(e.target.value)}
114
+ autoFocus
115
+ />
116
+ </div>
117
+ <div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950">
118
+ <p className="text-sm text-blue-900 dark:text-blue-100">
119
+ {t('securityKeyInstructions')}
120
+ </p>
121
+ </div>
122
+ </>
123
+ )}
124
+
125
+ {selectedMethod !== UserMfaTypeEnum.WEBAUTHN && (
126
+ <div className="space-y-2 mt-8 flex flex-col items-center justify-center">
127
+ <Label htmlFor="verification-code">
128
+ {useRecoveryCode
129
+ ? t('recoveryCodeLabel')
130
+ : t('verificationCodeLabel')}
131
+ </Label>
132
+ {useRecoveryCode ? (
133
+ <Input
134
+ id="verification-code"
135
+ placeholder={t('recoveryCodePlaceholder')}
136
+ value={verificationCode}
137
+ onChange={(e) => onVerificationCodeChange(e.target.value)}
138
+ className="text-center font-mono"
139
+ autoFocus
140
+ />
141
+ ) : (
142
+ <InputOTP
143
+ maxLength={
144
+ selectedMethod === UserMfaTypeEnum.TOTP
145
+ ? 6
146
+ : pinCodeLength
147
+ }
148
+ value={verificationCode}
149
+ onChange={onVerificationCodeChange}
150
+ >
151
+ {(() => {
152
+ const length =
153
+ selectedMethod === UserMfaTypeEnum.TOTP
154
+ ? 6
155
+ : pinCodeLength;
156
+ const groupSize = Math.ceil(length / 2);
157
+ const groups = [
158
+ Array.from({ length: groupSize }, (_, i) => (
159
+ <InputOTPSlot key={i} index={i} />
160
+ )),
161
+ Array.from({ length: length - groupSize }, (_, i) => (
162
+ <InputOTPSlot
163
+ key={i + groupSize}
164
+ index={i + groupSize}
165
+ />
166
+ )),
167
+ ];
168
+ return (
169
+ <>
170
+ <InputOTPGroup>{groups[0]}</InputOTPGroup>
171
+ <InputOTPSeparator />
172
+ <InputOTPGroup>{groups[1]}</InputOTPGroup>
173
+ </>
174
+ );
175
+ })()}
176
+ </InputOTP>
177
+ )}
178
+ {!isRemoval &&
179
+ selectedMethod === UserMfaTypeEnum.EMAIL &&
180
+ onResendCode && (
181
+ <Button
182
+ type="button"
183
+ variant="outline"
184
+ size="sm"
185
+ onClick={onResendCode}
186
+ disabled={resendLoading || resendCooldown > 0}
187
+ className="mt-2"
188
+ >
189
+ {resendLoading
190
+ ? t('resending')
191
+ : resendCooldown > 0
192
+ ? t('resendIn', { seconds: resendCooldown })
193
+ : t('resendCode')}
194
+ </Button>
195
+ )}
196
+ {isRemoval && onToggleRecoveryCode && (
197
+ <Button
198
+ type="button"
199
+ variant="link"
200
+ size="sm"
201
+ onClick={onToggleRecoveryCode}
202
+ className="text-xs"
203
+ >
204
+ {useRecoveryCode
205
+ ? 'Use verification code'
206
+ : 'Use recovery code instead'}
207
+ </Button>
208
+ )}
209
+ </div>
210
+ )}
211
+ </div>
212
+ </div>
213
+ <DialogFooter>
214
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
215
+ {t('cancelButton')}
216
+ </Button>
217
+ <Button
218
+ onClick={onVerify}
219
+ variant={isRemoval ? 'destructive' : 'default'}
220
+ disabled={
221
+ !isRemoval && selectedMethod === UserMfaTypeEnum.WEBAUTHN && !name
222
+ }
223
+ >
224
+ {isRemoval && selectedMethod === UserMfaTypeEnum.WEBAUTHN
225
+ ? t('registerButton')
226
+ : isRemoval
227
+ ? t('verifyRemoveButton')
228
+ : selectedMethod === UserMfaTypeEnum.WEBAUTHN
229
+ ? t('registerButton')
230
+ : t('verifyButton')}
231
+ </Button>
232
+ </DialogFooter>
233
+ </DialogContent>
234
+ </Dialog>
235
+ );
236
+ }
@@ -0,0 +1,209 @@
1
+ 'use client';
2
+
3
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
+ import { Button } from '@/components/ui/button';
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from '@/components/ui/card';
12
+ import {
13
+ Form,
14
+ FormControl,
15
+ FormField,
16
+ FormItem,
17
+ FormLabel,
18
+ FormMessage,
19
+ } from '@/components/ui/form';
20
+ import { Input } from '@/components/ui/input';
21
+ import { getPhotoUrl } from '@/lib/get-photo-url';
22
+ import { User as UserType } from '@hed-hog/api-types';
23
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
24
+ import { zodResolver } from '@hookform/resolvers/zod';
25
+ import { Loader, Upload, User } from 'lucide-react';
26
+ import { useTranslations } from 'next-intl';
27
+ import { useEffect, useRef, useState } from 'react';
28
+ import { useForm } from 'react-hook-form';
29
+ import { z } from 'zod';
30
+
31
+ export function ProfileForm() {
32
+ const { user, request, showToastHandler, refetchUser } = useApp();
33
+ const [uploading, setUploading] = useState(false);
34
+ const fileInputRef = useRef<HTMLInputElement>(null);
35
+ const t = useTranslations('core.ProfileForm');
36
+
37
+ const profileSchema = z.object({
38
+ name: z.string().min(2, t('errorName')),
39
+ });
40
+
41
+ type ProfileFormValues = z.infer<typeof profileSchema>;
42
+
43
+ const {
44
+ data: profile,
45
+ isLoading,
46
+ refetch,
47
+ } = useQuery({
48
+ queryKey: ['profile'],
49
+ queryFn: async () => {
50
+ const response = await request<UserType>({
51
+ url: `/profile`,
52
+ });
53
+ return response.data;
54
+ },
55
+ });
56
+
57
+ const form = useForm<ProfileFormValues>({
58
+ resolver: zodResolver(profileSchema),
59
+ defaultValues: {
60
+ name: profile?.name || '',
61
+ },
62
+ });
63
+
64
+ const onSubmit = async (data: ProfileFormValues) => {
65
+ try {
66
+ await request({
67
+ url: `/profile`,
68
+ method: 'PUT',
69
+ data,
70
+ });
71
+ showToastHandler('success', t('updatedSuccess'));
72
+ } catch (error) {
73
+ showToastHandler('error', t('updateFailure'));
74
+ }
75
+ };
76
+
77
+ const handleAvatarChange = async (
78
+ event: React.ChangeEvent<HTMLInputElement>
79
+ ) => {
80
+ const file = event.target.files?.[0];
81
+ if (!file) return;
82
+
83
+ // Validate file type
84
+ if (!file.type.startsWith('image/')) {
85
+ showToastHandler('error', t('invalidImageType'));
86
+ return;
87
+ }
88
+
89
+ // Validate file size (5MB)
90
+ if (file.size > 5 * 1024 * 1024) {
91
+ showToastHandler('error', t('imageTooLarge'));
92
+ return;
93
+ }
94
+
95
+ setUploading(true);
96
+ try {
97
+ const formData = new FormData();
98
+ formData.append('file', file);
99
+
100
+ await request({
101
+ url: `/profile/avatar`,
102
+ method: 'PUT',
103
+ data: formData,
104
+ headers: {
105
+ 'Content-Type': 'multipart/form-data',
106
+ },
107
+ });
108
+
109
+ showToastHandler('success', t('avatarUpdateSuccess'));
110
+ refetchUser();
111
+ refetch();
112
+ } catch (error) {
113
+ showToastHandler('error', t('avatarUpdateFailure'));
114
+ } finally {
115
+ setUploading(false);
116
+ }
117
+ };
118
+
119
+ const userInitials =
120
+ user?.name
121
+ ?.split(' ')
122
+ .map((n) => n[0])
123
+ .join('')
124
+ .toUpperCase()
125
+ .slice(0, 2) || 'U';
126
+
127
+ useEffect(() => {
128
+ if (profile) {
129
+ form.reset({
130
+ name: profile.name,
131
+ });
132
+ }
133
+ }, [profile, form]);
134
+
135
+ return (
136
+ <Card>
137
+ <CardHeader className="pb-4">
138
+ <CardTitle>{t('title')}</CardTitle>
139
+ <CardDescription>{t('description')}</CardDescription>
140
+ </CardHeader>
141
+ <CardContent className="space-y-6 px-6 pb-6">
142
+ <div className="flex items-center gap-6">
143
+ <div className="relative">
144
+ <Avatar className="size-24">
145
+ <AvatarImage
146
+ src={getPhotoUrl(profile?.photo_id)}
147
+ alt={profile?.name || t('avatarAlt')}
148
+ />
149
+ <AvatarFallback className="text-2xl">
150
+ {userInitials}
151
+ </AvatarFallback>
152
+ </Avatar>
153
+ {uploading && (
154
+ <div className="absolute inset-0 flex items-center justify-center bg-black/40 rounded-full">
155
+ <Loader className="animate-spin h-8 w-8 text-white" />
156
+ </div>
157
+ )}
158
+ </div>
159
+ <div>
160
+ <input
161
+ ref={fileInputRef}
162
+ type="file"
163
+ accept="image/*"
164
+ className="hidden"
165
+ onChange={handleAvatarChange}
166
+ />
167
+ <Button
168
+ type="button"
169
+ variant="outline"
170
+ onClick={() => fileInputRef.current?.click()}
171
+ disabled={uploading}
172
+ >
173
+ <Upload className="mr-2 size-4" />
174
+ {uploading ? t('uploading') : t('changeAvatar')}
175
+ </Button>
176
+ <p className="mt-2 text-xs text-muted-foreground">
177
+ {t('avatarGuidelines')}
178
+ </p>
179
+ </div>
180
+ </div>
181
+
182
+ <Form {...form}>
183
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
184
+ <FormField
185
+ control={form.control}
186
+ name="name"
187
+ render={({ field }) => (
188
+ <FormItem>
189
+ <FormLabel>{t('formNameLabel')}</FormLabel>
190
+ <FormControl>
191
+ <Input placeholder={t('formNamePlaceholder')} {...field} />
192
+ </FormControl>
193
+ <FormMessage />
194
+ </FormItem>
195
+ )}
196
+ />
197
+ <Button
198
+ type="submit"
199
+ disabled={form.formState.isSubmitting || isLoading}
200
+ >
201
+ <User className="mr-2 size-4" />
202
+ {t('saveChanges')}
203
+ </Button>
204
+ </form>
205
+ </Form>
206
+ </CardContent>
207
+ </Card>
208
+ );
209
+ }