@authagonal/login 0.1.97

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 (73) hide show
  1. package/README.md +348 -0
  2. package/dist/App.d.ts +1 -0
  3. package/dist/api.d.ts +35 -0
  4. package/dist/branding.d.ts +22 -0
  5. package/dist/branding.json +8 -0
  6. package/dist/components/AuthLayout.d.ts +7 -0
  7. package/dist/components/ui/alert.d.ts +9 -0
  8. package/dist/components/ui/button.d.ts +11 -0
  9. package/dist/components/ui/card.d.ts +8 -0
  10. package/dist/components/ui/input.d.ts +3 -0
  11. package/dist/components/ui/label.d.ts +3 -0
  12. package/dist/components/ui/separator.d.ts +6 -0
  13. package/dist/favicon.svg +1 -0
  14. package/dist/hooks/useDarkMode.d.ts +6 -0
  15. package/dist/i18n/index.d.ts +2 -0
  16. package/dist/icons.svg +24 -0
  17. package/dist/index.css +3 -0
  18. package/dist/index.d.ts +23 -0
  19. package/dist/index.js +6332 -0
  20. package/dist/lib/utils.d.ts +2 -0
  21. package/dist/main.d.ts +2 -0
  22. package/dist/pages/ConsentPage.d.ts +1 -0
  23. package/dist/pages/DevicePage.d.ts +1 -0
  24. package/dist/pages/ForgotPasswordPage.d.ts +1 -0
  25. package/dist/pages/GrantsPage.d.ts +1 -0
  26. package/dist/pages/LoginPage.d.ts +1 -0
  27. package/dist/pages/MfaChallengePage.d.ts +1 -0
  28. package/dist/pages/MfaSetupPage.d.ts +1 -0
  29. package/dist/pages/RegisterPage.d.ts +1 -0
  30. package/dist/pages/ResetPasswordPage.d.ts +1 -0
  31. package/dist/types.d.ts +91 -0
  32. package/index.html +13 -0
  33. package/package.json +65 -0
  34. package/public/branding.json +8 -0
  35. package/public/favicon.svg +1 -0
  36. package/public/icons.svg +24 -0
  37. package/src/App.tsx +32 -0
  38. package/src/api.ts +156 -0
  39. package/src/branding.ts +55 -0
  40. package/src/components/AuthLayout.tsx +107 -0
  41. package/src/components/ui/alert.tsx +31 -0
  42. package/src/components/ui/button.tsx +51 -0
  43. package/src/components/ui/card.tsx +50 -0
  44. package/src/components/ui/input.tsx +21 -0
  45. package/src/components/ui/label.tsx +17 -0
  46. package/src/components/ui/separator.tsx +16 -0
  47. package/src/hooks/useDarkMode.ts +39 -0
  48. package/src/i18n/de.json +111 -0
  49. package/src/i18n/en.json +136 -0
  50. package/src/i18n/es.json +111 -0
  51. package/src/i18n/fr.json +111 -0
  52. package/src/i18n/index.ts +39 -0
  53. package/src/i18n/pt.json +111 -0
  54. package/src/i18n/tlh.json +111 -0
  55. package/src/i18n/vi.json +111 -0
  56. package/src/i18n/zh-Hans.json +111 -0
  57. package/src/index.ts +44 -0
  58. package/src/lib/utils.ts +6 -0
  59. package/src/main.tsx +19 -0
  60. package/src/pages/ConsentPage.tsx +144 -0
  61. package/src/pages/DevicePage.tsx +145 -0
  62. package/src/pages/ForgotPasswordPage.tsx +90 -0
  63. package/src/pages/GrantsPage.tsx +87 -0
  64. package/src/pages/LoginPage.tsx +423 -0
  65. package/src/pages/MfaChallengePage.tsx +246 -0
  66. package/src/pages/MfaSetupPage.tsx +366 -0
  67. package/src/pages/RegisterPage.tsx +161 -0
  68. package/src/pages/ResetPasswordPage.tsx +219 -0
  69. package/src/styles.css +33 -0
  70. package/src/types.ts +112 -0
  71. package/tsconfig.app.json +37 -0
  72. package/tsconfig.json +7 -0
  73. package/vite.config.ts +54 -0
