From 4475a45a2602ab13ceb69673d28927bb4dd8a3dc Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 3 Jun 2026 23:21:33 +0200 Subject: [PATCH] security and mfa --- src/api/authApi.ts | 51 +- .../DataSourceSettingsModal.tsx | 7 +- src/hooks/useAuthentication.ts | 75 ++- src/pages/Login.tsx | 477 ++++++++++++------ src/pages/RagInventoryPage.tsx | 23 +- src/pages/Settings.tsx | 192 ++++++- 6 files changed, 642 insertions(+), 183 deletions(-) diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 95ea26c..7c691c9 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -12,14 +12,30 @@ export interface LoginRequest { } export interface LoginResponse { - type: 'local_auth_success'; + type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required'; accessToken?: string; tokenType?: string; authenticationAuthority?: string; + mfaToken?: string; + provisioningUri?: string; label?: any; fieldLabels?: any; } +export interface MfaVerifyRequest { + token: string; + code: string; +} + +export interface MfaSetupResponse { + provisioningUri: string; +} + +export interface MfaStatusResponse { + mfaEnabled: boolean; + mfaRequired: boolean; +} + export interface RegisterData { username: string; email: string; @@ -316,3 +332,36 @@ export async function logoutApi(): Promise { await api.post('/api/local/logout'); } +// ============================================================================ +// MFA API FUNCTIONS +// ============================================================================ + +export async function mfaVerifyApi(data: MfaVerifyRequest): Promise { + const response = await api.post('/api/mfa/verify', data); + return response.data; +} + +export async function mfaSetupApi(): Promise { + const response = await api.post('/api/mfa/setup'); + return response.data; +} + +export async function mfaConfirmApi(code: string, token?: string): Promise<{ mfaEnabled: boolean }> { + if (token) { + const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm', { code, token }); + return response.data; + } + const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm-authenticated', { code }); + return response.data; +} + +export async function mfaStatusApi(): Promise { + const response = await api.get('/api/mfa/status'); + return response.data; +} + +export async function mfaDisableApi(code: string): Promise<{ mfaEnabled: boolean }> { + const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/disable', { code }); + return response.data; +} + diff --git a/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx b/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx index de18147..c70af12 100644 --- a/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx +++ b/src/components/UnifiedDataBar/DataSourceSettingsModal.tsx @@ -22,6 +22,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { FaTimes, FaToggleOn, FaToggleOff } from 'react-icons/fa'; import { useApiRequest } from '../../hooks/useApi'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { useConfirm } from '../../hooks/useConfirm'; import { patchDataSourceSettings, getDataSourceCostEstimate, @@ -90,6 +91,7 @@ export const DataSourceSettingsModal: React.FC = ({ }) => { const { t } = useLanguage(); const { request } = useApiRequest(); + const { confirm, ConfirmDialog } = useConfirm(); const [knowledgeOn, setKnowledgeOn] = useState(!!initialKnowledgeIngestionEnabled); const [ragLimits, setRagLimits] = useState(initialRagLimits || {}); @@ -131,7 +133,7 @@ export const DataSourceSettingsModal: React.FC = ({ if (!connectionId) return; const newValue = !knowledgeOn; if (!newValue) { - const ok = window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?')); + const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') }); if (!ok) return; } setSaving(true); @@ -179,6 +181,8 @@ export const DataSourceSettingsModal: React.FC = ({ }; return ( + <> +
= ({
+ ); }; diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index fb901ba..f724916 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -11,6 +11,7 @@ import { logoutApi, requestPasswordResetApi, resetPasswordApi, + mfaVerifyApi, type LoginResponse, type RegisterResponse, type UsernameAvailabilityResponse, @@ -31,19 +32,19 @@ export function useAuth() { try { const response = await loginApi({ username, password }); + if (response.type === 'mfa_required' || response.type === 'mfa_setup_required') { + return response; + } + // Tokens are automatically set in httpOnly cookies by backend if (response.type === 'local_auth_success') { if (response.authenticationAuthority) { - // Use sessionStorage for non-sensitive routing hint (cleared when tab closes) sessionStorage.setItem('auth_authority', response.authenticationAuthority); } - // CRITICAL: Immediately fetch user data after successful login try { const userData = await fetchCurrentUserApi(); - if (userData) { - // Cache user data in sessionStorage (cleared on tab close - more secure than localStorage) setUserDataCache(userData as CachedUserData); } } catch (userError) { @@ -59,7 +60,6 @@ export function useAuth() { console.error('Login error:', error); if (error.response) { - // Handle different error response formats if (error.response.data?.detail) { if (Array.isArray(error.response.data.detail)) { errorMessage = error.response.data.detail.map((err: any) => err.msg || err).join(', '); @@ -86,8 +86,37 @@ export function useAuth() { } }; + const verifyMfa = async (mfaToken: string, code: string): Promise => { + setIsLoading(true); + setError(null); + try { + const response = await mfaVerifyApi({ token: mfaToken, code }); + if (response.type === 'local_auth_success') { + if (response.authenticationAuthority) { + sessionStorage.setItem('auth_authority', response.authenticationAuthority); + } + try { + const userData = await fetchCurrentUserApi(response.authenticationAuthority); + if (userData) { + setUserDataCache(userData as CachedUserData); + } + } catch (userError) { + console.error('Failed to fetch user data after MFA:', userError); + } + } + return response; + } catch (error: any) { + const errorMessage = error.response?.data?.detail || 'MFA verification failed'; + setError(errorMessage); + throw error; + } finally { + setIsLoading(false); + } + }; + return { login, + verifyMfa, error, isLoading }; @@ -95,6 +124,9 @@ export function useAuth() { // Microsoft Authentication interface MsalAuthResponse { + type?: string; + mfaToken?: string; + provisioningUri?: string; accessToken: string; tokenType: string; user: { @@ -149,24 +181,25 @@ export function useMsalAuth() { return; } - if (event.data.type === 'msft_auth_success') { + if (event.data.type === 'mfa_required' || event.data.type === 'mfa_setup_required') { + window.removeEventListener('message', messageListener); + popup.close(); + setIsMsalLoading(false); + resolve(event.data as any); + } else if (event.data.type === 'msft_auth_success') { console.log('Login successful!'); const tokenData = event.data.token_data; - // Store the token FIRST localStorage.setItem('authToken', tokenData.tokenAccess); - // Set auth authority if (event.data.authenticationAuthority) { sessionStorage.setItem('auth_authority', event.data.authenticationAuthority); } else { sessionStorage.setItem('auth_authority', 'msft'); } - // Configure axios to use the token api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`; - // NOW fetch user data with proper authentication setTimeout(async () => { try { const userData = await fetchCurrentUserApi('msft'); @@ -176,14 +209,12 @@ export function useMsalAuth() { } catch (userError) { console.error('Failed to fetch user data after Microsoft login:', userError); } - }, 100); // Reduced timeout since we're not waiting for cookies + }, 100); - // Clean up window.removeEventListener('message', messageListener); popup.close(); setIsMsalLoading(false); - // Resolve with the response data resolve(event.data); } else if (event.data.type === 'msft_connection_error') { console.error('Login failed:', event.data.error); @@ -273,6 +304,9 @@ export function useRegister() { // Google Authentication interface GoogleAuthResponse { + type?: string; + mfaToken?: string; + provisioningUri?: string; accessToken: string; tokenType: string; isNewUser?: boolean; @@ -328,24 +362,25 @@ export function useGoogleAuth() { return; } - if (event.data.type === 'google_auth_success') { + if (event.data.type === 'mfa_required' || event.data.type === 'mfa_setup_required') { + window.removeEventListener('message', messageListener); + popup.close(); + setIsGoogleLoading(false); + resolve(event.data as any); + } else if (event.data.type === 'google_auth_success') { console.log('Login successful!'); const tokenData = event.data.token_data; - // Store the token FIRST localStorage.setItem('authToken', tokenData.tokenAccess); - // Set auth authority if (event.data.authenticationAuthority) { sessionStorage.setItem('auth_authority', event.data.authenticationAuthority); } else { sessionStorage.setItem('auth_authority', 'google'); } - // Configure axios to use the token api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`; - // NOW fetch user data with proper authentication setTimeout(async () => { try { const userData = await fetchCurrentUserApi('google'); @@ -355,14 +390,12 @@ export function useGoogleAuth() { } catch (userError) { console.error('Failed to fetch user data after Google login:', userError); } - }, 100); // Reduced timeout since we're not waiting for cookies + }, 100); - // Clean up window.removeEventListener('message', messageListener); popup.close(); setIsGoogleLoading(false); - // Resolve with the response data resolve(event.data); } else if (event.data.type === 'google_connection_error') { console.error('Login failed:', event.data.error); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 63895e6..d186914 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,83 +1,100 @@ import { useNavigate, useLocation } from 'react-router-dom'; -import { useState, useEffect } from 'react'; -import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText, FaShieldAlt } from 'react-icons/fa'; import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { PENDING_INVITATION_KEY } from './InvitePage'; import OnboardingWizard from '../components/OnboardingWizard'; +import { mfaConfirmApi } from '../api/authApi'; import styles from './Login.module.css'; import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; import { useLanguage } from '../providers/language/LanguageContext'; +type LoginPhase = 'credentials' | 'mfa_code' | 'mfa_setup'; + function Login() { const { t } = useLanguage(); const navigate = useNavigate(); const location = useLocation(); - // Pre-fill username from invitation if provided via location.state const invitationUsername = (location.state as any)?.invitationUsername || ''; const [username, setUsername] = useState(invitationUsername); const [password, setPassword] = useState(''); const [usernameFocused, setUsernameFocused] = useState(false); const [passwordFocused, setPasswordFocused] = useState(false); - const { login, error: loginError, isLoading: isLoginLoading } = useAuth(); + const { login, verifyMfa, error: loginError, isLoading: isLoginLoading } = useAuth(); const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); const [showOnboardingWizard, setShowOnboardingWizard] = useState(false); - - // Check for pending invitation + + const [phase, setPhase] = useState('credentials'); + const [mfaToken, setMfaToken] = useState(null); + const [mfaCode, setMfaCode] = useState(''); + const [mfaError, setMfaError] = useState(null); + const [mfaLoading, setMfaLoading] = useState(false); + const [provisioningUri, setProvisioningUri] = useState(null); + const [mfaSetupStep, setMfaSetupStep] = useState<'qr' | 'confirm'>('qr'); + const [mfaConfirmCode, setMfaConfirmCode] = useState(''); + const mfaInputRef = useRef(null); + const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const hasPendingInvitation = !!pendingInvitationToken; const fromLocation = location.state?.from; const from = (fromLocation?.pathname || "/") + (fromLocation?.search || ""); - // Set page title and generate CSRF token useEffect(() => { document.title = "PowerOn AI Platform - Login"; - - // Generate CSRF token for new security implementation generateAndStoreCSRFToken(); }, []); - // Check for autofilled inputs useEffect(() => { const checkAutofill = () => { const usernameInput = document.querySelector('input[type="text"]') as HTMLInputElement; const passwordInput = document.querySelector('input[type="password"]') as HTMLInputElement; - - if (usernameInput && usernameInput.value) { - setUsername(usernameInput.value); - } - if (passwordInput && passwordInput.value) { - setPassword(passwordInput.value); - } + if (usernameInput && usernameInput.value) setUsername(usernameInput.value); + if (passwordInput && passwordInput.value) setPassword(passwordInput.value); }; - - // Check immediately and after a short delay checkAutofill(); const timer = setTimeout(checkAutofill, 100); - return () => clearTimeout(timer); }, []); - // Handle redirect after successful login - const handleSuccessfulLogin = () => { - // If there's a pending invitation, redirect to accept it + useEffect(() => { + if (phase === 'mfa_code' && mfaInputRef.current) { + mfaInputRef.current.focus(); + } + }, [phase]); + + const handleSuccessfulLogin = useCallback(() => { if (pendingInvitationToken) { navigate(`/invite/${pendingInvitationToken}`, { replace: true }); } else { navigate(from, { replace: true }); } - }; - + }, [pendingInvitationToken, navigate, from]); + + const _handleMfaResponse = useCallback(async (response: any) => { + if (response.type === 'mfa_required') { + setMfaToken(response.mfaToken); + setPhase('mfa_code'); + } else if (response.type === 'mfa_setup_required') { + setMfaToken(response.mfaToken); + setProvisioningUri(response.provisioningUri || null); + setPhase('mfa_setup'); + setMfaSetupStep('qr'); + } + }, [t]); + const handleMsalLogin = async () => { try { - console.log("Attempting MSAL login..."); const response = await loginWithMsal(); - console.log("MSAL login successful:", response); + if (response?.type === 'mfa_required' || response?.type === 'mfa_setup_required') { + await _handleMfaResponse(response); + return; + } handleSuccessfulLogin(); } catch (error) { console.error("MSAL login failed:", error); @@ -86,10 +103,12 @@ function Login() { const handleGoogleLogin = async () => { try { - console.log("Attempting Google login..."); const response = await loginWithGoogle(); - console.log("Google login successful:", response); - if (response?.isNewUser) { + if (response?.type === 'mfa_required' || response?.type === 'mfa_setup_required') { + await _handleMfaResponse(response); + return; + } + if ((response as any)?.isNewUser) { setShowOnboardingWizard(true); return; } @@ -100,34 +119,193 @@ function Login() { }; const handleCredentialLogin = async (e?: React.MouseEvent) => { - e?.preventDefault(); // Prevent default form submission + e?.preventDefault(); try { - console.log("Attempting login with:", username); - await login(username, password); - console.log("Login successful"); + const response = await login(username, password); + if (response.type === 'mfa_required' || response.type === 'mfa_setup_required') { + await _handleMfaResponse(response); + return; + } handleSuccessfulLogin(); } catch (error) { console.error("Login failed:", error); - // Stay on login page to show error message - // The error will be displayed via the loginError state from useAuth hook } }; + const _handleMfaVerify = async (code?: string) => { + const c = code ?? mfaCode; + if (!mfaToken || c.length < 6) return; + setMfaError(null); + setMfaLoading(true); + try { + await verifyMfa(mfaToken, c); + handleSuccessfulLogin(); + } catch { + setMfaError(t('Ungültiger MFA-Code')); + setMfaCode(''); + } finally { + setMfaLoading(false); + } + }; + + const _handleMfaSetupConfirm = async (code?: string) => { + const c = code ?? mfaConfirmCode; + if (c.length < 6) return; + setMfaError(null); + setMfaLoading(true); + try { + await mfaConfirmApi(c, mfaToken || undefined); + setPhase('mfa_code'); + setMfaCode(''); + setMfaError(null); + } catch { + setMfaError(t('Ungültiger Code – bitte erneut versuchen')); + setMfaConfirmCode(''); + } finally { + setMfaLoading(false); + } + }; + + const _handleBackToCredentials = () => { + setPhase('credentials'); + setMfaToken(null); + setMfaCode(''); + setMfaError(null); + setProvisioningUri(null); + setMfaConfirmCode(''); + }; + if (showOnboardingWizard) { return ( { - setShowOnboardingWizard(false); - handleSuccessfulLogin(); - }} - onDismiss={() => { - setShowOnboardingWizard(false); - handleSuccessfulLogin(); - }} + onComplete={() => { setShowOnboardingWizard(false); handleSuccessfulLogin(); }} + onDismiss={() => { setShowOnboardingWizard(false); handleSuccessfulLogin(); }} /> ); } + const _renderMfaCodePhase = () => ( + <> +
+ +

+ {t('Zwei-Faktor-Authentifizierung')} +

+

+ {t('Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.')} +

+
+ {mfaError &&
{mfaError}
} +
+ { + const val = e.target.value.replace(/\D/g, '').slice(0, 6); + setMfaCode(val); + if (val.length === 6) _handleMfaVerify(val); + }} + className={`${styles.input} ${mfaCode ? styles.focused : ''}`} + style={{ textAlign: 'center', letterSpacing: '0.5em', fontSize: '1.4rem' }} + /> + +
+ +
+ +
+ + ); + + const _renderMfaSetupPhase = () => ( + <> +
+ +

+ {t('MFA-Setup erforderlich')} +

+

+ {t('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z.B. Microsoft Authenticator, Google Authenticator).')} +

+
+ {mfaError &&
{mfaError}
} + {mfaLoading && !provisioningUri && ( +
{t('wird geladen…')}
+ )} + {mfaSetupStep === 'qr' && provisioningUri && ( + <> +
+ MFA QR Code +
+ + + )} + {mfaSetupStep === 'confirm' && ( + <> +

+ {t('Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein, um das Setup abzuschliessen.')} +

+
+ { + const val = e.target.value.replace(/\D/g, '').slice(0, 6); + setMfaConfirmCode(val); + if (val.length === 6) _handleMfaSetupConfirm(val); + }} + className={`${styles.input} ${mfaConfirmCode ? styles.focused : ''}`} + style={{ textAlign: 'center', letterSpacing: '0.5em', fontSize: '1.4rem' }} + /> + +
+ + + )} +
+ +
+ + ); + return (
@@ -144,113 +322,112 @@ function Login() {
- {/* Pending invitation notice */} - {hasPendingInvitation && ( -
- - {t('Sie haben eine ausstehende Einladung')} -
+ {phase === 'mfa_code' && _renderMfaCodePhase()} + {phase === 'mfa_setup' && _renderMfaSetupPhase()} + {phase === 'credentials' && ( + <> + {hasPendingInvitation && ( +
+ + {t('Sie haben eine ausstehende Einladung')} +
+ )} + + {(loginError || msalError || googleError) && ( +
{loginError || msalError || googleError}
+ )} +
+ setUsername(e.target.value)} + onFocus={() => setUsernameFocused(true)} + onBlur={() => setUsernameFocused(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); handleCredentialLogin(); } + }} + className={`${styles.input} ${usernameFocused || username ? styles.focused : ''}`} + /> + +
+
+ setPassword(e.target.value)} + onFocus={() => setPasswordFocused(true)} + onBlur={() => setPasswordFocused(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); handleCredentialLogin(); } + }} + className={`${styles.input} ${passwordFocused || password ? styles.focused : ''}`} + /> + +
+
+

+ {t('Mit der Anmeldung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.')} +

+
+ + +
+ +
+ +
+ {t('oder')} +
+ + + + + +
+ {t('Du hast noch kein Konto?')} +
+
+ +
+ )} - - {(loginError || msalError || googleError) && ( -
{loginError || msalError || googleError}
- )} -
- setUsername(e.target.value)} - onFocus={() => setUsernameFocused(true)} - onBlur={() => setUsernameFocused(false)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleCredentialLogin(); - } - }} - className={`${styles.input} ${usernameFocused || username ? styles.focused : ''}`} - /> - -
-
- setPassword(e.target.value)} - onFocus={() => setPasswordFocused(true)} - onBlur={() => setPasswordFocused(false)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleCredentialLogin(); - } - }} - className={`${styles.input} ${passwordFocused || password ? styles.focused : ''}`} - /> - -
-
-

