@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.
- package/hedhog/frontend/app/account/2fa/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/accounts/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +356 -0
- package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +379 -0
- package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +184 -0
- package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +144 -0
- package/hedhog/frontend/app/account/components/email-request-dialog.tsx.ejs +96 -0
- package/hedhog/frontend/app/account/components/mfa-add-buttons.tsx.ejs +43 -0
- package/hedhog/frontend/app/account/components/mfa-method-card.tsx.ejs +115 -0
- package/hedhog/frontend/app/account/components/mfa-setup-dialog.tsx.ejs +236 -0
- package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +209 -0
- package/hedhog/frontend/app/account/components/recovery-codes-dialog.tsx.ejs +192 -0
- package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +372 -0
- package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +337 -0
- package/hedhog/frontend/app/account/components/two-factor-auth.tsx.ejs +393 -0
- package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +332 -0
- package/hedhog/frontend/app/account/email/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/hooks/use-mfa-methods.ts.ejs +27 -0
- package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +461 -0
- package/hedhog/frontend/app/account/layout.tsx.ejs +105 -0
- package/hedhog/frontend/app/account/lib/mfa-utils.tsx.ejs +37 -0
- package/hedhog/frontend/app/account/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/password/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/profile/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/sessions/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +490 -0
- package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +62 -0
- package/hedhog/frontend/app/configurations/layout.tsx.ejs +316 -0
- package/hedhog/frontend/app/configurations/page.tsx.ejs +35 -0
- package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +351 -0
- package/hedhog/frontend/app/dashboard/[slug]/page.tsx.ejs +11 -0
- package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +62 -0
- package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +45 -0
- package/hedhog/frontend/app/dashboard/dashboard.css.ejs +196 -0
- package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +63 -0
- package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +516 -0
- package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +753 -0
- package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +516 -0
- package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +489 -0
- package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +621 -0
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -0
- package/hedhog/frontend/app/mail/log/page.tsx.ejs +312 -0
- package/hedhog/frontend/app/mail/template/page.tsx.ejs +1177 -0
- package/hedhog/frontend/app/preferences/page.tsx.ejs +448 -0
- package/hedhog/frontend/app/roles/menus.tsx.ejs +504 -0
- package/hedhog/frontend/app/roles/page.tsx.ejs +814 -0
- package/hedhog/frontend/app/roles/routes.tsx.ejs +397 -0
- package/hedhog/frontend/app/roles/users.tsx.ejs +306 -0
- package/hedhog/frontend/app/users/active-session.tsx.ejs +159 -0
- package/hedhog/frontend/app/users/identifiers.tsx.ejs +279 -0
- package/hedhog/frontend/app/users/page.tsx.ejs +1257 -0
- package/hedhog/frontend/app/users/permissions.tsx.ejs +155 -0
- package/hedhog/frontend/messages/en.json +1080 -0
- package/hedhog/frontend/messages/pt.json +1135 -0
- package/package.json +4 -4
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Badge } from '@/components/ui/badge';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogFooter,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
} from '@/components/ui/dialog';
|
|
12
|
+
import { Label } from '@/components/ui/label';
|
|
13
|
+
import { useApp } from '@hed-hog/next-app-provider';
|
|
14
|
+
import { CheckedState } from '@radix-ui/react-checkbox';
|
|
15
|
+
import { Copy, Download, Shield } from 'lucide-react';
|
|
16
|
+
import { useTranslations } from 'next-intl';
|
|
17
|
+
import { useCallback, useState } from 'react';
|
|
18
|
+
import { copyToClipboard } from '../lib/mfa-utils';
|
|
19
|
+
|
|
20
|
+
interface RecoveryCodesDialogProps {
|
|
21
|
+
open: boolean;
|
|
22
|
+
codes: string[];
|
|
23
|
+
onOpenChange: (open: boolean) => void;
|
|
24
|
+
onConfirm: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function RecoveryCodesDialog({
|
|
28
|
+
open,
|
|
29
|
+
codes,
|
|
30
|
+
onOpenChange,
|
|
31
|
+
onConfirm,
|
|
32
|
+
}: RecoveryCodesDialogProps) {
|
|
33
|
+
const t = useTranslations('core.RecoveryCodes');
|
|
34
|
+
const [checkSavedCodes, setCheckSavedCodes] = useState<CheckedState>(false);
|
|
35
|
+
const [showError, setShowError] = useState(false);
|
|
36
|
+
const { showToastHandler } = useApp();
|
|
37
|
+
|
|
38
|
+
const handleCopyCode = async (code: string) => {
|
|
39
|
+
const success = await copyToClipboard(code);
|
|
40
|
+
if (success) {
|
|
41
|
+
showToastHandler('success', t('codeCopied'));
|
|
42
|
+
} else {
|
|
43
|
+
showToastHandler('error', t('codeCopyFailed'));
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const handleCopyAllCodes = async () => {
|
|
48
|
+
const codesText = codes.join('\n');
|
|
49
|
+
const success = await copyToClipboard(codesText);
|
|
50
|
+
if (success) {
|
|
51
|
+
showToastHandler('success', t('allCodesCopied'));
|
|
52
|
+
} else {
|
|
53
|
+
showToastHandler('error', t('allCodesCopyFailed'));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleDownloadCodes = useCallback(() => {
|
|
58
|
+
if (!codes || codes.length === 0) {
|
|
59
|
+
showToastHandler('error', t('noCodesAvailable'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const content = codes.join('\n');
|
|
64
|
+
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
|
65
|
+
const url = URL.createObjectURL(blob);
|
|
66
|
+
const a = document.createElement('a');
|
|
67
|
+
a.href = url;
|
|
68
|
+
a.download = 'recovery-codes.txt';
|
|
69
|
+
document.body.appendChild(a);
|
|
70
|
+
a.click();
|
|
71
|
+
a.remove();
|
|
72
|
+
URL.revokeObjectURL(url);
|
|
73
|
+
showToastHandler('success', t('codesDownloaded'));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
showToastHandler('error', t('downloadFailed'));
|
|
76
|
+
}
|
|
77
|
+
}, [codes, showToastHandler, t]);
|
|
78
|
+
|
|
79
|
+
const handleConfirm = () => {
|
|
80
|
+
if (!checkSavedCodes) {
|
|
81
|
+
setShowError(true);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
onConfirm();
|
|
87
|
+
} finally {
|
|
88
|
+
setShowError(false);
|
|
89
|
+
onOpenChange(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const handleOpenChange = (open: boolean) => {
|
|
94
|
+
if (!open && !checkSavedCodes) {
|
|
95
|
+
setShowError(true);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setShowError(false);
|
|
99
|
+
onOpenChange(open);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
104
|
+
<DialogContent className="max-w-md">
|
|
105
|
+
<DialogHeader>
|
|
106
|
+
<DialogTitle className="flex items-center gap-2">
|
|
107
|
+
<Shield className="size-5 text-primary" />
|
|
108
|
+
{t('title')}
|
|
109
|
+
</DialogTitle>
|
|
110
|
+
<DialogDescription>{t('description')}</DialogDescription>
|
|
111
|
+
</DialogHeader>
|
|
112
|
+
<div className="space-y-4 py-4">
|
|
113
|
+
<div className="rounded-lg border bg-muted/50 p-4">
|
|
114
|
+
<div className="grid grid-cols-2 gap-3">
|
|
115
|
+
{codes.map((code, index) => (
|
|
116
|
+
<div
|
|
117
|
+
key={index}
|
|
118
|
+
className="font-mono text-sm bg-background rounded pl-3 py-2 text-center font-semibold tracking-wider border flex items-center justify-between"
|
|
119
|
+
>
|
|
120
|
+
<span className="break-all">{code}</span>
|
|
121
|
+
<Button
|
|
122
|
+
variant="ghost"
|
|
123
|
+
size="icon"
|
|
124
|
+
onClick={() => handleCopyCode(code)}
|
|
125
|
+
>
|
|
126
|
+
<Copy className="w-4 h-4" />
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div className="bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg p-3">
|
|
133
|
+
<p className="text-xs text-amber-800 dark:text-amber-200 font-medium">
|
|
134
|
+
{t('warningMessage')}
|
|
135
|
+
</p>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-900 rounded-lg p-3">
|
|
138
|
+
<p className="text-xs text-blue-800 dark:text-blue-200 font-medium">
|
|
139
|
+
{t('infoMessage')}
|
|
140
|
+
</p>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<DialogFooter className="flex-col sm:flex-col gap-2">
|
|
144
|
+
<Button
|
|
145
|
+
variant="outline"
|
|
146
|
+
className="w-full"
|
|
147
|
+
onClick={handleCopyAllCodes}
|
|
148
|
+
disabled={!codes.length}
|
|
149
|
+
>
|
|
150
|
+
{t('copyAllCodes')}
|
|
151
|
+
</Button>
|
|
152
|
+
<Button
|
|
153
|
+
variant="outline"
|
|
154
|
+
className="w-full"
|
|
155
|
+
onClick={handleDownloadCodes}
|
|
156
|
+
disabled={!codes.length}
|
|
157
|
+
>
|
|
158
|
+
<Download className="w-4 h-4 mr-2" />
|
|
159
|
+
{t('downloadCodes')}
|
|
160
|
+
</Button>
|
|
161
|
+
<div className="w-full mt-4">
|
|
162
|
+
<Label
|
|
163
|
+
htmlFor="saved-codes-checkbox"
|
|
164
|
+
className="flex items-center gap-2 cursor-pointer select-none"
|
|
165
|
+
>
|
|
166
|
+
<Checkbox
|
|
167
|
+
id="saved-codes-checkbox"
|
|
168
|
+
checked={checkSavedCodes}
|
|
169
|
+
onCheckedChange={(checked) => {
|
|
170
|
+
setCheckSavedCodes(checked);
|
|
171
|
+
if (checked) setShowError(false);
|
|
172
|
+
}}
|
|
173
|
+
className="h-4 w-4 rounded border-muted bg-background"
|
|
174
|
+
/>
|
|
175
|
+
<span className="text-sm font-medium">{t('confirmSaved')}</span>
|
|
176
|
+
</Label>
|
|
177
|
+
|
|
178
|
+
{showError && (
|
|
179
|
+
<Badge className="w-full text-sm my-4 bg-red-400 text-white font-medium">
|
|
180
|
+
{t('errorNotConfirmed')}
|
|
181
|
+
</Badge>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
<Button className="w-full mt-2" onClick={handleConfirm}>
|
|
185
|
+
{t('confirmAndClose')}
|
|
186
|
+
</Button>
|
|
187
|
+
</div>
|
|
188
|
+
</DialogFooter>
|
|
189
|
+
</DialogContent>
|
|
190
|
+
</Dialog>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
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 { useApp } from '@hed-hog/next-app-provider';
|
|
19
|
+
import {
|
|
20
|
+
AlertTriangle,
|
|
21
|
+
Fingerprint,
|
|
22
|
+
Key,
|
|
23
|
+
Mail,
|
|
24
|
+
RefreshCw,
|
|
25
|
+
Shield,
|
|
26
|
+
} from 'lucide-react';
|
|
27
|
+
import { useTranslations } from 'next-intl';
|
|
28
|
+
import { useEffect, useState } from 'react';
|
|
29
|
+
|
|
30
|
+
interface RegenerateCodesDialogProps {
|
|
31
|
+
open: boolean;
|
|
32
|
+
onOpenChange: (open: boolean) => void;
|
|
33
|
+
onConfirm: (
|
|
34
|
+
verificationCode: string,
|
|
35
|
+
hash?: string,
|
|
36
|
+
useTotp?: boolean,
|
|
37
|
+
verificationType?: 'totp' | 'email' | 'recovery' | 'webauthn',
|
|
38
|
+
assertionResponse?: any
|
|
39
|
+
) => void;
|
|
40
|
+
requiresMfa: boolean;
|
|
41
|
+
verificationType?: 'totp' | 'email';
|
|
42
|
+
availableMethods?: ('totp' | 'email')[];
|
|
43
|
+
codeHash?: string;
|
|
44
|
+
hasWebAuthn?: boolean;
|
|
45
|
+
hasRecoveryCodes?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function RegenerateCodesDialog({
|
|
49
|
+
open,
|
|
50
|
+
onOpenChange,
|
|
51
|
+
onConfirm,
|
|
52
|
+
requiresMfa,
|
|
53
|
+
verificationType,
|
|
54
|
+
availableMethods,
|
|
55
|
+
codeHash,
|
|
56
|
+
hasWebAuthn,
|
|
57
|
+
hasRecoveryCodes,
|
|
58
|
+
}: RegenerateCodesDialogProps) {
|
|
59
|
+
const t = useTranslations('core.RegenerateRecoveryCodes');
|
|
60
|
+
const [verificationCode, setVerificationCode] = useState('');
|
|
61
|
+
const [loading, setLoading] = useState(false);
|
|
62
|
+
const [resendLoading, setResendLoading] = useState(false);
|
|
63
|
+
const [resendCooldown, setResendCooldown] = useState(0);
|
|
64
|
+
|
|
65
|
+
const getInitialMethod = (): 'totp' | 'email' | 'recovery' | 'webauthn' => {
|
|
66
|
+
if (verificationType)
|
|
67
|
+
return verificationType as 'totp' | 'email' | 'recovery' | 'webauthn';
|
|
68
|
+
if (availableMethods && availableMethods.length > 0)
|
|
69
|
+
return availableMethods[0] as 'totp' | 'email' | 'recovery' | 'webauthn';
|
|
70
|
+
if (hasWebAuthn) return 'recovery';
|
|
71
|
+
return 'totp';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const [selectedMethod, setSelectedMethod] = useState<
|
|
75
|
+
'totp' | 'email' | 'recovery' | 'webauthn'
|
|
76
|
+
>(getInitialMethod());
|
|
77
|
+
const { getSettingValue, request, showToastHandler } = useApp();
|
|
78
|
+
const pinCodeLength = getSettingValue('mfa-email-code-length') || 6;
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (resendCooldown > 0) {
|
|
82
|
+
const timer = setTimeout(
|
|
83
|
+
() => setResendCooldown(resendCooldown - 1),
|
|
84
|
+
1000
|
|
85
|
+
);
|
|
86
|
+
return () => clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}, [resendCooldown]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (open) {
|
|
92
|
+
setSelectedMethod(getInitialMethod());
|
|
93
|
+
}
|
|
94
|
+
}, [open, verificationType, availableMethods, hasWebAuthn]);
|
|
95
|
+
|
|
96
|
+
const hasBothMethods = availableMethods && availableMethods.length > 0;
|
|
97
|
+
const showMethodToggle = hasBothMethods || hasWebAuthn || hasRecoveryCodes;
|
|
98
|
+
const currentMethod = selectedMethod;
|
|
99
|
+
|
|
100
|
+
const handleResendCode = async () => {
|
|
101
|
+
setResendLoading(true);
|
|
102
|
+
try {
|
|
103
|
+
await request({
|
|
104
|
+
url: '/profile/recovery-codes/send-verification',
|
|
105
|
+
method: 'POST',
|
|
106
|
+
});
|
|
107
|
+
setResendCooldown(30);
|
|
108
|
+
showToastHandler?.('success', t('codeResent'));
|
|
109
|
+
} catch (error) {
|
|
110
|
+
showToastHandler?.('error', t('resendFailed'));
|
|
111
|
+
} finally {
|
|
112
|
+
setResendLoading(false);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleConfirm = async () => {
|
|
117
|
+
if (requiresMfa && !verificationCode) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setLoading(true);
|
|
122
|
+
try {
|
|
123
|
+
let useTotp = false;
|
|
124
|
+
let hashToUse = undefined;
|
|
125
|
+
|
|
126
|
+
if (currentMethod === 'totp') {
|
|
127
|
+
useTotp = true;
|
|
128
|
+
} else if (currentMethod === 'email') {
|
|
129
|
+
hashToUse = codeHash;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
onConfirm(verificationCode, hashToUse, useTotp, currentMethod);
|
|
133
|
+
setVerificationCode('');
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error('Verification failed:', error);
|
|
136
|
+
} finally {
|
|
137
|
+
setLoading(false);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const handleOpenChange = (open: boolean) => {
|
|
142
|
+
if (!open) {
|
|
143
|
+
setVerificationCode('');
|
|
144
|
+
setSelectedMethod(getInitialMethod());
|
|
145
|
+
}
|
|
146
|
+
onOpenChange(open);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
151
|
+
<DialogContent className="sm:max-w-md">
|
|
152
|
+
<DialogHeader>
|
|
153
|
+
<DialogTitle className="flex items-center gap-2">
|
|
154
|
+
<AlertTriangle className="size-5 text-amber-500" />
|
|
155
|
+
{t('title')}
|
|
156
|
+
</DialogTitle>
|
|
157
|
+
<DialogDescription>{t('description')}</DialogDescription>
|
|
158
|
+
</DialogHeader>
|
|
159
|
+
<div className="space-y-4 py-4">
|
|
160
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/20">
|
|
161
|
+
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
|
162
|
+
{t('warningMessage')}
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{requiresMfa && (
|
|
167
|
+
<div className="space-y-2">
|
|
168
|
+
{showMethodToggle && (
|
|
169
|
+
<div className="flex justify-center gap-2 mb-4 flex-wrap">
|
|
170
|
+
{(availableMethods?.includes('totp') ||
|
|
171
|
+
verificationType === 'totp') && (
|
|
172
|
+
<Button
|
|
173
|
+
type="button"
|
|
174
|
+
variant={
|
|
175
|
+
selectedMethod === 'totp' ? 'default' : 'outline'
|
|
176
|
+
}
|
|
177
|
+
size="sm"
|
|
178
|
+
onClick={() => {
|
|
179
|
+
setSelectedMethod('totp');
|
|
180
|
+
setVerificationCode('');
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<Key className="mr-2 size-4" />
|
|
184
|
+
{t('buttonApp')}
|
|
185
|
+
</Button>
|
|
186
|
+
)}
|
|
187
|
+
{(availableMethods?.includes('email') ||
|
|
188
|
+
verificationType === 'email') && (
|
|
189
|
+
<Button
|
|
190
|
+
type="button"
|
|
191
|
+
variant={
|
|
192
|
+
selectedMethod === 'email' ? 'default' : 'outline'
|
|
193
|
+
}
|
|
194
|
+
size="sm"
|
|
195
|
+
onClick={() => {
|
|
196
|
+
setSelectedMethod('email');
|
|
197
|
+
setVerificationCode('');
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<Mail className="mr-2 size-4" />
|
|
201
|
+
{t('buttonEmail')}
|
|
202
|
+
</Button>
|
|
203
|
+
)}
|
|
204
|
+
{hasWebAuthn && (
|
|
205
|
+
<Button
|
|
206
|
+
type="button"
|
|
207
|
+
variant={
|
|
208
|
+
selectedMethod === 'webauthn' ? 'default' : 'outline'
|
|
209
|
+
}
|
|
210
|
+
size="sm"
|
|
211
|
+
onClick={async () => {
|
|
212
|
+
setLoading(true);
|
|
213
|
+
try {
|
|
214
|
+
const { startAuthentication } =
|
|
215
|
+
await import('@simplewebauthn/browser');
|
|
216
|
+
|
|
217
|
+
const { data: optionsData } = await request({
|
|
218
|
+
url: '/profile/webauthn/authenticate/generate',
|
|
219
|
+
method: 'POST',
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const assertion = await startAuthentication(
|
|
223
|
+
optionsData as any
|
|
224
|
+
);
|
|
225
|
+
await onConfirm(
|
|
226
|
+
'',
|
|
227
|
+
undefined,
|
|
228
|
+
false,
|
|
229
|
+
'webauthn',
|
|
230
|
+
assertion
|
|
231
|
+
);
|
|
232
|
+
setVerificationCode('');
|
|
233
|
+
setLoading(false);
|
|
234
|
+
handleOpenChange(false);
|
|
235
|
+
} catch (error: any) {
|
|
236
|
+
console.error('Verification failed:', error);
|
|
237
|
+
setLoading(false);
|
|
238
|
+
}
|
|
239
|
+
}}
|
|
240
|
+
disabled={loading}
|
|
241
|
+
>
|
|
242
|
+
<Fingerprint className="mr-2 size-4" />
|
|
243
|
+
{loading
|
|
244
|
+
? t('buttonAuthenticating')
|
|
245
|
+
: t('buttonSecurityKey')}
|
|
246
|
+
</Button>
|
|
247
|
+
)}
|
|
248
|
+
{hasRecoveryCodes && (
|
|
249
|
+
<Button
|
|
250
|
+
type="button"
|
|
251
|
+
variant={
|
|
252
|
+
selectedMethod === 'recovery' ? 'default' : 'outline'
|
|
253
|
+
}
|
|
254
|
+
size="sm"
|
|
255
|
+
onClick={() => {
|
|
256
|
+
setSelectedMethod('recovery');
|
|
257
|
+
setVerificationCode('');
|
|
258
|
+
}}
|
|
259
|
+
>
|
|
260
|
+
<Shield className="mr-2 size-4" />
|
|
261
|
+
{t('buttonRecoveryCode')}
|
|
262
|
+
</Button>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
<Label htmlFor="verification-code" className="text-center block">
|
|
267
|
+
{currentMethod === 'email'
|
|
268
|
+
? t('labelEmail')
|
|
269
|
+
: currentMethod === 'recovery'
|
|
270
|
+
? t('labelRecovery')
|
|
271
|
+
: currentMethod === 'webauthn'
|
|
272
|
+
? t('labelWebAuthn')
|
|
273
|
+
: t('labelTotp')}
|
|
274
|
+
</Label>
|
|
275
|
+
{currentMethod === 'webauthn' ? (
|
|
276
|
+
<div className="text-center text-sm text-muted-foreground">
|
|
277
|
+
{t('webAuthnInstruction')}
|
|
278
|
+
</div>
|
|
279
|
+
) : currentMethod === 'recovery' ? (
|
|
280
|
+
<div className="flex justify-center">
|
|
281
|
+
<Input
|
|
282
|
+
id="verification-code"
|
|
283
|
+
value={verificationCode}
|
|
284
|
+
onChange={(e) => setVerificationCode(e.target.value)}
|
|
285
|
+
className="text-center text-lg font-mono uppercase max-w-xs"
|
|
286
|
+
autoComplete="off"
|
|
287
|
+
placeholder={t('placeholderRecovery')}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
) : (
|
|
291
|
+
<div className="flex justify-center">
|
|
292
|
+
<InputOTP
|
|
293
|
+
maxLength={currentMethod === 'totp' ? 6 : pinCodeLength}
|
|
294
|
+
value={verificationCode}
|
|
295
|
+
onChange={setVerificationCode}
|
|
296
|
+
>
|
|
297
|
+
{(() => {
|
|
298
|
+
const length =
|
|
299
|
+
currentMethod === 'totp' ? 6 : pinCodeLength;
|
|
300
|
+
const groupSize = Math.ceil(length / 2);
|
|
301
|
+
const groups = [
|
|
302
|
+
Array.from({ length: groupSize }, (_, i) => (
|
|
303
|
+
<InputOTPSlot key={i} index={i} />
|
|
304
|
+
)),
|
|
305
|
+
Array.from({ length: length - groupSize }, (_, i) => (
|
|
306
|
+
<InputOTPSlot
|
|
307
|
+
key={i + groupSize}
|
|
308
|
+
index={i + groupSize}
|
|
309
|
+
/>
|
|
310
|
+
)),
|
|
311
|
+
];
|
|
312
|
+
return (
|
|
313
|
+
<>
|
|
314
|
+
<InputOTPGroup>{groups[0]}</InputOTPGroup>
|
|
315
|
+
<InputOTPSeparator />
|
|
316
|
+
<InputOTPGroup>{groups[1]}</InputOTPGroup>
|
|
317
|
+
</>
|
|
318
|
+
);
|
|
319
|
+
})()}
|
|
320
|
+
</InputOTP>
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
{currentMethod === 'email' && (
|
|
324
|
+
<div className="flex justify-center">
|
|
325
|
+
<Button
|
|
326
|
+
type="button"
|
|
327
|
+
variant="outline"
|
|
328
|
+
size="sm"
|
|
329
|
+
onClick={handleResendCode}
|
|
330
|
+
disabled={resendLoading || resendCooldown > 0}
|
|
331
|
+
className="mt-2"
|
|
332
|
+
>
|
|
333
|
+
{resendLoading ? (
|
|
334
|
+
<>
|
|
335
|
+
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
|
336
|
+
{t('resending')}
|
|
337
|
+
</>
|
|
338
|
+
) : resendCooldown > 0 ? (
|
|
339
|
+
<>
|
|
340
|
+
<RefreshCw className="w-4 h-4 mr-2" />
|
|
341
|
+
{t('resendIn', { seconds: resendCooldown })}
|
|
342
|
+
</>
|
|
343
|
+
) : (
|
|
344
|
+
<>
|
|
345
|
+
<RefreshCw className="w-4 h-4 mr-2" />
|
|
346
|
+
{t('resendCode')}
|
|
347
|
+
</>
|
|
348
|
+
)}
|
|
349
|
+
</Button>
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
)}
|
|
354
|
+
</div>
|
|
355
|
+
<DialogFooter>
|
|
356
|
+
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
|
357
|
+
{t('buttonCancel')}
|
|
358
|
+
</Button>
|
|
359
|
+
{currentMethod !== 'webauthn' && (
|
|
360
|
+
<Button
|
|
361
|
+
onClick={handleConfirm}
|
|
362
|
+
variant="default"
|
|
363
|
+
disabled={(requiresMfa && !verificationCode) || loading}
|
|
364
|
+
>
|
|
365
|
+
{loading ? t('buttonGenerating') : t('buttonGenerate')}
|
|
366
|
+
</Button>
|
|
367
|
+
)}
|
|
368
|
+
</DialogFooter>
|
|
369
|
+
</DialogContent>
|
|
370
|
+
</Dialog>
|
|
371
|
+
);
|
|
372
|
+
}
|