@@ -0,0 +1,423 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { useSearchParams, Link, useNavigate } from 'react-router-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { login, logout, ssoCheck, getProviders, getSession, ApiRequestError } from '../api';
5
+ import { useBranding } from '../branding';
6
+ import type { ExternalProvider } from '../types';
7
+ import { Button } from '@/components/ui/button';
8
+ import { Input } from '@/components/ui/input';
9
+ import { Label } from '@/components/ui/label';
10
+ import { Alert } from '@/components/ui/alert';
11
+ import { Separator } from '@/components/ui/separator';
12
+ import { CardTitle, CardFooter } from '@/components/ui/card';
13
+
14
+ const API_URL = import.meta.env.VITE_API_URL || '';
15
+
16
+ function isSafeReturnUrl(url: string): boolean {
17
+ if (!url) return false;
18
+ // Only allow relative paths (starting with /) that don't escape to another host
19
+ try {
20
+ const parsed = new URL(url, window.location.origin);
21
+ return parsed.origin === window.location.origin && url.startsWith('/');
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export default function LoginPage() {
28
+ const { t } = useTranslation();
29
+ const branding = useBranding();
30
+ const navigate = useNavigate();
31
+ const [searchParams] = useSearchParams();
32
+ const returnUrl = searchParams.get('returnUrl') || '';
33
+ const loginHint = searchParams.get('login_hint') || '';
34
+ const oidcError = searchParams.get('error_description') || searchParams.get('error') || '';
35
+ const messageParam = searchParams.get('message') || '';
36
+
37
+ const [email, setEmail] = useState(loginHint);
38
+ const [password, setPassword] = useState('');
39
+ const [error, setError] = useState(oidcError);
40
+ const [successMessage] = useState(() =>
41
+ messageParam === 'registration_success' ? t('registrationSuccess') : ''
42
+ );
43
+ const [loading, setLoading] = useState(false);
44
+ const [ssoInfo, setSsoInfo] = useState<{ redirectUrl: string } | null>(null);
45
+ const [ssoChecked, setSsoChecked] = useState(false);
46
+ const [ssoChecking, setSsoChecking] = useState(false);
47
+ const [providers, setProviders] = useState<ExternalProvider[]>([]);
48
+ const [session, setSession] = useState<{ name: string; email: string } | null>(null);
49
+ const [mfaPrompt, setMfaPrompt] = useState<{ returnUrl: string; userId: string; clientId: string } | null>(null);
50
+
51
+ const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
52
+ const lastCheckedEmailRef = useRef('');
53
+
54
+ const performSsoCheck = useCallback(async (emailToCheck: string) => {
55
+ if (!emailToCheck.includes('@') || emailToCheck === lastCheckedEmailRef.current) {
56
+ return;
57
+ }
58
+
59
+ lastCheckedEmailRef.current = emailToCheck;
60
+ setSsoChecking(true);
61
+ setError('');
62
+
63
+ try {
64
+ const result = await ssoCheck(emailToCheck);
65
+ if (result.ssoRequired && result.redirectUrl) {
66
+ setSsoInfo({ redirectUrl: result.redirectUrl });
67
+ } else {
68
+ setSsoInfo(null);
69
+ }
70
+ setSsoChecked(true);
71
+ } catch {
72
+ // If SSO check fails, allow normal login
73
+ setSsoInfo(null);
74
+ setSsoChecked(true);
75
+ } finally {
76
+ setSsoChecking(false);
77
+ }
78
+ }, []);
79
+
80
+ // Check for existing session (e.g. after OIDC callback with no returnUrl)
81
+ useEffect(() => {
82
+ if (returnUrl && isSafeReturnUrl(returnUrl)) return; // OAuth flow — don't check session
83
+ getSession()
84
+ .then((s) => {
85
+ if (s.authenticated) {
86
+ setSession({ name: s.name, email: s.email });
87
+ }
88
+ })
89
+ .catch(() => {});
90
+ }, [returnUrl]);
91
+
92
+ // Fetch available external providers
93
+ useEffect(() => {
94
+ getProviders()
95
+ .then((res) => setProviders(res.providers ?? []))
96
+ .catch(() => {});
97
+ }, []);
98
+
99
+ // Auto-trigger SSO check if login_hint is provided
100
+ useEffect(() => {
101
+ if (loginHint && loginHint.includes('@')) {
102
+ performSsoCheck(loginHint);
103
+ }
104
+ }, [loginHint, performSsoCheck]);
105
+
106
+ function handleEmailBlur() {
107
+ if (debounceTimerRef.current) {
108
+ clearTimeout(debounceTimerRef.current);
109
+ }
110
+ debounceTimerRef.current = setTimeout(() => {
111
+ performSsoCheck(email);
112
+ }, 300);
113
+ }
114
+
115
+ function handleEmailChange(value: string) {
116
+ setEmail(value);
117
+ // Reset SSO state when email changes
118
+ if (value !== lastCheckedEmailRef.current) {
119
+ setSsoChecked(false);
120
+ setSsoInfo(null);
121
+ }
122
+ }
123
+
124
+ function handleProviderLogin(provider: ExternalProvider) {
125
+ const url = new URL(`${API_URL}${provider.loginUrl}`, window.location.origin);
126
+ if (returnUrl && isSafeReturnUrl(returnUrl)) {
127
+ url.searchParams.set('returnUrl', returnUrl);
128
+ }
129
+ window.location.href = url.toString();
130
+ }
131
+
132
+ function handleSsoRedirect() {
133
+ if (ssoInfo) {
134
+ const ssoUrl = new URL(`${API_URL}${ssoInfo.redirectUrl}`, window.location.origin);
135
+ if (returnUrl && isSafeReturnUrl(returnUrl)) {
136
+ ssoUrl.searchParams.set('returnUrl', returnUrl);
137
+ }
138
+ if (email) {
139
+ ssoUrl.searchParams.set('loginHint', email);
140
+ }
141
+ window.location.href = ssoUrl.toString();
142
+ }
143
+ }
144
+
145
+ async function handleSubmit(e: React.FormEvent) {
146
+ e.preventDefault();
147
+ setError('');
148
+ setLoading(true);
149
+
150
+ try {
151
+ const result = await login(email, password, returnUrl || undefined);
152
+
153
+ if (result.mfaRequired && result.challengeId) {
154
+ // Redirect to MFA challenge page
155
+ const params = new URLSearchParams({
156
+ challengeId: result.challengeId,
157
+ ...(returnUrl ? { returnUrl } : {}),
158
+ ...(result.methods ? { methods: result.methods.join(',') } : {}),
159
+ ...(result.webAuthn ? { webAuthn: JSON.stringify(result.webAuthn) } : {}),
160
+ });
161
+ navigate(`/mfa-challenge?${params.toString()}`);
162
+ return;
163
+ }
164
+
165
+ if (result.mfaSetupRequired) {
166
+ // Redirect to MFA setup page with setup token
167
+ const params = new URLSearchParams({
168
+ ...(returnUrl ? { returnUrl } : {}),
169
+ ...(result.setupToken ? { setupToken: result.setupToken } : {}),
170
+ });
171
+ navigate(`/mfa-setup?${params.toString()}`);
172
+ return;
173
+ }
174
+
175
+ // If MFA is available but not enrolled, offer to set it up (once per client)
176
+ if (result.mfaAvailable && result.userId) {
177
+ const dismissKey = `mfa-prompt-dismissed:${result.userId}:${result.clientId || 'default'}`;
178
+ if (!localStorage.getItem(dismissKey)) {
179
+ setMfaPrompt({ returnUrl, userId: result.userId, clientId: result.clientId || 'default' });
180
+ return;
181
+ }
182
+ }
183
+
184
+ // On success, redirect to returnUrl (validated) using window.location.href
185
+ if (returnUrl && isSafeReturnUrl(returnUrl)) {
186
+ window.location.href = returnUrl;
187
+ } else {
188
+ window.location.href = '/';
189
+ }
190
+ } catch (err) {
191
+ if (err instanceof ApiRequestError) {
192
+ switch (err.error) {
193
+ case 'invalid_credentials':
194
+ setError(t('errorInvalidCredentials'));
195
+ break;
196
+ case 'locked_out':
197
+ setError(t('errorLockedOut', { seconds: err.retryAfter ?? '?' }));
198
+ break;
199
+ case 'email_not_confirmed':
200
+ setError(t('errorEmailNotConfirmed'));
201
+ break;
202
+ case 'sso_required':
203
+ if (err.redirectUrl) {
204
+ const ssoUrl = new URL(`${API_URL}${err.redirectUrl}`, window.location.origin);
205
+ if (returnUrl && isSafeReturnUrl(returnUrl)) {
206
+ ssoUrl.searchParams.set('returnUrl', returnUrl);
207
+ }
208
+ window.location.href = ssoUrl.toString();
209
+ return;
210
+ }
211
+ setError(t('errorSsoRequired'));
212
+ break;
213
+ case 'email_required':
214
+ setError(t('errorEmailRequired'));
215
+ break;
216
+ case 'password_required':
217
+ setError(t('errorPasswordRequired'));
218
+ break;
219
+ default:
220
+ setError(err.message || t('errorUnexpected'));
221
+ }
222
+ } else {
223
+ setError(t('errorUnexpected'));
224
+ }
225
+ } finally {
226
+ setLoading(false);
227
+ }
228
+ }
229
+
230
+ const forgotPasswordLink = returnUrl && isSafeReturnUrl(returnUrl)
231
+ ? `/forgot-password?returnUrl=${encodeURIComponent(returnUrl)}`
232
+ : '/forgot-password';
233
+
234
+ const showPasswordField = ssoChecked && !ssoInfo;
235
+
236
+ if (mfaPrompt) {
237
+ const skipMfa = () => {
238
+ localStorage.setItem(`mfa-prompt-dismissed:${mfaPrompt.userId}:${mfaPrompt.clientId}`, '1');
239
+ const dest = mfaPrompt.returnUrl && isSafeReturnUrl(mfaPrompt.returnUrl)
240
+ ? mfaPrompt.returnUrl
241
+ : '/';
242
+ window.location.href = dest;
243
+ };
244
+
245
+ return (
246
+ <div>
247
+ <CardTitle>{t('mfaPromptTitle')}</CardTitle>
248
+ <p className="text-center text-gray-500 dark:text-gray-400 mb-6">
249
+ {t('mfaPromptMessage')}
250
+ </p>
251
+ <Button
252
+ className="mb-3"
253
+ onClick={() => navigate(`/mfa-setup?returnUrl=${encodeURIComponent(mfaPrompt.returnUrl || '/')}`)}
254
+ >
255
+ {t('mfaPromptSetup')}
256
+ </Button>
257
+ <Button variant="secondary" onClick={skipMfa}>
258
+ {t('mfaPromptSkip')}
259
+ </Button>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ if (session) {
265
+ return (
266
+ <div>
267
+ <CardTitle>{t('signedInAs', { name: session.name || session.email })}</CardTitle>
268
+ <p className="text-center text-gray-500 dark:text-gray-400">{t('signedInMessage')}</p>
269
+ <CardFooter>
270
+ <Button
271
+ variant="secondary"
272
+ onClick={() => {
273
+ logout().then(() => {
274
+ setSession(null);
275
+ }).catch(() => {
276
+ setSession(null);
277
+ });
278
+ }}
279
+ >
280
+ {t('signOut')}
281
+ </Button>
282
+ </CardFooter>
283
+ </div>
284
+ );
285
+ }
286
+
287
+ return (
288
+ <div>
289
+ <CardTitle>{t('signIn')}</CardTitle>
290
+
291
+ {providers.length > 0 && !showPasswordField && (
292
+ <div className="mb-2">
293
+ {providers.map((p) => (
294
+ <Button
295
+ key={p.connectionId}
296
+ type="button"
297
+ variant="secondary"
298
+ className="mb-2"
299
+ onClick={() => handleProviderLogin(p)}
300
+ >
301
+ {p.connectionId === 'google' && (
302
+ <svg className="shrink-0" viewBox="0 0 24 24" width="20" height="20">
303
+ <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/>
304
+ <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
305
+ <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
306
+ <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
307
+ </svg>
308
+ )}
309
+ {t('continueWith', { provider: p.name })}
310
+ </Button>
311
+ ))}
312
+ <Separator label={t('or')} />
313
+ </div>
314
+ )}
315
+
316
+ {providers.length > 0 && showPasswordField && (
317
+ <div className="flex items-center gap-3 mb-4 text-gray-400 dark:text-gray-500 text-[13px]">
318
+ <div className="flex-1 h-px bg-gray-200 dark:bg-gray-800" />
319
+ <button
320
+ type="button"
321
+ onClick={() => { setSsoChecked(false); setSsoInfo(null); lastCheckedEmailRef.current = ''; }}
322
+ className="bg-transparent border-none cursor-pointer text-[13px] text-primary hover:underline"
323
+ >
324
+ {t('orSignInWith', { provider: providers.map(p => p.name).join(', ') })}
325
+ </button>
326
+ <div className="flex-1 h-px bg-gray-200 dark:bg-gray-800" />
327
+ </div>
328
+ )}
329
+
330
+ {successMessage && <Alert variant="success">{successMessage}</Alert>}
331
+ {error && <Alert variant="error">{error}</Alert>}
332
+
333
+ <form onSubmit={handleSubmit} data-auth="login-form">
334
+ <div className="mb-4" data-auth="email-field">
335
+ <Label htmlFor="email">{t('email')}</Label>
336
+ <Input
337
+ id="email"
338
+ type="email"
339
+ value={email}
340
+ onChange={(e) => handleEmailChange(e.target.value)}
341
+ onBlur={handleEmailBlur}
342
+ onKeyDown={(e) => {
343
+ if (e.key === 'Enter' && !ssoChecked && !ssoChecking && email.includes('@')) {
344
+ e.preventDefault();
345
+ performSsoCheck(email);
346
+ }
347
+ }}
348
+ placeholder={t('emailPlaceholder')}
349
+ autoComplete="email"
350
+ autoFocus={!loginHint}
351
+ maxLength={256}
352
+ required
353
+ />
354
+ </div>
355
+
356
+ {!ssoChecked && !ssoChecking && (
357
+ <Button
358
+ type="button"
359
+ onClick={() => performSsoCheck(email)}
360
+ disabled={!email.includes('@')}
361
+ >
362
+ {t('continue')}
363
+ </Button>
364
+ )}
365
+
366
+ {ssoChecking && (
367
+ <p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t('ssoChecking')}</p>
368
+ )}
369
+
370
+ {ssoInfo && (
371
+ <div className="mb-4">
372
+ <p className="text-sm text-gray-500 dark:text-gray-400 mb-3">{t('ssoNotice')}</p>
373
+ <Button variant="secondary" type="button" onClick={handleSsoRedirect}>
374
+ {t('continueWithSso')}
375
+ </Button>
376
+ </div>
377
+ )}
378
+
379
+ {showPasswordField && (
380
+ <>
381
+ <div className="mb-4" data-auth="password-field">
382
+ <Label htmlFor="password">{t('password')}</Label>
383
+ <Input
384
+ id="password"
385
+ type="password"
386
+ value={password}
387
+ onChange={(e) => setPassword(e.target.value)}
388
+ placeholder={t('passwordPlaceholder')}
389
+ autoComplete="current-password"
390
+ autoFocus
391
+ maxLength={256}
392
+ required
393
+ />
394
+ </div>
395
+
396
+ <Button type="submit" loading={loading} data-auth="submit-button">
397
+ {loading ? t('signingIn') : t('signIn')}
398
+ </Button>
399
+
400
+ {branding.showForgotPassword && (
401
+ <CardFooter>
402
+ <Link to={forgotPasswordLink} className="text-sm font-medium text-primary hover:underline no-underline">
403
+ {t('forgotPassword')}
404
+ </Link>
405
+ </CardFooter>
406
+ )}
407
+ </>
408
+ )}
409
+ </form>
410
+
411
+ {branding.showRegistration && (
412
+ <CardFooter className="mt-4">
413
+ <span className="text-sm text-gray-500 dark:text-gray-400">
414
+ {t('noAccount')}{' '}
415
+ <Link to={returnUrl ? `/register?returnUrl=${encodeURIComponent(returnUrl)}` : '/register'} className="text-sm font-medium text-primary hover:underline no-underline">
416
+ {t('createAccount')}
417
+ </Link>
418
+ </span>
419
+ </CardFooter>
420
+ )}
421
+ </div>
422
+ );
423
+ }
@@ -0,0 +1,246 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { mfaVerify, ApiRequestError } from '../api';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Input } from '@/components/ui/input';
7
+ import { Label } from '@/components/ui/label';
8
+ import { Alert } from '@/components/ui/alert';
9
+ import { CardTitle, CardDescription } from '@/components/ui/card';
10
+
11
+ function isSafeReturnUrl(url: string): boolean {
12
+ if (!url) return false;
13
+ try {
14
+ const parsed = new URL(url, window.location.origin);
15
+ return parsed.origin === window.location.origin && url.startsWith('/');
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ // Helper: Base64URL decode to Uint8Array
22
+ function base64UrlToBuffer(base64url: string): ArrayBuffer {
23
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
24
+ const pad = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
25
+ const binary = atob(base64 + pad);
26
+ const bytes = new Uint8Array(binary.length);
27
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
28
+ return bytes.buffer as ArrayBuffer;
29
+ }
30
+
31
+ // Helper: ArrayBuffer to Base64URL
32
+ function bufferToBase64Url(buffer: ArrayBuffer): string {
33
+ const bytes = new Uint8Array(buffer);
34
+ let binary = '';
35
+ for (const byte of bytes) binary += String.fromCharCode(byte);
36
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
37
+ }
38
+
39
+ export default function MfaChallengePage() {
40
+ const { t } = useTranslation();
41
+ const [searchParams] = useSearchParams();
42
+ const challengeId = searchParams.get('challengeId') || '';
43
+ const returnUrl = searchParams.get('returnUrl') || '';
44
+ const methodsParam = searchParams.get('methods') || '';
45
+ const availableMethods = methodsParam ? methodsParam.split(',') : [];
46
+
47
+ const hasWebAuthn = availableMethods.includes('webauthn');
48
+ const defaultMethod = hasWebAuthn ? 'webauthn'
49
+ : availableMethods.includes('totp') ? 'totp'
50
+ : availableMethods[0] || 'totp';
51
+
52
+ const [method, setMethod] = useState(defaultMethod);
53
+ const [code, setCode] = useState('');
54
+ const [error, setError] = useState('');
55
+ const [loading, setLoading] = useState(false);
56
+
57
+ const handleSuccess = useCallback(() => {
58
+ if (returnUrl && isSafeReturnUrl(returnUrl)) {
59
+ window.location.href = returnUrl;
60
+ } else {
61
+ window.location.href = '/';
62
+ }
63
+ }, [returnUrl]);
64
+
65
+ const handleError = useCallback((err: unknown) => {
66
+ if (err instanceof ApiRequestError) {
67
+ switch (err.error) {
68
+ case 'invalid_code':
69
+ case 'assertion_failed':
70
+ setError(t('mfaInvalidCode'));
71
+ break;
72
+ case 'invalid_challenge':
73
+ setError(t('mfaChallengeExpired'));
74
+ break;
75
+ default:
76
+ setError(err.message || t('errorUnexpected'));
77
+ }
78
+ } else {
79
+ setError(t('errorUnexpected'));
80
+ }
81
+ }, [t]);
82
+
83
+ async function handleWebAuthn() {
84
+ setError('');
85
+ setLoading(true);
86
+
87
+ try {
88
+ // Get webAuthn options from the search params (stored as JSON in the URL)
89
+ const webAuthnOptionsParam = searchParams.get('webAuthn');
90
+ if (!webAuthnOptionsParam) {
91
+ setError(t('errorUnexpected'));
92
+ return;
93
+ }
94
+
95
+ const options = JSON.parse(webAuthnOptionsParam);
96
+
97
+ // Convert challenge and allowCredentials from Base64URL to ArrayBuffer
98
+ const publicKeyOptions: PublicKeyCredentialRequestOptions = {
99
+ challenge: base64UrlToBuffer(options.challenge),
100
+ rpId: options.rpId,
101
+ timeout: options.timeout || 60000,
102
+ userVerification: options.userVerification || 'preferred',
103
+ allowCredentials: (options.allowCredentials || []).map((c: { id: string; type: string; transports?: string[] }) => ({
104
+ id: base64UrlToBuffer(c.id),
105
+ type: c.type,
106
+ transports: c.transports,
107
+ })),
108
+ };
109
+
110
+ const credential = await navigator.credentials.get({ publicKey: publicKeyOptions }) as PublicKeyCredential;
111
+ if (!credential) {
112
+ setError(t('errorUnexpected'));
113
+ return;
114
+ }
115
+
116
+ const response = credential.response as AuthenticatorAssertionResponse;
117
+ const assertionJson = JSON.stringify({
118
+ id: credential.id,
119
+ rawId: bufferToBase64Url(credential.rawId),
120
+ type: credential.type,
121
+ response: {
122
+ authenticatorData: bufferToBase64Url(response.authenticatorData),
123
+ clientDataJSON: bufferToBase64Url(response.clientDataJSON),
124
+ signature: bufferToBase64Url(response.signature),
125
+ userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
126
+ },
127
+ });
128
+
129
+ await mfaVerify(challengeId, 'webauthn', undefined, assertionJson);
130
+ handleSuccess();
131
+ } catch (err) {
132
+ if (err instanceof DOMException && err.name === 'NotAllowedError') {
133
+ setError(t('mfaWebAuthnCancelled'));
134
+ } else {
135
+ handleError(err);
136
+ }
137
+ } finally {
138
+ setLoading(false);
139
+ }
140
+ }
141
+
142
+ async function handleSubmit(e: React.FormEvent) {
143
+ e.preventDefault();
144
+ if (!code.trim()) return;
145
+ setError('');
146
+ setLoading(true);
147
+
148
+ try {
149
+ await mfaVerify(challengeId, method, code);
150
+ handleSuccess();
151
+ } catch (err) {
152
+ handleError(err);
153
+ } finally {
154
+ setLoading(false);
155
+ }
156
+ }
157
+
158
+ function handleCodeChange(value: string) {
159
+ setCode(value);
160
+ // Auto-submit on 6 digits for TOTP
161
+ if (method === 'totp' && value.replace(/\s/g, '').length === 6) {
162
+ setTimeout(() => {
163
+ const form = document.getElementById('mfa-form') as HTMLFormElement;
164
+ form?.requestSubmit();
165
+ }, 100);
166
+ }
167
+ }
168
+
169
+ return (
170
+ <div>
171
+ <CardTitle>{t('mfaTitle')}</CardTitle>
172
+ <CardDescription className="mb-6">{t('mfaSubtitle')}</CardDescription>
173
+
174
+ {availableMethods.length > 1 && (
175
+ <div className="flex gap-2 mb-4 justify-center flex-wrap">
176
+ {hasWebAuthn && (
177
+ <Button
178
+ type="button"
179
+ variant={method === 'webauthn' ? 'default' : 'secondary'}
180
+ size="sm"
181
+ className="flex-1"
182
+ onClick={() => { setMethod('webauthn'); setCode(''); setError(''); }}
183
+ >
184
+ {t('mfaMethodWebAuthn')}
185
+ </Button>
186
+ )}
187
+ {availableMethods.includes('totp') && (
188
+ <Button
189
+ type="button"
190
+ variant={method === 'totp' ? 'default' : 'secondary'}
191
+ size="sm"
192
+ className="flex-1"
193
+ onClick={() => { setMethod('totp'); setCode(''); setError(''); }}
194
+ >
195
+ {t('mfaMethodTotp')}
196
+ </Button>
197
+ )}
198
+ {availableMethods.includes('recoverycode') && (
199
+ <Button
200
+ type="button"
201
+ variant={method === 'recovery' ? 'default' : 'secondary'}
202
+ size="sm"
203
+ className="flex-1"
204
+ onClick={() => { setMethod('recovery'); setCode(''); setError(''); }}
205
+ >
206
+ {t('mfaMethodRecovery')}
207
+ </Button>
208
+ )}
209
+ </div>
210
+ )}
211
+
212
+ {error && <Alert variant="error">{error}</Alert>}
213
+
214
+ {method === 'webauthn' ? (
215
+ <Button type="button" loading={loading} onClick={handleWebAuthn}>
216
+ {loading ? t('mfaVerifying') : t('mfaUsePasskey')}
217
+ </Button>
218
+ ) : (
219
+ <form id="mfa-form" onSubmit={handleSubmit}>
220
+ <div className="mb-4">
221
+ <Label htmlFor="mfa-code">
222
+ {method === 'totp' ? t('mfaTotpLabel') : t('mfaRecoveryLabel')}
223
+ </Label>
224
+ <Input
225
+ id="mfa-code"
226
+ type="text"
227
+ value={code}
228
+ onChange={(e) => handleCodeChange(e.target.value)}
229
+ placeholder={method === 'totp' ? '000000' : 'XXXX-XXXX'}
230
+ autoComplete="one-time-code"
231
+ autoFocus
232
+ maxLength={method === 'totp' ? 6 : 9}
233
+ inputMode={method === 'totp' ? 'numeric' : 'text'}
234
+ pattern={method === 'totp' ? '[0-9]{6}' : undefined}
235
+ required
236
+ />
237
+ </div>
238
+
239
+ <Button type="submit" loading={loading}>
240
+ {loading ? t('mfaVerifying') : t('mfaVerify')}
241
+ </Button>
242
+ </form>
243
+ )}
244
+ </div>
245
+ );
246
+ }