- {t('Mit der Anmeldung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.')} -

-
- - -
- -
- -
- {t('oder')} -
- - - - - -
- {t('Du hast noch kein Konto?')} -
-
- -
diff --git a/src/pages/RagInventoryPage.tsx b/src/pages/RagInventoryPage.tsx index d8f943a..2ee4ea2 100644 --- a/src/pages/RagInventoryPage.tsx +++ b/src/pages/RagInventoryPage.tsx @@ -10,6 +10,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useLanguage } from '../providers/language/LanguageContext'; import { useApiRequest } from '../hooks/useApi'; +import { useConfirm } from '../hooks/useConfirm'; import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi'; import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa'; import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; @@ -19,6 +20,7 @@ import styles from './RagInventoryPage.module.css'; export const RagInventoryPage: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); + const { confirm, ConfirmDialog } = useConfirm(); const [mandates, setMandates] = useState([]); const [mandatesLoading, setMandatesLoading] = useState(true); @@ -138,16 +140,18 @@ export const RagInventoryPage: React.FC = () => { }; const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => { - if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) { - try { - await request({ - url: `/api/connections/${connectionId}/knowledge-consent`, - method: 'patch', - data: { enabled: !currentEnabled }, - }); - _fetchInventory(); - } catch {} + if (currentEnabled) { + const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') }); + if (!ok) return; } + try { + await request({ + url: `/api/connections/${connectionId}/knowledge-consent`, + method: 'patch', + data: { enabled: !currentEnabled }, + }); + _fetchInventory(); + } catch {} }; const _formatRelative = useCallback((finishedAt: number | null | undefined): string => { @@ -195,6 +199,7 @@ export const RagInventoryPage: React.FC = () => { return (
+
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ead846c..1a9cb59 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -12,19 +12,21 @@ import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import { useApiRequest } from '../hooks/useApi'; import { useVoiceCatalog } from '../contexts/VoiceCatalogContext'; +import { mfaStatusApi, mfaSetupApi, mfaConfirmApi, mfaDisableApi } from '../api/authApi'; import styles from './Settings.module.css'; // ============================================================================= // TYPES // ============================================================================= -type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'security' | 'privacy'; function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] { return [ { key: 'profile', label: t('Profil') }, { key: 'appearance', label: t('Darstellung') }, { key: 'voice', label: t('Stimme & Sprache') }, + { key: 'security', label: t('Sicherheit') }, { key: 'privacy', label: t('Datenschutz') }, ]; } @@ -419,6 +421,192 @@ const NeutralizationMappingsTab: React.FC = () => { ); }; +// ============================================================================= +// MFA SETTINGS TAB +// ============================================================================= + +const MfaSettingsTab: React.FC = () => { + const { t } = useLanguage(); + const [mfaEnabled, setMfaEnabled] = useState(false); + const [mfaRequired, setMfaRequired] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [setupUri, setSetupUri] = useState(null); + const [confirmCode, setConfirmCode] = useState(''); + const [disableCode, setDisableCode] = useState(''); + const [actionLoading, setActionLoading] = useState(false); + const [showSetup, setShowSetup] = useState(false); + const [showDisable, setShowDisable] = useState(false); + + const _fetchStatus = useCallback(async () => { + setLoading(true); + try { + const status = await mfaStatusApi(); + setMfaEnabled(status.mfaEnabled); + setMfaRequired(status.mfaRequired); + } catch { + setError(t('MFA-Status konnte nicht geladen werden')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { _fetchStatus(); }, [_fetchStatus]); + + const _handleStartSetup = async () => { + setError(null); + setActionLoading(true); + try { + const result = await mfaSetupApi(); + setSetupUri(result.provisioningUri); + setShowSetup(true); + } catch { + setError(t('MFA-Setup konnte nicht gestartet werden')); + } finally { + setActionLoading(false); + } + }; + + const _handleConfirmSetup = async (code?: string) => { + const c = code ?? confirmCode; + if (c.length < 6) return; + setError(null); + setActionLoading(true); + try { + await mfaConfirmApi(c); + setMfaEnabled(true); + setShowSetup(false); + setSetupUri(null); + setConfirmCode(''); + } catch { + setError(t('Ungültiger Code')); + setConfirmCode(''); + } finally { + setActionLoading(false); + } + }; + + const _handleDisable = async (code?: string) => { + const c = code ?? disableCode; + if (c.length < 6) return; + setError(null); + setActionLoading(true); + try { + await mfaDisableApi(c); + setMfaEnabled(false); + setShowDisable(false); + setDisableCode(''); + } catch { + setError(t('Ungültiger Code')); + setDisableCode(''); + } finally { + setActionLoading(false); + } + }; + + if (loading) { + return

{t('wird geladen…')}

; + } + + return ( +
+

{t('Zwei-Faktor-Authentifizierung (MFA)')}

+

+ {t('Schützen Sie Ihr Konto mit einem zusätzlichen Bestätigungscode bei der Anmeldung.')} +

+ + {error &&
{error}
} + +
+
+ +

+ {mfaEnabled ? t('MFA ist aktiv') : mfaRequired ? t('MFA ist Pflicht, aber noch nicht eingerichtet') : t('MFA ist nicht aktiv')} +

+
+
+ {!mfaEnabled && ( + + )} + {mfaEnabled && !mfaRequired && ( + + )} + {mfaEnabled && mfaRequired && ( + {t('MFA-Pflicht – kann nicht deaktiviert werden')} + )} +
+
+ + {showSetup && setupUri && ( +
+

{t('Authenticator-App einrichten')}

+

+ {t('Scannen Sie diesen QR-Code mit Ihrer Authenticator-App.')} +

+
+ MFA QR Code +
+

{t('Geben Sie den 6-stelligen Code ein:')}

+
+ { + const val = e.target.value.replace(/\D/g, '').slice(0, 6); + setConfirmCode(val); + if (val.length === 6) _handleConfirmSetup(val); + }} + placeholder="000000" + style={{ padding: '8px 12px', fontSize: 18, letterSpacing: '0.3em', textAlign: 'center', width: 160, border: '1px solid var(--color-border, #d1d5db)', borderRadius: 6 }} + /> + +
+
+ )} + + {showDisable && ( +
+

{t('MFA deaktivieren')}

+

{t('Geben Sie Ihren aktuellen MFA-Code ein, um zu bestätigen:')}

+
+ { + const val = e.target.value.replace(/\D/g, '').slice(0, 6); + setDisableCode(val); + if (val.length === 6) _handleDisable(val); + }} + placeholder="000000" + style={{ padding: '8px 12px', fontSize: 18, letterSpacing: '0.3em', textAlign: 'center', width: 160, border: '1px solid #fca5a5', borderRadius: 6 }} + /> + + +
+
+ )} +
+ ); +}; + // ============================================================================= // SETTINGS PAGE // ============================================================================= @@ -554,6 +742,8 @@ export const SettingsPage: React.FC = () => { {activeTab === 'voice' && } + {activeTab === 'security' && } + {activeTab === 'privacy' && ( <>