@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.
@@ -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
- try {
120
- await axios.post(`${HEADLESS_BASE}/auth/login`, { email, password }, { withCredentials: true });
121
- }
122
- catch (error) {
123
- if (error.response && error.response.status === 409) {
124
- // Already logged in: continue and fetch current user
125
- }
126
- else {
127
- throw new Error(extractErrorMessage(error));
128
- }
129
- }
130
- const user = await fetchCurrentUser();
131
- return { user };
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;
@@ -1,70 +1,84 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // src/pages/LoginPage.jsx (oder wo deine LoginPage liegt)
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
- const handleSubmit = async ({ identifier, password }) => {
17
- var _a, _b;
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 data = await authApi.loginWithPassword(identifier, password);
22
- login(data.user);
23
- navigate('/');
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(((_b = (_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.detail) ||
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
- const handleForgotPassword = () => {
35
- navigate('/reset-request-password');
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.startSocialLogin(providerKey);
51
+ const data = await authApi.authenticateWithMFA({ code: totpCode });
52
+ login(data.user || await authApi.fetchCurrentUser());
53
+ navigate('/');
41
54
  }
42
55
  catch (err) {
43
- console.error('Social login init failed', err);
44
- setError('Could not start social login.');
56
+ setError((err === null || err === void 0 ? void 0 : err.message) || 'Invalid code.');
57
+ }
58
+ finally {
59
+ setSubmitting(false);
45
60
  }
46
61
  };
47
- const handlePasskeyLogin = async () => {
48
- var _a, _b;
62
+ // SCHRITT 2b: Passkey als 2. Faktor nutzen
63
+ const handleMfaPasskey = async () => {
49
64
  setError('');
50
- setSubmitting(true);
51
65
  try {
52
- const { user } = await authApi.loginWithPasskey(); // API muss natürlich implementiert sein
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(((_b = (_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.detail) ||
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
- const handleSignUp = () => {
66
- navigate('/signup');
67
- };
68
- return (_jsxs(NarrowPage, { title: "Login", children: [_jsx(Helmet, { children: _jsx("title", { children: "PROJECT_NAME \u2013 Login" }) }), error && (_jsx(Typography, { color: "error", gutterBottom: true, children: error })), _jsx(LoginForm, { onSubmit: handleSubmit, onForgotPassword: handleForgotPassword, onSocialLogin: handleSocialLogin, onPasskeyLogin: handlePasskeyLogin, onSignUp: handleSignUp, error: undefined, disabled: submitting })] }));
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.10",
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": "^18.0.0",
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"
@@ -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
  };
@@ -1,96 +1,143 @@
1
- // src/pages/LoginPage.jsx (oder wo deine LoginPage liegt)
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
- const handleSubmit = async ({ identifier, password }) => {
24
+ // SCHRITT 1: Email & Passwort senden
25
+ const handleSubmitCredentials = async ({ identifier, password }) => {
18
26
  setError('');
19
27
  setSubmitting(true);
20
28
  try {
21
- const data = await authApi.loginWithPassword(identifier, password);
22
- login(data.user);
23
- navigate('/');
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
- const handleForgotPassword = () => {
36
- navigate('/reset-request-password');
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.startSocialLogin(providerKey);
53
+ const data = await authApi.authenticateWithMFA({ code: totpCode });
54
+ login(data.user || await authApi.fetchCurrentUser());
55
+ navigate('/');
43
56
  } catch (err) {
44
- console.error('Social login init failed', err);
45
- setError('Could not start social login.');
57
+ setError(err?.message || 'Invalid code.');
58
+ } finally {
59
+ setSubmitting(false);
46
60
  }
47
61
  };
48
62
 
49
- const handlePasskeyLogin = async () => {
63
+ // SCHRITT 2b: Passkey als 2. Faktor nutzen
64
+ const handleMfaPasskey = async () => {
50
65
  setError('');
51
- setSubmitting(true);
52
66
  try {
53
- const { user } = await authApi.loginWithPasskey(); // API muss natürlich implementiert sein
54
- login(user);
55
- navigate('/');
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
- setError(
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
- const handleSignUp = () => {
68
- navigate('/signup');
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
- <Typography color="error" gutterBottom>
79
- {error}
80
- </Typography>
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
- <LoginForm
84
- onSubmit={handleSubmit}
85
- onForgotPassword={handleForgotPassword}
86
- onSocialLogin={handleSocialLogin}
87
- onPasskeyLogin={handlePasskeyLogin}
88
- onSignUp={handleSignUp}
89
- error={undefined}
90
- disabled={submitting}
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;