@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.
- package/README.md +348 -0
- package/dist/App.d.ts +1 -0
- package/dist/api.d.ts +35 -0
- package/dist/branding.d.ts +22 -0
- package/dist/branding.json +8 -0
- package/dist/components/AuthLayout.d.ts +7 -0
- package/dist/components/ui/alert.d.ts +9 -0
- package/dist/components/ui/button.d.ts +11 -0
- package/dist/components/ui/card.d.ts +8 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/separator.d.ts +6 -0
- package/dist/favicon.svg +1 -0
- package/dist/hooks/useDarkMode.d.ts +6 -0
- package/dist/i18n/index.d.ts +2 -0
- package/dist/icons.svg +24 -0
- package/dist/index.css +3 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +6332 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/main.d.ts +2 -0
- package/dist/pages/ConsentPage.d.ts +1 -0
- package/dist/pages/DevicePage.d.ts +1 -0
- package/dist/pages/ForgotPasswordPage.d.ts +1 -0
- package/dist/pages/GrantsPage.d.ts +1 -0
- package/dist/pages/LoginPage.d.ts +1 -0
- package/dist/pages/MfaChallengePage.d.ts +1 -0
- package/dist/pages/MfaSetupPage.d.ts +1 -0
- package/dist/pages/RegisterPage.d.ts +1 -0
- package/dist/pages/ResetPasswordPage.d.ts +1 -0
- package/dist/types.d.ts +91 -0
- package/index.html +13 -0
- package/package.json +65 -0
- package/public/branding.json +8 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.tsx +32 -0
- package/src/api.ts +156 -0
- package/src/branding.ts +55 -0
- package/src/components/AuthLayout.tsx +107 -0
- package/src/components/ui/alert.tsx +31 -0
- package/src/components/ui/button.tsx +51 -0
- package/src/components/ui/card.tsx +50 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/separator.tsx +16 -0
- package/src/hooks/useDarkMode.ts +39 -0
- package/src/i18n/de.json +111 -0
- package/src/i18n/en.json +136 -0
- package/src/i18n/es.json +111 -0
- package/src/i18n/fr.json +111 -0
- package/src/i18n/index.ts +39 -0
- package/src/i18n/pt.json +111 -0
- package/src/i18n/tlh.json +111 -0
- package/src/i18n/vi.json +111 -0
- package/src/i18n/zh-Hans.json +111 -0
- package/src/index.ts +44 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +19 -0
- package/src/pages/ConsentPage.tsx +144 -0
- package/src/pages/DevicePage.tsx +145 -0
- package/src/pages/ForgotPasswordPage.tsx +90 -0
- package/src/pages/GrantsPage.tsx +87 -0
- package/src/pages/LoginPage.tsx +423 -0
- package/src/pages/MfaChallengePage.tsx +246 -0
- package/src/pages/MfaSetupPage.tsx +366 -0
- package/src/pages/RegisterPage.tsx +161 -0
- package/src/pages/ResetPasswordPage.tsx +219 -0
- package/src/styles.css +33 -0
- package/src/types.ts +112 -0
- package/tsconfig.app.json +37 -0
- package/tsconfig.json +7 -0
- 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
|
+
}
|