@micha.bigler/ui-core-micha 1.2.10 → 1.3.2
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/dist/auth/authApi.js +118 -13
- package/dist/components/MFAComponent.js +124 -0
- package/dist/components/SecurityComponent.js +2 -1
- package/dist/pages/LoginPage.js +46 -32
- package/package.json +5 -3
- package/src/auth/authApi.jsx +126 -1
- package/src/components/MFAComponent.jsx +264 -0
- package/src/components/SecurityComponent.jsx +5 -0
- package/src/pages/LoginPage.jsx +99 -52
package/dist/auth/authApi.js
CHANGED
|
@@ -115,21 +115,26 @@ export async function updateUserProfile(data) {
|
|
|
115
115
|
// -----------------------------
|
|
116
116
|
// Authentication: password
|
|
117
117
|
// -----------------------------
|
|
118
|
+
/*
|
|
118
119
|
export async function loginWithPassword(email, password) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
120
|
+
try {
|
|
121
|
+
await axios.post(
|
|
122
|
+
`${HEADLESS_BASE}/auth/login`,
|
|
123
|
+
{ email, password },
|
|
124
|
+
{ withCredentials: true },
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error.response && error.response.status === 409) {
|
|
128
|
+
// Already logged in: continue and fetch current user
|
|
129
|
+
} else {
|
|
130
|
+
throw new Error(extractErrorMessage(error));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const user = await fetchCurrentUser();
|
|
135
|
+
return { user };
|
|
132
136
|
}
|
|
137
|
+
*/
|
|
133
138
|
export async function requestPasswordReset(email) {
|
|
134
139
|
try {
|
|
135
140
|
await axios.post(`${USERS_BASE}/reset-request/`, { email }, { withCredentials: true });
|
|
@@ -346,6 +351,99 @@ export async function deletePasskey(id) {
|
|
|
346
351
|
{ withCredentials: true });
|
|
347
352
|
}
|
|
348
353
|
// -----------------------------
|
|
354
|
+
// Authentication: MFA Step
|
|
355
|
+
// -----------------------------
|
|
356
|
+
export async function authenticateWithMFA({ code, credential }) {
|
|
357
|
+
// Entweder 'code' (TOTP/Recovery) oder 'credential' (Passkey) senden
|
|
358
|
+
const payload = {};
|
|
359
|
+
if (code)
|
|
360
|
+
payload.code = code;
|
|
361
|
+
if (credential)
|
|
362
|
+
payload.credential = credential;
|
|
363
|
+
try {
|
|
364
|
+
const res = await axios.post(`${HEADLESS_BASE}/auth/mfa/authenticate`, // Endpoint für Schritt 2
|
|
365
|
+
payload, { withCredentials: true });
|
|
366
|
+
return res.data; // Enthält User-Objekt bei Erfolg
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
throw new Error(extractErrorMessage(error));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// -----------------------------
|
|
373
|
+
// Authentication: password (MODIFIZIERT)
|
|
374
|
+
// -----------------------------
|
|
375
|
+
export async function loginWithPassword(email, password) {
|
|
376
|
+
var _a;
|
|
377
|
+
try {
|
|
378
|
+
await axios.post(`${HEADLESS_BASE}/auth/login`, { email, password }, { withCredentials: true });
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
// SKEPTISCHER CHECK: Ist es der MFA-Flow?
|
|
382
|
+
if (error.response &&
|
|
383
|
+
error.response.status === 401 &&
|
|
384
|
+
((_a = error.response.data) === null || _a === void 0 ? void 0 : _a.flow) === 'mfa_authenticate') {
|
|
385
|
+
// KEIN FEHLER! Wir geben zurück, dass MFA nötig ist.
|
|
386
|
+
return {
|
|
387
|
+
needsMfa: true,
|
|
388
|
+
availableTypes: error.response.data.types || [], // z.B. ["webauthn", "totp"]
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
// Bestehende Logik für "Already logged in"
|
|
392
|
+
if (error.response && error.response.status === 409) {
|
|
393
|
+
// continue to fetch user
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
throw new Error(extractErrorMessage(error));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Wenn wir hier landen, war kein MFA nötig oder wir waren schon eingeloggt
|
|
400
|
+
const user = await fetchCurrentUser();
|
|
401
|
+
return { user, needsMfa: false };
|
|
402
|
+
}
|
|
403
|
+
// 1. Status prüfen (Liste der aktiven Authenticators)
|
|
404
|
+
export async function fetchAuthenticators() {
|
|
405
|
+
const res = await axios.get(`${HEADLESS_BASE}/mfa/authenticators`, {
|
|
406
|
+
withCredentials: true,
|
|
407
|
+
});
|
|
408
|
+
return res.data; // Array von Objekten, z.B. [{ type: 'totp', ... }, { type: 'webauthn' }]
|
|
409
|
+
}
|
|
410
|
+
// 2. TOTP Einrichtung starten (liefert Secret & QR-URL)
|
|
411
|
+
export async function requestTotpKey() {
|
|
412
|
+
// GET /mfa/totp/key liefert das Secret für die Einrichtung
|
|
413
|
+
const res = await axios.get(`${HEADLESS_BASE}/mfa/totp/key`, {
|
|
414
|
+
withCredentials: true,
|
|
415
|
+
});
|
|
416
|
+
return res.data; // { secret: "...", key_uri: "otpauth://..." }
|
|
417
|
+
}
|
|
418
|
+
// 3. TOTP Einrichtung abschließen (Code verifizieren)
|
|
419
|
+
export async function activateTotp(code) {
|
|
420
|
+
const res = await axios.post(`${HEADLESS_BASE}/mfa/totp/key`, { code }, { withCredentials: true });
|
|
421
|
+
return res.data;
|
|
422
|
+
}
|
|
423
|
+
// 4. TOTP deaktivieren
|
|
424
|
+
export async function deactivateTotp(id) {
|
|
425
|
+
// Manche Implementierungen nutzen DELETE auf /mfa/totp, andere auf den Authenticator-ID endpoint.
|
|
426
|
+
// Wir nutzen hier den allgemeinen Authenticator-Delete Endpoint, wenn wir die ID haben.
|
|
427
|
+
// Alternativ: DELETE /mfa/totp
|
|
428
|
+
const res = await axios.delete(`${HEADLESS_BASE}/mfa/authenticators/${id}`, {
|
|
429
|
+
withCredentials: true,
|
|
430
|
+
});
|
|
431
|
+
return res.data;
|
|
432
|
+
}
|
|
433
|
+
// -----------------------------
|
|
434
|
+
// MFA: Recovery Codes
|
|
435
|
+
// -----------------------------
|
|
436
|
+
export async function fetchRecoveryCodes() {
|
|
437
|
+
const res = await axios.get(`${HEADLESS_BASE}/mfa/recovery_codes`, {
|
|
438
|
+
withCredentials: true,
|
|
439
|
+
});
|
|
440
|
+
return res.data; // { codes: [...] }
|
|
441
|
+
}
|
|
442
|
+
export async function generateRecoveryCodes() {
|
|
443
|
+
const res = await axios.post(`${HEADLESS_BASE}/mfa/recovery_codes`, {}, { withCredentials: true });
|
|
444
|
+
return res.data; // { codes: [...] }
|
|
445
|
+
}
|
|
446
|
+
// -----------------------------
|
|
349
447
|
// Aggregated API object
|
|
350
448
|
// -----------------------------
|
|
351
449
|
export const authApi = {
|
|
@@ -363,6 +461,13 @@ export const authApi = {
|
|
|
363
461
|
registerPasskey,
|
|
364
462
|
fetchPasskeys,
|
|
365
463
|
deletePasskey,
|
|
464
|
+
authenticateWithMFA,
|
|
465
|
+
fetchAuthenticators,
|
|
466
|
+
requestTotpKey,
|
|
467
|
+
activateTotp,
|
|
468
|
+
deactivateTotp,
|
|
469
|
+
fetchRecoveryCodes,
|
|
470
|
+
generateRecoveryCodes,
|
|
366
471
|
validateAccessCode,
|
|
367
472
|
requestInviteWithCode,
|
|
368
473
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// src/auth/components/MFAComponent.jsx
|
|
3
|
+
import React, { useState, useEffect } from 'react';
|
|
4
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
5
|
+
import { Box, Typography, Button, TextField, Card, CardContent, Alert, CircularProgress, Stack, Divider, IconButton, Tooltip, } from '@mui/material';
|
|
6
|
+
import DeleteIcon from '@mui/icons-material/Delete';
|
|
7
|
+
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
8
|
+
import { authApi } from '../auth/authApi';
|
|
9
|
+
const MFAComponent = () => {
|
|
10
|
+
const [authenticators, setAuthenticators] = useState([]);
|
|
11
|
+
const [loading, setLoading] = useState(true);
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
// Setup State
|
|
14
|
+
const [isSettingUp, setIsSettingUp] = useState(false);
|
|
15
|
+
const [totpData, setTotpData] = useState(null); // { secret, key_uri }
|
|
16
|
+
const [verifyCode, setVerifyCode] = useState('');
|
|
17
|
+
const [submitting, setSubmitting] = useState(false);
|
|
18
|
+
// Recovery Codes State
|
|
19
|
+
const [recoveryCodes, setRecoveryCodes] = useState([]);
|
|
20
|
+
const [showRecovery, setShowRecovery] = useState(false);
|
|
21
|
+
const loadData = async () => {
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError('');
|
|
24
|
+
try {
|
|
25
|
+
// Lade Authenticators (TOTP, WebAuthn, etc.)
|
|
26
|
+
const data = await authApi.fetchAuthenticators();
|
|
27
|
+
setAuthenticators(Array.isArray(data) ? data : []);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
// Ignorieren wir 404/Empty falls noch nichts da ist, sonst Error
|
|
31
|
+
console.error(err);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
setLoading(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
loadData();
|
|
39
|
+
}, []);
|
|
40
|
+
const totpAuthenticator = authenticators.find((a) => a.type === 'totp');
|
|
41
|
+
// --- HANDLER: TOTP START ---
|
|
42
|
+
const handleStartSetup = async () => {
|
|
43
|
+
setError('');
|
|
44
|
+
setIsSettingUp(true);
|
|
45
|
+
try {
|
|
46
|
+
const data = await authApi.requestTotpKey();
|
|
47
|
+
setTotpData(data);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
setError('Could not fetch setup key.');
|
|
51
|
+
setIsSettingUp(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
// --- HANDLER: TOTP VERIFY ---
|
|
55
|
+
const handleVerify = async (e) => {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
setSubmitting(true);
|
|
58
|
+
setError('');
|
|
59
|
+
try {
|
|
60
|
+
await authApi.activateTotp(verifyCode);
|
|
61
|
+
setIsSettingUp(false);
|
|
62
|
+
setVerifyCode('');
|
|
63
|
+
setTotpData(null);
|
|
64
|
+
// Neu laden, um den neuen Status zu sehen
|
|
65
|
+
await loadData();
|
|
66
|
+
// Automatisch Recovery Codes anzeigen (Best Practice!)
|
|
67
|
+
handleShowRecoveryCodes();
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
setError('Invalid code. Please try again.');
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
setSubmitting(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
// --- HANDLER: TOTP DELETE ---
|
|
77
|
+
const handleDeleteTotp = async () => {
|
|
78
|
+
if (!window.confirm('Disable Authenticator App? Your security will be reduced.'))
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
if (totpAuthenticator === null || totpAuthenticator === void 0 ? void 0 : totpAuthenticator.id) {
|
|
82
|
+
await authApi.deactivateTotp(totpAuthenticator.id);
|
|
83
|
+
}
|
|
84
|
+
await loadData();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
setError('Could not deactivate TOTP.');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// --- HANDLER: RECOVERY CODES ---
|
|
91
|
+
const handleShowRecoveryCodes = async () => {
|
|
92
|
+
try {
|
|
93
|
+
// Versuch, existierende zu laden
|
|
94
|
+
let data = await authApi.fetchRecoveryCodes();
|
|
95
|
+
// Wenn keine existieren (oft leer beim ersten Mal), generiere neue
|
|
96
|
+
if (!data.codes || data.codes.length === 0) {
|
|
97
|
+
data = await authApi.generateRecoveryCodes();
|
|
98
|
+
}
|
|
99
|
+
setRecoveryCodes(data.codes);
|
|
100
|
+
setShowRecovery(true);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
setError('Could not load recovery codes.');
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const handleCopyCode = (code) => {
|
|
107
|
+
navigator.clipboard.writeText(code);
|
|
108
|
+
};
|
|
109
|
+
if (loading)
|
|
110
|
+
return _jsx(CircularProgress, {});
|
|
111
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: "Authenticator App (TOTP)" }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: "Use an app like Google Authenticator or Microsoft Authenticator to generate verification codes." }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), totpAuthenticator ? (_jsx(Card, { variant: "outlined", sx: { mb: 3, bgcolor: '#f0fdf4' }, children: _jsxs(CardContent, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsxs(Box, { children: [_jsx(Typography, { variant: "subtitle1", color: "success.main", fontWeight: "bold", children: "Active" }), _jsxs(Typography, { variant: "caption", color: "text.secondary", children: ["Created: ", new Date(totpAuthenticator.created_at * 1000).toLocaleDateString()] })] }), _jsx(Button, { variant: "outlined", color: "error", startIcon: _jsx(DeleteIcon, {}), onClick: handleDeleteTotp, children: "Disable" })] }) })) : (
|
|
112
|
+
/* --- CASE 2: TOTP NOT ACTIVE --- */
|
|
113
|
+
!isSettingUp && (_jsx(Button, { variant: "contained", onClick: handleStartSetup, children: "Set up Authenticator App" }))), isSettingUp && totpData && (_jsx(Card, { variant: "outlined", sx: { mb: 3 }, children: _jsxs(CardContent, { children: [_jsx(Typography, { variant: "subtitle1", gutterBottom: true, children: "Scan this QR Code" }), _jsxs(Stack, { direction: { xs: 'column', sm: 'row' }, spacing: 3, alignItems: "center", children: [_jsx(Box, { sx: { p: 2, bgcolor: 'white', border: '1px solid #eee' }, children: _jsx(QRCodeSVG, { value: totpData.key_uri, size: 150 }) }), _jsxs(Box, { sx: { flex: 1 }, children: [_jsx(Typography, { variant: "body2", gutterBottom: true, children: "Can't scan? Enter this key manually:" }), _jsx(Typography, { variant: "mono", sx: { fontFamily: 'monospace', bgcolor: '#eee', p: 1, borderRadius: 1, display: 'inline-block', mb: 2 }, children: totpData.secret }), _jsxs("form", { onSubmit: handleVerify, children: [_jsx(TextField, { label: "Verification Code (6 digits)", value: verifyCode, onChange: (e) => setVerifyCode(e.target.value), fullWidth: true, required: true, sx: { mb: 2 }, autoComplete: "off" }), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Button, { type: "submit", variant: "contained", disabled: submitting, children: submitting ? 'Verifying...' : 'Verify & Activate' }), _jsx(Button, { onClick: () => setIsSettingUp(false), disabled: submitting, children: "Cancel" })] })] })] })] })] }) })), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Recovery Codes" }), _jsx(Typography, { variant: "body2", sx: { mb: 2 }, children: "If you lose your device, these codes are the only way to access your account." }), !showRecovery ? (_jsx(Button, { variant: "outlined", onClick: handleShowRecoveryCodes, children: "View/Generate Recovery Codes" })) : (_jsxs(Box, { sx: { bgcolor: '#f5f5f5', p: 2, borderRadius: 1 }, children: [_jsx(Alert, { severity: "warning", sx: { mb: 2 }, children: "Save these codes in a safe place (e.g., password manager). Each code can be used only once." }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 2, children: recoveryCodes.map((code) => (_jsxs(Box, { sx: {
|
|
114
|
+
bgcolor: 'white',
|
|
115
|
+
border: '1px solid #ddd',
|
|
116
|
+
p: 1,
|
|
117
|
+
borderRadius: 1,
|
|
118
|
+
fontFamily: 'monospace',
|
|
119
|
+
display: 'flex',
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
gap: 1
|
|
122
|
+
}, children: [code, _jsx(Tooltip, { title: "Copy", children: _jsx(IconButton, { size: "small", onClick: () => handleCopyCode(code), children: _jsx(ContentCopyIcon, { fontSize: "small" }) }) })] }, code))) }), _jsx(Button, { sx: { mt: 2 }, size: "small", onClick: () => authApi.generateRecoveryCodes().then(d => setRecoveryCodes(d.codes)), children: "Generate New Codes" })] }))] }));
|
|
123
|
+
};
|
|
124
|
+
export default MFAComponent;
|
|
@@ -5,6 +5,7 @@ import { Box, Typography, Divider, Alert, } from '@mui/material';
|
|
|
5
5
|
import PasswordChangeForm from './PasswordChangeForm';
|
|
6
6
|
import SocialLoginButtons from './SocialLoginButtons';
|
|
7
7
|
import PasskeysComponent from './PasskeysComponent'; // <--- WICHTIG
|
|
8
|
+
import MFAComponent from './MFAComponent';
|
|
8
9
|
import { authApi } from '../auth/authApi';
|
|
9
10
|
const SecurityComponent = () => {
|
|
10
11
|
const [message, setMessage] = useState('');
|
|
@@ -34,6 +35,6 @@ const SecurityComponent = () => {
|
|
|
34
35
|
setError(errorMsg);
|
|
35
36
|
}
|
|
36
37
|
};
|
|
37
|
-
return (_jsxs(Box, { children: [message && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: message })), error && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error })), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Password" }), _jsx(PasswordChangeForm, { onSubmit: handlePasswordChange }), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Social logins" }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: "Sign in using a connected Google or Microsoft account." }), _jsx(SocialLoginButtons, { onProviderClick: handleSocialClick }), _jsx(Divider, { sx: { my: 3 } }), _jsx(PasskeysComponent, {})] }));
|
|
38
|
+
return (_jsxs(Box, { children: [message && (_jsx(Alert, { severity: "success", sx: { mb: 2 }, children: message })), error && (_jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error })), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Password" }), _jsx(PasswordChangeForm, { onSubmit: handlePasswordChange }), _jsx(Divider, { sx: { my: 3 } }), _jsx(Typography, { variant: "h6", gutterBottom: true, children: "Social logins" }), _jsx(Typography, { variant: "body2", sx: { mb: 1 }, children: "Sign in using a connected Google or Microsoft account." }), _jsx(SocialLoginButtons, { onProviderClick: handleSocialClick }), _jsx(Divider, { sx: { my: 3 } }), _jsx(PasskeysComponent, {}), _jsx(Divider, { sx: { my: 3 } }), _jsx(MFAComponent, {})] }));
|
|
38
39
|
};
|
|
39
40
|
export default SecurityComponent;
|
package/dist/pages/LoginPage.js
CHANGED
|
@@ -1,70 +1,84 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// src/pages/LoginPage.jsx
|
|
2
|
+
// src/pages/LoginPage.jsx
|
|
3
3
|
import React, { useState, useContext } from 'react';
|
|
4
4
|
import { useNavigate } from 'react-router-dom';
|
|
5
5
|
import { Helmet } from 'react-helmet';
|
|
6
|
-
import { Typography } from '@mui/material';
|
|
6
|
+
import { Typography, Box, TextField, Button, Stack, Alert } from '@mui/material';
|
|
7
7
|
import { NarrowPage } from '../layout/PageLayout';
|
|
8
8
|
import { AuthContext } from '../auth/AuthContext';
|
|
9
|
-
import { authApi } from '../auth/authApi';
|
|
9
|
+
import { authApi, loginWithPasskey } from '../auth/authApi'; // loginWithPasskey direkt importieren oder via authApi
|
|
10
10
|
import LoginForm from '../components/LoginForm';
|
|
11
11
|
export function LoginPage() {
|
|
12
12
|
const navigate = useNavigate();
|
|
13
13
|
const { login } = useContext(AuthContext);
|
|
14
|
+
// States für den Ablauf
|
|
15
|
+
const [step, setStep] = useState('credentials'); // 'credentials' | 'mfa'
|
|
14
16
|
const [submitting, setSubmitting] = useState(false);
|
|
15
17
|
const [error, setError] = useState('');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
// Für den MFA Schritt
|
|
19
|
+
const [mfaTypes, setMfaTypes] = useState([]); // z.B. ['webauthn', 'totp']
|
|
20
|
+
const [totpCode, setTotpCode] = useState('');
|
|
21
|
+
// SCHRITT 1: Email & Passwort senden
|
|
22
|
+
const handleSubmitCredentials = async ({ identifier, password }) => {
|
|
18
23
|
setError('');
|
|
19
24
|
setSubmitting(true);
|
|
20
25
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const result = await authApi.loginWithPassword(identifier, password);
|
|
27
|
+
if (result.needsMfa) {
|
|
28
|
+
// Passwort war korrekt, aber MFA wird benötigt -> Wechsel zu Schritt 2
|
|
29
|
+
setMfaTypes(result.availableTypes);
|
|
30
|
+
setStep('mfa');
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// Login sofort erfolgreich (kein MFA eingerichtet)
|
|
34
|
+
login(result.user);
|
|
35
|
+
navigate('/');
|
|
36
|
+
}
|
|
24
37
|
}
|
|
25
38
|
catch (err) {
|
|
26
|
-
setError((
|
|
27
|
-
(err === null || err === void 0 ? void 0 : err.message) ||
|
|
28
|
-
'Login failed.');
|
|
39
|
+
setError((err === null || err === void 0 ? void 0 : err.message) || 'Login failed.');
|
|
29
40
|
}
|
|
30
41
|
finally {
|
|
31
42
|
setSubmitting(false);
|
|
32
43
|
}
|
|
33
44
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const handleSocialLogin = (providerKey) => {
|
|
45
|
+
// SCHRITT 2a: TOTP Code senden
|
|
46
|
+
const handleMfaTotpSubmit = async (e) => {
|
|
47
|
+
e.preventDefault();
|
|
38
48
|
setError('');
|
|
49
|
+
setSubmitting(true);
|
|
39
50
|
try {
|
|
40
|
-
authApi.
|
|
51
|
+
const data = await authApi.authenticateWithMFA({ code: totpCode });
|
|
52
|
+
login(data.user || await authApi.fetchCurrentUser());
|
|
53
|
+
navigate('/');
|
|
41
54
|
}
|
|
42
55
|
catch (err) {
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
setError((err === null || err === void 0 ? void 0 : err.message) || 'Invalid code.');
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
setSubmitting(false);
|
|
45
60
|
}
|
|
46
61
|
};
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
// SCHRITT 2b: Passkey als 2. Faktor nutzen
|
|
63
|
+
const handleMfaPasskey = async () => {
|
|
49
64
|
setError('');
|
|
50
|
-
setSubmitting(true);
|
|
51
65
|
try {
|
|
52
|
-
|
|
66
|
+
// Wir nutzen die existierende Passkey-Login Funktion.
|
|
67
|
+
// Allauth ist schlau genug: Wenn die Session im "MFA-Pending" Status ist,
|
|
68
|
+
// akzeptiert der /auth/webauthn/login Endpoint den Passkey als 2. Faktor.
|
|
69
|
+
const { user } = await authApi.loginWithPasskey();
|
|
53
70
|
login(user);
|
|
54
71
|
navigate('/');
|
|
55
72
|
}
|
|
56
73
|
catch (err) {
|
|
57
|
-
setError(
|
|
58
|
-
(err === null || err === void 0 ? void 0 : err.message) ||
|
|
59
|
-
'Passkey login failed.');
|
|
60
|
-
}
|
|
61
|
-
finally {
|
|
62
|
-
setSubmitting(false);
|
|
74
|
+
setError('Passkey verification failed.');
|
|
63
75
|
}
|
|
64
76
|
};
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
};
|
|
68
|
-
|
|
77
|
+
// ... (Social & Sign Up Handler bleiben gleich) ...
|
|
78
|
+
const handleSocialLogin = (provider) => authApi.startSocialLogin(provider);
|
|
79
|
+
const handlePasskeyLoginInitial = async () => { };
|
|
80
|
+
const handleSignUp = () => navigate('/signup');
|
|
81
|
+
const handleForgotPassword = () => navigate('/reset-request-password');
|
|
82
|
+
return (_jsxs(NarrowPage, { title: "Login", children: [_jsx(Helmet, { children: _jsx("title", { children: "Login" }) }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: error }), step === 'credentials' && (_jsx(LoginForm, { onSubmit: handleSubmitCredentials, onForgotPassword: handleForgotPassword, onSocialLogin: handleSocialLogin, onPasskeyLogin: handlePasskeyLoginInitial, onSignUp: handleSignUp, disabled: submitting })), step === 'mfa' && (_jsxs(Box, { children: [_jsx(Typography, { variant: "body1", gutterBottom: true, children: "Two-Factor Authentication required." }), _jsxs(Stack, { spacing: 2, sx: { mt: 2 }, children: [mfaTypes.includes('webauthn') && (_jsx(Button, { variant: "outlined", onClick: handleMfaPasskey, fullWidth: true, children: "Use Passkey / Security Key" })), (mfaTypes.includes('totp') || mfaTypes.includes('recovery_codes')) && (_jsxs("form", { onSubmit: handleMfaTotpSubmit, children: [_jsx(TextField, { label: "Authenticator Code (or Recovery Code)", value: totpCode, onChange: (e) => setTotpCode(e.target.value), fullWidth: true, autoFocus: true, disabled: submitting, sx: { mb: 2 } }), _jsx(Button, { type: "submit", variant: "contained", fullWidth: true, disabled: submitting, children: "Verify" })] })), _jsx(Button, { size: "small", onClick: () => setStep('credentials'), children: "Back to Login" })] })] }))] }));
|
|
69
83
|
}
|
|
70
84
|
export default LoginPage;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micha.bigler/ui-core-micha",
|
|
3
|
-
"version": "1.2
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"private": false,
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
"@emotion/styled": "^11.0.0",
|
|
10
10
|
"@mui/material": "^7.3.5",
|
|
11
11
|
"axios": "^1.0.0",
|
|
12
|
-
"react": "^
|
|
12
|
+
"react": "^19.2.0",
|
|
13
13
|
"react-dom": "^18.0.0",
|
|
14
14
|
"react-helmet": "^6.0.0",
|
|
15
|
-
"react-router-dom": "^6.0.0"
|
|
15
|
+
"react-router-dom": "^6.0.0",
|
|
16
|
+
"qrcode.react": "^4.2.0"
|
|
17
|
+
|
|
16
18
|
},
|
|
17
19
|
"scripts": {
|
|
18
20
|
"build": "tsc -p tsconfig.build.json"
|
package/src/auth/authApi.jsx
CHANGED
|
@@ -135,6 +135,7 @@ export async function updateUserProfile(data) {
|
|
|
135
135
|
// -----------------------------
|
|
136
136
|
// Authentication: password
|
|
137
137
|
// -----------------------------
|
|
138
|
+
/*
|
|
138
139
|
export async function loginWithPassword(email, password) {
|
|
139
140
|
try {
|
|
140
141
|
await axios.post(
|
|
@@ -153,7 +154,7 @@ export async function loginWithPassword(email, password) {
|
|
|
153
154
|
const user = await fetchCurrentUser();
|
|
154
155
|
return { user };
|
|
155
156
|
}
|
|
156
|
-
|
|
157
|
+
*/
|
|
157
158
|
export async function requestPasswordReset(email) {
|
|
158
159
|
try {
|
|
159
160
|
await axios.post(
|
|
@@ -459,6 +460,123 @@ export async function deletePasskey(id) {
|
|
|
459
460
|
);
|
|
460
461
|
}
|
|
461
462
|
|
|
463
|
+
// -----------------------------
|
|
464
|
+
// Authentication: MFA Step
|
|
465
|
+
// -----------------------------
|
|
466
|
+
export async function authenticateWithMFA({ code, credential }) {
|
|
467
|
+
// Entweder 'code' (TOTP/Recovery) oder 'credential' (Passkey) senden
|
|
468
|
+
const payload = {};
|
|
469
|
+
if (code) payload.code = code;
|
|
470
|
+
if (credential) payload.credential = credential;
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const res = await axios.post(
|
|
474
|
+
`${HEADLESS_BASE}/auth/mfa/authenticate`, // Endpoint für Schritt 2
|
|
475
|
+
payload,
|
|
476
|
+
{ withCredentials: true }
|
|
477
|
+
);
|
|
478
|
+
return res.data; // Enthält User-Objekt bei Erfolg
|
|
479
|
+
} catch (error) {
|
|
480
|
+
throw new Error(extractErrorMessage(error));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// -----------------------------
|
|
485
|
+
// Authentication: password (MODIFIZIERT)
|
|
486
|
+
// -----------------------------
|
|
487
|
+
export async function loginWithPassword(email, password) {
|
|
488
|
+
try {
|
|
489
|
+
await axios.post(
|
|
490
|
+
`${HEADLESS_BASE}/auth/login`,
|
|
491
|
+
{ email, password },
|
|
492
|
+
{ withCredentials: true }
|
|
493
|
+
);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
// SKEPTISCHER CHECK: Ist es der MFA-Flow?
|
|
496
|
+
if (
|
|
497
|
+
error.response &&
|
|
498
|
+
error.response.status === 401 &&
|
|
499
|
+
error.response.data?.flow === 'mfa_authenticate'
|
|
500
|
+
) {
|
|
501
|
+
// KEIN FEHLER! Wir geben zurück, dass MFA nötig ist.
|
|
502
|
+
return {
|
|
503
|
+
needsMfa: true,
|
|
504
|
+
availableTypes: error.response.data.types || [], // z.B. ["webauthn", "totp"]
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Bestehende Logik für "Already logged in"
|
|
509
|
+
if (error.response && error.response.status === 409) {
|
|
510
|
+
// continue to fetch user
|
|
511
|
+
} else {
|
|
512
|
+
throw new Error(extractErrorMessage(error));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Wenn wir hier landen, war kein MFA nötig oder wir waren schon eingeloggt
|
|
517
|
+
const user = await fetchCurrentUser();
|
|
518
|
+
return { user, needsMfa: false };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// 1. Status prüfen (Liste der aktiven Authenticators)
|
|
522
|
+
export async function fetchAuthenticators() {
|
|
523
|
+
const res = await axios.get(`${HEADLESS_BASE}/mfa/authenticators`, {
|
|
524
|
+
withCredentials: true,
|
|
525
|
+
});
|
|
526
|
+
return res.data; // Array von Objekten, z.B. [{ type: 'totp', ... }, { type: 'webauthn' }]
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 2. TOTP Einrichtung starten (liefert Secret & QR-URL)
|
|
530
|
+
export async function requestTotpKey() {
|
|
531
|
+
// GET /mfa/totp/key liefert das Secret für die Einrichtung
|
|
532
|
+
const res = await axios.get(`${HEADLESS_BASE}/mfa/totp/key`, {
|
|
533
|
+
withCredentials: true,
|
|
534
|
+
});
|
|
535
|
+
return res.data; // { secret: "...", key_uri: "otpauth://..." }
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 3. TOTP Einrichtung abschließen (Code verifizieren)
|
|
539
|
+
export async function activateTotp(code) {
|
|
540
|
+
const res = await axios.post(
|
|
541
|
+
`${HEADLESS_BASE}/mfa/totp/key`,
|
|
542
|
+
{ code },
|
|
543
|
+
{ withCredentials: true }
|
|
544
|
+
);
|
|
545
|
+
return res.data;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// 4. TOTP deaktivieren
|
|
549
|
+
export async function deactivateTotp(id) {
|
|
550
|
+
// Manche Implementierungen nutzen DELETE auf /mfa/totp, andere auf den Authenticator-ID endpoint.
|
|
551
|
+
// Wir nutzen hier den allgemeinen Authenticator-Delete Endpoint, wenn wir die ID haben.
|
|
552
|
+
// Alternativ: DELETE /mfa/totp
|
|
553
|
+
const res = await axios.delete(`${HEADLESS_BASE}/mfa/authenticators/${id}`, {
|
|
554
|
+
withCredentials: true,
|
|
555
|
+
});
|
|
556
|
+
return res.data;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// -----------------------------
|
|
560
|
+
// MFA: Recovery Codes
|
|
561
|
+
// -----------------------------
|
|
562
|
+
|
|
563
|
+
export async function fetchRecoveryCodes() {
|
|
564
|
+
const res = await axios.get(`${HEADLESS_BASE}/mfa/recovery_codes`, {
|
|
565
|
+
withCredentials: true,
|
|
566
|
+
});
|
|
567
|
+
return res.data; // { codes: [...] }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export async function generateRecoveryCodes() {
|
|
571
|
+
const res = await axios.post(
|
|
572
|
+
`${HEADLESS_BASE}/mfa/recovery_codes`,
|
|
573
|
+
{},
|
|
574
|
+
{ withCredentials: true }
|
|
575
|
+
);
|
|
576
|
+
return res.data; // { codes: [...] }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
462
580
|
// -----------------------------
|
|
463
581
|
// Aggregated API object
|
|
464
582
|
// -----------------------------
|
|
@@ -477,6 +595,13 @@ export const authApi = {
|
|
|
477
595
|
registerPasskey,
|
|
478
596
|
fetchPasskeys,
|
|
479
597
|
deletePasskey,
|
|
598
|
+
authenticateWithMFA,
|
|
599
|
+
fetchAuthenticators,
|
|
600
|
+
requestTotpKey,
|
|
601
|
+
activateTotp,
|
|
602
|
+
deactivateTotp,
|
|
603
|
+
fetchRecoveryCodes,
|
|
604
|
+
generateRecoveryCodes,
|
|
480
605
|
validateAccessCode,
|
|
481
606
|
requestInviteWithCode,
|
|
482
607
|
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// src/auth/components/MFAComponent.jsx
|
|
2
|
+
import React, { useState, useEffect } from 'react';
|
|
3
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
4
|
+
import {
|
|
5
|
+
Box,
|
|
6
|
+
Typography,
|
|
7
|
+
Button,
|
|
8
|
+
TextField,
|
|
9
|
+
Card,
|
|
10
|
+
CardContent,
|
|
11
|
+
Alert,
|
|
12
|
+
CircularProgress,
|
|
13
|
+
Stack,
|
|
14
|
+
Divider,
|
|
15
|
+
IconButton,
|
|
16
|
+
Tooltip,
|
|
17
|
+
} from '@mui/material';
|
|
18
|
+
import DeleteIcon from '@mui/icons-material/Delete';
|
|
19
|
+
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
20
|
+
import { authApi } from '../auth/authApi';
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
const MFAComponent = () => {
|
|
24
|
+
const [authenticators, setAuthenticators] = useState([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState('');
|
|
27
|
+
|
|
28
|
+
// Setup State
|
|
29
|
+
const [isSettingUp, setIsSettingUp] = useState(false);
|
|
30
|
+
const [totpData, setTotpData] = useState(null); // { secret, key_uri }
|
|
31
|
+
const [verifyCode, setVerifyCode] = useState('');
|
|
32
|
+
const [submitting, setSubmitting] = useState(false);
|
|
33
|
+
|
|
34
|
+
// Recovery Codes State
|
|
35
|
+
const [recoveryCodes, setRecoveryCodes] = useState([]);
|
|
36
|
+
const [showRecovery, setShowRecovery] = useState(false);
|
|
37
|
+
|
|
38
|
+
const loadData = async () => {
|
|
39
|
+
setLoading(true);
|
|
40
|
+
setError('');
|
|
41
|
+
try {
|
|
42
|
+
// Lade Authenticators (TOTP, WebAuthn, etc.)
|
|
43
|
+
const data = await authApi.fetchAuthenticators();
|
|
44
|
+
setAuthenticators(Array.isArray(data) ? data : []);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Ignorieren wir 404/Empty falls noch nichts da ist, sonst Error
|
|
47
|
+
console.error(err);
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
loadData();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const totpAuthenticator = authenticators.find((a) => a.type === 'totp');
|
|
58
|
+
|
|
59
|
+
// --- HANDLER: TOTP START ---
|
|
60
|
+
const handleStartSetup = async () => {
|
|
61
|
+
setError('');
|
|
62
|
+
setIsSettingUp(true);
|
|
63
|
+
try {
|
|
64
|
+
const data = await authApi.requestTotpKey();
|
|
65
|
+
setTotpData(data);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError('Could not fetch setup key.');
|
|
68
|
+
setIsSettingUp(false);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// --- HANDLER: TOTP VERIFY ---
|
|
73
|
+
const handleVerify = async (e) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
setSubmitting(true);
|
|
76
|
+
setError('');
|
|
77
|
+
try {
|
|
78
|
+
await authApi.activateTotp(verifyCode);
|
|
79
|
+
setIsSettingUp(false);
|
|
80
|
+
setVerifyCode('');
|
|
81
|
+
setTotpData(null);
|
|
82
|
+
// Neu laden, um den neuen Status zu sehen
|
|
83
|
+
await loadData();
|
|
84
|
+
// Automatisch Recovery Codes anzeigen (Best Practice!)
|
|
85
|
+
handleShowRecoveryCodes();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
setError('Invalid code. Please try again.');
|
|
88
|
+
} finally {
|
|
89
|
+
setSubmitting(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// --- HANDLER: TOTP DELETE ---
|
|
94
|
+
const handleDeleteTotp = async () => {
|
|
95
|
+
if (!window.confirm('Disable Authenticator App? Your security will be reduced.')) return;
|
|
96
|
+
try {
|
|
97
|
+
if (totpAuthenticator?.id) {
|
|
98
|
+
await authApi.deactivateTotp(totpAuthenticator.id);
|
|
99
|
+
}
|
|
100
|
+
await loadData();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
setError('Could not deactivate TOTP.');
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// --- HANDLER: RECOVERY CODES ---
|
|
107
|
+
const handleShowRecoveryCodes = async () => {
|
|
108
|
+
try {
|
|
109
|
+
// Versuch, existierende zu laden
|
|
110
|
+
let data = await authApi.fetchRecoveryCodes();
|
|
111
|
+
// Wenn keine existieren (oft leer beim ersten Mal), generiere neue
|
|
112
|
+
if (!data.codes || data.codes.length === 0) {
|
|
113
|
+
data = await authApi.generateRecoveryCodes();
|
|
114
|
+
}
|
|
115
|
+
setRecoveryCodes(data.codes);
|
|
116
|
+
setShowRecovery(true);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setError('Could not load recovery codes.');
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleCopyCode = (code) => {
|
|
123
|
+
navigator.clipboard.writeText(code);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (loading) return <CircularProgress />;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<Box>
|
|
130
|
+
<Typography variant="h6" gutterBottom>Authenticator App (TOTP)</Typography>
|
|
131
|
+
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
132
|
+
Use an app like Google Authenticator or Microsoft Authenticator to generate verification codes.
|
|
133
|
+
</Typography>
|
|
134
|
+
|
|
135
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
136
|
+
|
|
137
|
+
{/* --- CASE 1: TOTP IS ACTIVE --- */}
|
|
138
|
+
{totpAuthenticator ? (
|
|
139
|
+
<Card variant="outlined" sx={{ mb: 3, bgcolor: '#f0fdf4' }}>
|
|
140
|
+
<CardContent sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
141
|
+
<Box>
|
|
142
|
+
<Typography variant="subtitle1" color="success.main" fontWeight="bold">
|
|
143
|
+
Active
|
|
144
|
+
</Typography>
|
|
145
|
+
<Typography variant="caption" color="text.secondary">
|
|
146
|
+
Created: {new Date(totpAuthenticator.created_at * 1000).toLocaleDateString()}
|
|
147
|
+
</Typography>
|
|
148
|
+
</Box>
|
|
149
|
+
<Button
|
|
150
|
+
variant="outlined"
|
|
151
|
+
color="error"
|
|
152
|
+
startIcon={<DeleteIcon />}
|
|
153
|
+
onClick={handleDeleteTotp}
|
|
154
|
+
>
|
|
155
|
+
Disable
|
|
156
|
+
</Button>
|
|
157
|
+
</CardContent>
|
|
158
|
+
</Card>
|
|
159
|
+
) : (
|
|
160
|
+
/* --- CASE 2: TOTP NOT ACTIVE --- */
|
|
161
|
+
!isSettingUp && (
|
|
162
|
+
<Button variant="contained" onClick={handleStartSetup}>
|
|
163
|
+
Set up Authenticator App
|
|
164
|
+
</Button>
|
|
165
|
+
)
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* --- WIZARD: SETUP --- */}
|
|
169
|
+
{isSettingUp && totpData && (
|
|
170
|
+
<Card variant="outlined" sx={{ mb: 3 }}>
|
|
171
|
+
<CardContent>
|
|
172
|
+
<Typography variant="subtitle1" gutterBottom>Scan this QR Code</Typography>
|
|
173
|
+
|
|
174
|
+
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} alignItems="center">
|
|
175
|
+
{/* QR CODE */}
|
|
176
|
+
<Box sx={{ p: 2, bgcolor: 'white', border: '1px solid #eee' }}>
|
|
177
|
+
<QRCodeSVG value={totpData.key_uri} size={150} />
|
|
178
|
+
</Box>
|
|
179
|
+
|
|
180
|
+
{/* MANUAL ENTRY & VERIFY */}
|
|
181
|
+
<Box sx={{ flex: 1 }}>
|
|
182
|
+
<Typography variant="body2" gutterBottom>
|
|
183
|
+
Can't scan? Enter this key manually:
|
|
184
|
+
</Typography>
|
|
185
|
+
<Typography variant="mono" sx={{ fontFamily: 'monospace', bgcolor: '#eee', p: 1, borderRadius: 1, display: 'inline-block', mb: 2 }}>
|
|
186
|
+
{totpData.secret}
|
|
187
|
+
</Typography>
|
|
188
|
+
|
|
189
|
+
<form onSubmit={handleVerify}>
|
|
190
|
+
<TextField
|
|
191
|
+
label="Verification Code (6 digits)"
|
|
192
|
+
value={verifyCode}
|
|
193
|
+
onChange={(e) => setVerifyCode(e.target.value)}
|
|
194
|
+
fullWidth
|
|
195
|
+
required
|
|
196
|
+
sx={{ mb: 2 }}
|
|
197
|
+
autoComplete="off"
|
|
198
|
+
/>
|
|
199
|
+
<Stack direction="row" spacing={1}>
|
|
200
|
+
<Button type="submit" variant="contained" disabled={submitting}>
|
|
201
|
+
{submitting ? 'Verifying...' : 'Verify & Activate'}
|
|
202
|
+
</Button>
|
|
203
|
+
<Button onClick={() => setIsSettingUp(false)} disabled={submitting}>
|
|
204
|
+
Cancel
|
|
205
|
+
</Button>
|
|
206
|
+
</Stack>
|
|
207
|
+
</form>
|
|
208
|
+
</Box>
|
|
209
|
+
</Stack>
|
|
210
|
+
</CardContent>
|
|
211
|
+
</Card>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
<Divider sx={{ my: 3 }} />
|
|
215
|
+
|
|
216
|
+
{/* --- RECOVERY CODES --- */}
|
|
217
|
+
<Typography variant="h6" gutterBottom>Recovery Codes</Typography>
|
|
218
|
+
<Typography variant="body2" sx={{ mb: 2 }}>
|
|
219
|
+
If you lose your device, these codes are the only way to access your account.
|
|
220
|
+
</Typography>
|
|
221
|
+
|
|
222
|
+
{!showRecovery ? (
|
|
223
|
+
<Button variant="outlined" onClick={handleShowRecoveryCodes}>
|
|
224
|
+
View/Generate Recovery Codes
|
|
225
|
+
</Button>
|
|
226
|
+
) : (
|
|
227
|
+
<Box sx={{ bgcolor: '#f5f5f5', p: 2, borderRadius: 1 }}>
|
|
228
|
+
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
229
|
+
Save these codes in a safe place (e.g., password manager). Each code can be used only once.
|
|
230
|
+
</Alert>
|
|
231
|
+
<Stack direction="row" flexWrap="wrap" gap={2}>
|
|
232
|
+
{recoveryCodes.map((code) => (
|
|
233
|
+
<Box
|
|
234
|
+
key={code}
|
|
235
|
+
sx={{
|
|
236
|
+
bgcolor: 'white',
|
|
237
|
+
border: '1px solid #ddd',
|
|
238
|
+
p: 1,
|
|
239
|
+
borderRadius: 1,
|
|
240
|
+
fontFamily: 'monospace',
|
|
241
|
+
display: 'flex',
|
|
242
|
+
alignItems: 'center',
|
|
243
|
+
gap: 1
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{code}
|
|
247
|
+
<Tooltip title="Copy">
|
|
248
|
+
<IconButton size="small" onClick={() => handleCopyCode(code)}>
|
|
249
|
+
<ContentCopyIcon fontSize="small" />
|
|
250
|
+
</IconButton>
|
|
251
|
+
</Tooltip>
|
|
252
|
+
</Box>
|
|
253
|
+
))}
|
|
254
|
+
</Stack>
|
|
255
|
+
<Button sx={{ mt: 2 }} size="small" onClick={() => authApi.generateRecoveryCodes().then(d => setRecoveryCodes(d.codes))}>
|
|
256
|
+
Generate New Codes
|
|
257
|
+
</Button>
|
|
258
|
+
</Box>
|
|
259
|
+
)}
|
|
260
|
+
</Box>
|
|
261
|
+
);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export default MFAComponent;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import PasswordChangeForm from './PasswordChangeForm';
|
|
10
10
|
import SocialLoginButtons from './SocialLoginButtons';
|
|
11
11
|
import PasskeysComponent from './PasskeysComponent'; // <--- WICHTIG
|
|
12
|
+
import MFAComponent from './MFAComponent';
|
|
12
13
|
import { authApi } from '../auth/authApi';
|
|
13
14
|
|
|
14
15
|
const SecurityComponent = () => {
|
|
@@ -74,6 +75,10 @@ const SecurityComponent = () => {
|
|
|
74
75
|
|
|
75
76
|
{/* Passkeys Section */}
|
|
76
77
|
<PasskeysComponent />
|
|
78
|
+
|
|
79
|
+
<Divider sx={{ my: 3 }} />
|
|
80
|
+
|
|
81
|
+
<MFAComponent />
|
|
77
82
|
</Box>
|
|
78
83
|
);
|
|
79
84
|
};
|
package/src/pages/LoginPage.jsx
CHANGED
|
@@ -1,96 +1,143 @@
|
|
|
1
|
-
// src/pages/LoginPage.jsx
|
|
1
|
+
// src/pages/LoginPage.jsx
|
|
2
2
|
import React, { useState, useContext } from 'react';
|
|
3
3
|
import { useNavigate } from 'react-router-dom';
|
|
4
4
|
import { Helmet } from 'react-helmet';
|
|
5
|
-
import { Typography } from '@mui/material';
|
|
5
|
+
import { Typography, Box, TextField, Button, Stack, Alert } from '@mui/material';
|
|
6
6
|
import { NarrowPage } from '../layout/PageLayout';
|
|
7
7
|
import { AuthContext } from '../auth/AuthContext';
|
|
8
|
-
import { authApi } from '../auth/authApi';
|
|
8
|
+
import { authApi, loginWithPasskey } from '../auth/authApi'; // loginWithPasskey direkt importieren oder via authApi
|
|
9
9
|
import LoginForm from '../components/LoginForm';
|
|
10
10
|
|
|
11
11
|
export function LoginPage() {
|
|
12
12
|
const navigate = useNavigate();
|
|
13
13
|
const { login } = useContext(AuthContext);
|
|
14
|
+
|
|
15
|
+
// States für den Ablauf
|
|
16
|
+
const [step, setStep] = useState('credentials'); // 'credentials' | 'mfa'
|
|
14
17
|
const [submitting, setSubmitting] = useState(false);
|
|
15
18
|
const [error, setError] = useState('');
|
|
19
|
+
|
|
20
|
+
// Für den MFA Schritt
|
|
21
|
+
const [mfaTypes, setMfaTypes] = useState([]); // z.B. ['webauthn', 'totp']
|
|
22
|
+
const [totpCode, setTotpCode] = useState('');
|
|
16
23
|
|
|
17
|
-
|
|
24
|
+
// SCHRITT 1: Email & Passwort senden
|
|
25
|
+
const handleSubmitCredentials = async ({ identifier, password }) => {
|
|
18
26
|
setError('');
|
|
19
27
|
setSubmitting(true);
|
|
20
28
|
try {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
const result = await authApi.loginWithPassword(identifier, password);
|
|
30
|
+
|
|
31
|
+
if (result.needsMfa) {
|
|
32
|
+
// Passwort war korrekt, aber MFA wird benötigt -> Wechsel zu Schritt 2
|
|
33
|
+
setMfaTypes(result.availableTypes);
|
|
34
|
+
setStep('mfa');
|
|
35
|
+
} else {
|
|
36
|
+
// Login sofort erfolgreich (kein MFA eingerichtet)
|
|
37
|
+
login(result.user);
|
|
38
|
+
navigate('/');
|
|
39
|
+
}
|
|
24
40
|
} catch (err) {
|
|
25
|
-
setError(
|
|
26
|
-
err?.response?.data?.detail ||
|
|
27
|
-
err?.message ||
|
|
28
|
-
'Login failed.',
|
|
29
|
-
);
|
|
41
|
+
setError(err?.message || 'Login failed.');
|
|
30
42
|
} finally {
|
|
31
43
|
setSubmitting(false);
|
|
32
44
|
}
|
|
33
45
|
};
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const handleSocialLogin = (providerKey) => {
|
|
47
|
+
// SCHRITT 2a: TOTP Code senden
|
|
48
|
+
const handleMfaTotpSubmit = async (e) => {
|
|
49
|
+
e.preventDefault();
|
|
40
50
|
setError('');
|
|
51
|
+
setSubmitting(true);
|
|
41
52
|
try {
|
|
42
|
-
authApi.
|
|
53
|
+
const data = await authApi.authenticateWithMFA({ code: totpCode });
|
|
54
|
+
login(data.user || await authApi.fetchCurrentUser());
|
|
55
|
+
navigate('/');
|
|
43
56
|
} catch (err) {
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
setError(err?.message || 'Invalid code.');
|
|
58
|
+
} finally {
|
|
59
|
+
setSubmitting(false);
|
|
46
60
|
}
|
|
47
61
|
};
|
|
48
62
|
|
|
49
|
-
|
|
63
|
+
// SCHRITT 2b: Passkey als 2. Faktor nutzen
|
|
64
|
+
const handleMfaPasskey = async () => {
|
|
50
65
|
setError('');
|
|
51
|
-
setSubmitting(true);
|
|
52
66
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
// Wir nutzen die existierende Passkey-Login Funktion.
|
|
68
|
+
// Allauth ist schlau genug: Wenn die Session im "MFA-Pending" Status ist,
|
|
69
|
+
// akzeptiert der /auth/webauthn/login Endpoint den Passkey als 2. Faktor.
|
|
70
|
+
const { user } = await authApi.loginWithPasskey();
|
|
71
|
+
login(user);
|
|
72
|
+
navigate('/');
|
|
56
73
|
} catch (err) {
|
|
57
|
-
|
|
58
|
-
err?.response?.data?.detail ||
|
|
59
|
-
err?.message ||
|
|
60
|
-
'Passkey login failed.',
|
|
61
|
-
);
|
|
62
|
-
} finally {
|
|
63
|
-
setSubmitting(false);
|
|
74
|
+
setError('Passkey verification failed.');
|
|
64
75
|
}
|
|
65
76
|
};
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
78
|
+
// ... (Social & Sign Up Handler bleiben gleich) ...
|
|
79
|
+
const handleSocialLogin = (provider) => authApi.startSocialLogin(provider);
|
|
80
|
+
const handlePasskeyLoginInitial = async () => { /* wie gehabt für Step 1 */ };
|
|
81
|
+
const handleSignUp = () => navigate('/signup');
|
|
82
|
+
const handleForgotPassword = () => navigate('/reset-request-password');
|
|
70
83
|
|
|
71
84
|
return (
|
|
72
85
|
<NarrowPage title="Login">
|
|
73
|
-
<Helmet>
|
|
74
|
-
<title>PROJECT_NAME – Login</title>
|
|
75
|
-
</Helmet>
|
|
86
|
+
<Helmet><title>Login</title></Helmet>
|
|
76
87
|
|
|
77
|
-
{error &&
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
|
89
|
+
|
|
90
|
+
{step === 'credentials' && (
|
|
91
|
+
<LoginForm
|
|
92
|
+
onSubmit={handleSubmitCredentials}
|
|
93
|
+
onForgotPassword={handleForgotPassword}
|
|
94
|
+
onSocialLogin={handleSocialLogin}
|
|
95
|
+
onPasskeyLogin={handlePasskeyLoginInitial} // Passkey als 1. Faktor
|
|
96
|
+
onSignUp={handleSignUp}
|
|
97
|
+
disabled={submitting}
|
|
98
|
+
/>
|
|
81
99
|
)}
|
|
82
100
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
101
|
+
{step === 'mfa' && (
|
|
102
|
+
<Box>
|
|
103
|
+
<Typography variant="body1" gutterBottom>
|
|
104
|
+
Two-Factor Authentication required.
|
|
105
|
+
</Typography>
|
|
106
|
+
|
|
107
|
+
<Stack spacing={2} sx={{ mt: 2 }}>
|
|
108
|
+
{/* OPTION A: Passkey Button (wenn verfügbar) */}
|
|
109
|
+
{mfaTypes.includes('webauthn') && (
|
|
110
|
+
<Button variant="outlined" onClick={handleMfaPasskey} fullWidth>
|
|
111
|
+
Use Passkey / Security Key
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* OPTION B: TOTP Input (wenn verfügbar) */}
|
|
116
|
+
{(mfaTypes.includes('totp') || mfaTypes.includes('recovery_codes')) && (
|
|
117
|
+
<form onSubmit={handleMfaTotpSubmit}>
|
|
118
|
+
<TextField
|
|
119
|
+
label="Authenticator Code (or Recovery Code)"
|
|
120
|
+
value={totpCode}
|
|
121
|
+
onChange={(e) => setTotpCode(e.target.value)}
|
|
122
|
+
fullWidth
|
|
123
|
+
autoFocus
|
|
124
|
+
disabled={submitting}
|
|
125
|
+
sx={{ mb: 2 }}
|
|
126
|
+
/>
|
|
127
|
+
<Button type="submit" variant="contained" fullWidth disabled={submitting}>
|
|
128
|
+
Verify
|
|
129
|
+
</Button>
|
|
130
|
+
</form>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<Button size="small" onClick={() => setStep('credentials')}>
|
|
134
|
+
Back to Login
|
|
135
|
+
</Button>
|
|
136
|
+
</Stack>
|
|
137
|
+
</Box>
|
|
138
|
+
)}
|
|
92
139
|
</NarrowPage>
|
|
93
140
|
);
|
|
94
141
|
}
|
|
95
142
|
|
|
96
|
-
export default LoginPage;
|
|
143
|
+
export default LoginPage;
|