@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,379 @@
1
+ 'use client';
2
+
3
+ import {
4
+ AlertDialog,
5
+ AlertDialogAction,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ } from '@/components/ui/alert-dialog';
13
+ import { Button } from '@/components/ui/button';
14
+ import {
15
+ Card,
16
+ CardContent,
17
+ CardDescription,
18
+ CardHeader,
19
+ CardTitle,
20
+ } from '@/components/ui/card';
21
+ import {
22
+ Dialog,
23
+ DialogContent,
24
+ DialogDescription,
25
+ DialogFooter,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ } from '@/components/ui/dialog';
29
+ import {
30
+ Form,
31
+ FormControl,
32
+ FormField,
33
+ FormItem,
34
+ FormLabel,
35
+ FormMessage,
36
+ } from '@/components/ui/form';
37
+ import { Input } from '@/components/ui/input';
38
+ import {
39
+ InputOTP,
40
+ InputOTPGroup,
41
+ InputOTPSeparator,
42
+ InputOTPSlot,
43
+ } from '@/components/ui/input-otp';
44
+ import { Label } from '@/components/ui/label';
45
+ import { UserIdentifier } from '@hed-hog/api-types';
46
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
47
+ import { zodResolver } from '@hookform/resolvers/zod';
48
+ import { CheckCircle2, Mail, ShieldCheck, XCircle } from 'lucide-react';
49
+ import { useTranslations } from 'next-intl';
50
+ import { useCallback, useState } from 'react';
51
+ import { useForm } from 'react-hook-form';
52
+ import { z } from 'zod';
53
+
54
+ export function ChangeEmailForm() {
55
+ const t = useTranslations('core.ChangeEmailForm');
56
+ const { user, request, showToastHandler, getSettingValue } = useApp();
57
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
58
+ const [showPinDialog, setShowPinDialog] = useState(false);
59
+ const [pendingData, setPendingData] = useState<EmailFormValues | null>(null);
60
+ const pinCodeLength = getSettingValue('mfa-email-code-length') || 6;
61
+ const [challengeId, setChallengeId] = useState<number | null>(null);
62
+
63
+ const emailSchema = z.object({
64
+ newEmail: z.string().email(t('errorEmailRequired')),
65
+ password: z.string().min(1, t('errorPasswordRequired')),
66
+ });
67
+
68
+ const pinSchema = z.object({
69
+ pin: z
70
+ .string()
71
+ .length(pinCodeLength, t('errorPinLength', { length: pinCodeLength })),
72
+ });
73
+
74
+ type EmailFormValues = z.infer<typeof emailSchema>;
75
+ type PinFormValues = z.infer<typeof pinSchema>;
76
+
77
+ const {
78
+ data: userIdentifier,
79
+ isLoading,
80
+ refetch,
81
+ } = useQuery({
82
+ queryKey: ['profile', 'email'],
83
+ queryFn: async () => {
84
+ const response = await request<UserIdentifier>({
85
+ url: '/profile/email',
86
+ });
87
+ return response.data;
88
+ },
89
+ });
90
+
91
+ const form = useForm<EmailFormValues>({
92
+ resolver: zodResolver(emailSchema),
93
+ defaultValues: {
94
+ newEmail: '',
95
+ password: '',
96
+ },
97
+ });
98
+
99
+ const pinForm = useForm<PinFormValues>({
100
+ resolver: zodResolver(pinSchema),
101
+ defaultValues: {
102
+ pin: '',
103
+ },
104
+ });
105
+
106
+ // Estado para o código OTP
107
+ const [verificationCode, setVerificationCode] = useState('');
108
+
109
+ const onSubmit = async ({ newEmail, password }: EmailFormValues) => {
110
+ if (newEmail === userIdentifier?.value) {
111
+ showToastHandler('error', t('errorDifferentEmail'));
112
+ return;
113
+ }
114
+
115
+ try {
116
+ const { data } = await request<{ requireEmailVerification: boolean }>({
117
+ url: '/profile/change-email',
118
+ method: 'PUT',
119
+ data: {
120
+ email: newEmail,
121
+ password: password,
122
+ },
123
+ });
124
+
125
+ if (data.requireEmailVerification) {
126
+ setPendingData({ newEmail, password });
127
+ setShowConfirmDialog(true);
128
+ } else {
129
+ setPendingData(null);
130
+ form.reset({ newEmail: '', password: '' });
131
+ await refetch();
132
+ showToastHandler('success', t('successEmailChanged'));
133
+ }
134
+ } catch (error) {}
135
+ };
136
+
137
+ const handleSendVerificationEmail = async () => {
138
+ try {
139
+ const { data } = await request<{ challengeId: number }>({
140
+ url: '/profile/email/verify',
141
+ method: 'POST',
142
+ data: {
143
+ email: pendingData?.newEmail ?? userIdentifier?.value,
144
+ },
145
+ });
146
+
147
+ setChallengeId(data.challengeId);
148
+
149
+ showToastHandler('success', t('successEmailSent'));
150
+
151
+ setShowPinDialog(true);
152
+ } catch (error) {
153
+ showToastHandler('error', t('errorSendFailed'));
154
+ }
155
+ };
156
+
157
+ const onPinSubmit = useCallback(async () => {
158
+ try {
159
+ await request({
160
+ url: '/profile/email/verify/confirm',
161
+ method: 'POST',
162
+ data: { pin: verificationCode, challengeId },
163
+ });
164
+
165
+ form.reset({ newEmail: '', password: '' });
166
+ refetch();
167
+ showToastHandler('success', t('successVerified'));
168
+ setShowPinDialog(false);
169
+ pinForm.reset();
170
+ setVerificationCode('');
171
+ } catch (error) {
172
+ showToastHandler('error', t('errorInvalidPin'));
173
+ }
174
+ }, [challengeId, pinForm, verificationCode]);
175
+
176
+ return (
177
+ <>
178
+ <Card>
179
+ <CardHeader className="pb-4">
180
+ <CardTitle>{t('title')}</CardTitle>
181
+ <CardDescription>{t('description')}</CardDescription>
182
+ </CardHeader>
183
+ <CardContent className="px-6 pb-6">
184
+ <div className="mb-6 flex items-center justify-between rounded-lg border bg-muted/50 p-4">
185
+ {isLoading ? (
186
+ <div className="flex w-full items-center justify-between">
187
+ <div className="flex items-center gap-3">
188
+ <div className="bg-muted-foreground/10 rounded-full h-5 w-5 animate-pulse" />
189
+ <div>
190
+ <div className="h-4 w-24 bg-muted-foreground/10 rounded animate-pulse mb-1" />
191
+ <div className="h-4 w-40 bg-muted-foreground/10 rounded animate-pulse" />
192
+ </div>
193
+ </div>
194
+ <div className="h-5 w-20 bg-muted-foreground/10 rounded animate-pulse" />
195
+ </div>
196
+ ) : (
197
+ <>
198
+ <div className="flex items-center gap-3">
199
+ <Mail className="size-5 text-muted-foreground" />
200
+ <div>
201
+ <p className="text-sm font-medium">
202
+ {t('currentEmailLabel')}
203
+ </p>
204
+ <p className="text-sm text-muted-foreground">
205
+ {userIdentifier?.value}
206
+ </p>
207
+ </div>
208
+ </div>
209
+ <div className="flex items-center gap-2">
210
+ {userIdentifier?.verified_at ? (
211
+ <div className="flex items-center gap-1 text-green-600">
212
+ <CheckCircle2 className="size-5" />
213
+ <span className="text-sm font-medium">
214
+ {t('verified')}
215
+ </span>
216
+ </div>
217
+ ) : (
218
+ <>
219
+ <div className="flex items-center gap-1 text-amber-600">
220
+ <XCircle className="size-5" />
221
+ <span className="text-sm font-medium">
222
+ {t('notVerified')}
223
+ </span>
224
+ </div>
225
+ <Button
226
+ size="sm"
227
+ variant="outline"
228
+ onClick={handleSendVerificationEmail}
229
+ >
230
+ <ShieldCheck className="mr-2 size-4" />
231
+ {t('verifyButton')}
232
+ </Button>
233
+ </>
234
+ )}
235
+ </div>
236
+ </>
237
+ )}
238
+ </div>
239
+
240
+ <Form {...form}>
241
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
242
+ <FormField
243
+ control={form.control}
244
+ name="newEmail"
245
+ render={({ field }) => (
246
+ <FormItem>
247
+ <FormLabel>{t('newEmailLabel')}</FormLabel>
248
+ <FormControl>
249
+ <Input
250
+ type="email"
251
+ placeholder={t('newEmailPlaceholder')}
252
+ {...field}
253
+ />
254
+ </FormControl>
255
+ <FormMessage />
256
+ </FormItem>
257
+ )}
258
+ />
259
+ <FormField
260
+ control={form.control}
261
+ name="password"
262
+ render={({ field }) => (
263
+ <FormItem>
264
+ <FormLabel>{t('passwordLabel')}</FormLabel>
265
+ <FormControl>
266
+ <Input
267
+ type="password"
268
+ placeholder={t('passwordPlaceholder')}
269
+ {...field}
270
+ />
271
+ </FormControl>
272
+ <FormMessage />
273
+ </FormItem>
274
+ )}
275
+ />
276
+ <Button type="submit" disabled={form.formState.isSubmitting}>
277
+ <Mail className="mr-2 size-4" />
278
+ {t('changeEmailButton')}
279
+ </Button>
280
+ </form>
281
+ </Form>
282
+ </CardContent>
283
+ </Card>
284
+
285
+ <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
286
+ <AlertDialogContent>
287
+ <AlertDialogHeader>
288
+ <AlertDialogTitle>{t('confirmTitle')}</AlertDialogTitle>
289
+ <AlertDialogDescription>
290
+ {t.rich('confirmDescription', {
291
+ newEmail: String(pendingData?.newEmail),
292
+ strong: (chunks) => <strong>{chunks}</strong>,
293
+ })}
294
+ </AlertDialogDescription>
295
+ </AlertDialogHeader>
296
+ <AlertDialogFooter>
297
+ <AlertDialogCancel>{t('cancelButton')}</AlertDialogCancel>
298
+ <AlertDialogAction onClick={handleSendVerificationEmail}>
299
+ {t('confirmButton')}
300
+ </AlertDialogAction>
301
+ </AlertDialogFooter>
302
+ </AlertDialogContent>
303
+ </AlertDialog>
304
+
305
+ <Dialog open={showPinDialog} onOpenChange={setShowPinDialog}>
306
+ <DialogContent className="sm:max-w-lg">
307
+ <DialogHeader>
308
+ <DialogTitle>{t('verifyDialogTitle')}</DialogTitle>
309
+ <DialogDescription>
310
+ {t('verifyDialogDescription')}
311
+ </DialogDescription>
312
+ </DialogHeader>
313
+ <form
314
+ onSubmit={(e) => {
315
+ e.preventDefault();
316
+ onPinSubmit();
317
+ }}
318
+ className="space-y-4"
319
+ >
320
+ <div className="space-y-2 flex flex-col gap-2 items-center justify-center">
321
+ <Label className="text-sm font-medium">{t('pinCodeLabel')}</Label>
322
+ <InputOTP
323
+ maxLength={pinCodeLength}
324
+ value={verificationCode}
325
+ onChange={setVerificationCode}
326
+ >
327
+ {(() => {
328
+ const groupSize = Math.ceil(pinCodeLength / 2);
329
+ const groups = [
330
+ Array.from({ length: groupSize }, (_, i) => (
331
+ <InputOTPSlot key={i} index={i} />
332
+ )),
333
+ Array.from(
334
+ { length: pinCodeLength - groupSize },
335
+ (_, i) => (
336
+ <InputOTPSlot
337
+ key={i + groupSize}
338
+ index={i + groupSize}
339
+ />
340
+ )
341
+ ),
342
+ ];
343
+ return (
344
+ <>
345
+ <InputOTPGroup>{groups[0]}</InputOTPGroup>
346
+ <InputOTPSeparator />
347
+ <InputOTPGroup>{groups[1]}</InputOTPGroup>
348
+ </>
349
+ );
350
+ })()}
351
+ </InputOTP>
352
+ <Button
353
+ type="button"
354
+ variant="ghost"
355
+ className="mt-2"
356
+ onClick={handleSendVerificationEmail}
357
+ >
358
+ {t('resendEmailButton')}
359
+ </Button>
360
+ </div>
361
+ <DialogFooter>
362
+ <Button
363
+ type="button"
364
+ variant="outline"
365
+ onClick={() => setShowPinDialog(false)}
366
+ >
367
+ {t('cancelButton')}
368
+ </Button>
369
+ <Button type="submit" disabled={verificationCode.length !== 6}>
370
+ <ShieldCheck className="mr-2 size-4" />
371
+ {t('verifySubmitButton')}
372
+ </Button>
373
+ </DialogFooter>
374
+ </form>
375
+ </DialogContent>
376
+ </Dialog>
377
+ </>
378
+ );
379
+ }
@@ -0,0 +1,184 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ } from '@/components/ui/card';
11
+ import {
12
+ Form,
13
+ FormControl,
14
+ FormField,
15
+ FormItem,
16
+ FormLabel,
17
+ FormMessage,
18
+ } from '@/components/ui/form';
19
+ import { Input } from '@/components/ui/input';
20
+ import { useApp } from '@hed-hog/next-app-provider';
21
+ import { zodResolver } from '@hookform/resolvers/zod';
22
+ import { Lock } from 'lucide-react';
23
+ import { useTranslations } from 'next-intl';
24
+ import { useForm } from 'react-hook-form';
25
+ import { z } from 'zod';
26
+
27
+ export function ChangePasswordForm() {
28
+ const { request, showToastHandler, getSettingValue } = useApp();
29
+ const t = useTranslations('core.ChangePasswordForm');
30
+ const minPasswordLength = getSettingValue('password-min-length') || 6;
31
+ const minPasswordSymbols = getSettingValue('password-min-symbols') || 0;
32
+ const minPasswordUppercase = getSettingValue('password-min-uppercase') || 0;
33
+ const minPasswordNumbers = getSettingValue('password-min-numbers') || 0;
34
+
35
+ const passwordSchema = z
36
+ .object({
37
+ currentPassword: z
38
+ .string()
39
+ .min(minPasswordLength, t('currentPasswordRequired')),
40
+ newPassword: z
41
+ .string()
42
+ .min(minPasswordLength, t('newPasswordMinLength'))
43
+ .refine(
44
+ (val) => {
45
+ const symbolCount = val.replace(/[a-zA-Z0-9]/g, '').length;
46
+ return symbolCount >= minPasswordSymbols;
47
+ },
48
+ {
49
+ message: t('newPasswordMinSymbols', {
50
+ minSymbols: minPasswordSymbols,
51
+ }),
52
+ }
53
+ )
54
+ .refine(
55
+ (val) => {
56
+ const uppercaseCount = (val.match(/[A-Z]/g) || []).length;
57
+ return uppercaseCount >= minPasswordUppercase;
58
+ },
59
+ {
60
+ message: t('newPasswordMinUppercase', {
61
+ minUppercase: minPasswordUppercase,
62
+ }),
63
+ }
64
+ )
65
+ .refine(
66
+ (val) => {
67
+ const numberCount = (val.match(/[0-9]/g) || []).length;
68
+ return numberCount >= minPasswordNumbers;
69
+ },
70
+ {
71
+ message: t('newPasswordMinNumbers', {
72
+ minNumbers: minPasswordNumbers,
73
+ }),
74
+ }
75
+ ),
76
+ confirmPassword: z
77
+ .string()
78
+ .min(minPasswordLength, t('confirmPasswordRequired')),
79
+ })
80
+ .refine((data) => data.newPassword === data.confirmPassword, {
81
+ message: t('passwordsDontMatch'),
82
+ path: ['confirmPassword'],
83
+ });
84
+
85
+ type PasswordFormValues = z.infer<typeof passwordSchema>;
86
+
87
+ const form = useForm<PasswordFormValues>({
88
+ resolver: zodResolver(passwordSchema),
89
+ defaultValues: {
90
+ currentPassword: '',
91
+ newPassword: '',
92
+ confirmPassword: '',
93
+ },
94
+ });
95
+
96
+ const onSubmit = async (data: PasswordFormValues) => {
97
+ try {
98
+ await request({
99
+ url: '/profile/change-password',
100
+ method: 'PUT',
101
+ data: {
102
+ currentPassword: data.currentPassword,
103
+ newPassword: data.newPassword,
104
+ },
105
+ });
106
+
107
+ showToastHandler('success', t('updateSuccess'));
108
+
109
+ form.reset();
110
+ } catch (error) {
111
+ showToastHandler('error', t('updateFailure'));
112
+ }
113
+ };
114
+
115
+ return (
116
+ <Card>
117
+ <CardHeader className="pb-4">
118
+ <CardTitle>{t('title')}</CardTitle>
119
+ <CardDescription>{t('description')}</CardDescription>
120
+ </CardHeader>
121
+ <CardContent className="px-6 pb-6">
122
+ <Form {...form}>
123
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
124
+ <FormField
125
+ control={form.control}
126
+ name="currentPassword"
127
+ render={({ field }) => (
128
+ <FormItem>
129
+ <FormLabel>{t('currentPasswordLabel')}</FormLabel>
130
+ <FormControl>
131
+ <Input
132
+ type="password"
133
+ placeholder={t('currentPasswordPlaceholder')}
134
+ {...field}
135
+ />
136
+ </FormControl>
137
+ <FormMessage />
138
+ </FormItem>
139
+ )}
140
+ />
141
+ <FormField
142
+ control={form.control}
143
+ name="newPassword"
144
+ render={({ field }) => (
145
+ <FormItem>
146
+ <FormLabel>{t('newPasswordLabel')}</FormLabel>
147
+ <FormControl>
148
+ <Input
149
+ type="password"
150
+ placeholder={t('newPasswordPlaceholder')}
151
+ {...field}
152
+ />
153
+ </FormControl>
154
+ <FormMessage />
155
+ </FormItem>
156
+ )}
157
+ />
158
+ <FormField
159
+ control={form.control}
160
+ name="confirmPassword"
161
+ render={({ field }) => (
162
+ <FormItem>
163
+ <FormLabel>{t('confirmPasswordLabel')}</FormLabel>
164
+ <FormControl>
165
+ <Input
166
+ type="password"
167
+ placeholder={t('confirmPasswordPlaceholder')}
168
+ {...field}
169
+ />
170
+ </FormControl>
171
+ <FormMessage />
172
+ </FormItem>
173
+ )}
174
+ />
175
+ <Button type="submit" disabled={form.formState.isSubmitting}>
176
+ <Lock className="mr-2 size-4" />
177
+ {t('changePasswordButton')}
178
+ </Button>
179
+ </form>
180
+ </Form>
181
+ </CardContent>
182
+ </Card>
183
+ );
184
+ }
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import { Badge } from '@/components/ui/badge';
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 { useApp } from '@hed-hog/next-app-provider';
13
+ import { Link as LinkIcon, Unlink } from 'lucide-react';
14
+ import { useTranslations } from 'next-intl';
15
+ import Link from 'next/link';
16
+ import FacebookIcon from '../../../../../public/facebook.svg';
17
+ import GithubIcon from '../../../../../public/github.svg';
18
+ import GoogleIcon from '../../../../../public/google.svg';
19
+ import MicrosoftIcon from '../../../../../public/microsoft.svg';
20
+
21
+ export function ConnectedAccounts() {
22
+ const { getSettingValue, request, setAccessToken, showToastHandler, user } =
23
+ useApp();
24
+ const t = useTranslations('core.ConnectedAccounts');
25
+ const availableProviders = getSettingValue('providers') || '[]';
26
+
27
+ const providerIcons: Record<string, React.ComponentType<any>> = {
28
+ google: GoogleIcon,
29
+ facebook: FacebookIcon,
30
+ github: GithubIcon,
31
+ microsoft: MicrosoftIcon,
32
+ };
33
+
34
+ const providers = Array.isArray(availableProviders)
35
+ ? availableProviders.map((p: any) => {
36
+ if (typeof p === 'string') {
37
+ return {
38
+ id: p,
39
+ name: p.charAt(0).toUpperCase() + p.slice(1),
40
+ icon: providerIcons[p] ?? (() => null),
41
+ };
42
+ }
43
+ return {
44
+ ...p,
45
+ icon: providerIcons[p.id] ?? (() => null),
46
+ };
47
+ })
48
+ : [];
49
+
50
+ const handleDisconnect = async (email: string, providerName: string) => {
51
+ try {
52
+ const { data }: any = await request({
53
+ url: `/oauth/${providerName.toLowerCase()}`,
54
+ method: 'DELETE',
55
+ data: { email },
56
+ });
57
+
58
+ if (data.accessToken) {
59
+ setAccessToken(data.accessToken);
60
+ }
61
+ showToastHandler('success', t('disconnectSuccess'));
62
+ } catch (error) {
63
+ showToastHandler('error', t('disconnectFailure'));
64
+ throw error;
65
+ }
66
+ };
67
+
68
+ const getConnectionInfo = (providerName: string) => {
69
+ const accounts = (user?.user_account ?? []).filter(
70
+ (ua) => ua.provider === providerName.toLowerCase()
71
+ );
72
+
73
+ return {
74
+ isConnected: accounts.length > 0,
75
+ account: accounts[0],
76
+ accountCount: accounts.length,
77
+ };
78
+ };
79
+
80
+ return (
81
+ <Card>
82
+ <CardHeader className="pb-4">
83
+ <CardTitle>{t('title')}</CardTitle>
84
+ <CardDescription>{t('description')}</CardDescription>
85
+ </CardHeader>
86
+ <CardContent className="space-y-4 px-6 pb-6">
87
+ {providers?.map((provider: any) => {
88
+ const { isConnected, account } = getConnectionInfo(provider.name);
89
+
90
+ return (
91
+ <div
92
+ key={provider.id}
93
+ className="flex items-center justify-between rounded-lg border p-4"
94
+ >
95
+ <div className="flex items-center gap-4">
96
+ <div className="flex size-10 items-center justify-center rounded-lg bg-muted">
97
+ <img className="size-5" src={provider.icon.src} />
98
+ </div>
99
+ <div>
100
+ <div className="flex items-center gap-2">
101
+ <p className="font-medium">{provider.name}</p>
102
+ {Boolean(isConnected) && (
103
+ <Badge variant="default" className="text-xs">
104
+ {t('connected')}
105
+ </Badge>
106
+ )}
107
+ </div>
108
+ {Boolean(isConnected) && (
109
+ <p className="text-sm text-muted-foreground">
110
+ {account?.email || t('connected')}
111
+ </p>
112
+ )}
113
+ </div>
114
+ </div>
115
+ <div>
116
+ {Boolean(isConnected) ? (
117
+ <Button
118
+ variant="outline"
119
+ size="sm"
120
+ onClick={() =>
121
+ handleDisconnect(String(account?.email), provider.name)
122
+ }
123
+ >
124
+ <Unlink className="mr-2 size-4" />
125
+ {t('disconnect')}
126
+ </Button>
127
+ ) : (
128
+ <Link
129
+ href={`${process.env.NEXT_PUBLIC_API_BASE_URL}/oauth/${provider.name.toLowerCase()}/connect`}
130
+ >
131
+ <Button type="button" variant="outline" size="sm">
132
+ <LinkIcon className="mr-2 size-4" />
133
+ {t('connect')}
134
+ </Button>
135
+ </Link>
136
+ )}
137
+ </div>
138
+ </div>
139
+ );
140
+ })}
141
+ </CardContent>
142
+ </Card>
143
+ );
144
+ }