security and mfa
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s

This commit is contained in:
ValueOn AG 2026-06-03 23:21:33 +02:00
parent 59b1e1f6a7
commit 4475a45a26
6 changed files with 642 additions and 183 deletions

View file

@ -12,14 +12,30 @@ export interface LoginRequest {
} }
export interface LoginResponse { export interface LoginResponse {
type: 'local_auth_success'; type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required';
accessToken?: string; accessToken?: string;
tokenType?: string; tokenType?: string;
authenticationAuthority?: string; authenticationAuthority?: string;
mfaToken?: string;
provisioningUri?: string;
label?: any; label?: any;
fieldLabels?: 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 { export interface RegisterData {
username: string; username: string;
email: string; email: string;
@ -316,3 +332,36 @@ export async function logoutApi(): Promise<void> {
await api.post('/api/local/logout'); await api.post('/api/local/logout');
} }
// ============================================================================
// MFA API FUNCTIONS
// ============================================================================
export async function mfaVerifyApi(data: MfaVerifyRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/api/mfa/verify', data);
return response.data;
}
export async function mfaSetupApi(): Promise<MfaSetupResponse> {
const response = await api.post<MfaSetupResponse>('/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<MfaStatusResponse> {
const response = await api.get<MfaStatusResponse>('/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;
}

View file

@ -22,6 +22,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { FaTimes, FaToggleOn, FaToggleOff } from 'react-icons/fa'; import { FaTimes, FaToggleOn, FaToggleOff } from 'react-icons/fa';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { useConfirm } from '../../hooks/useConfirm';
import { import {
patchDataSourceSettings, patchDataSourceSettings,
getDataSourceCostEstimate, getDataSourceCostEstimate,
@ -90,6 +91,7 @@ export const DataSourceSettingsModal: React.FC<Props> = ({
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { confirm, ConfirmDialog } = useConfirm();
const [knowledgeOn, setKnowledgeOn] = useState<boolean>(!!initialKnowledgeIngestionEnabled); const [knowledgeOn, setKnowledgeOn] = useState<boolean>(!!initialKnowledgeIngestionEnabled);
const [ragLimits, setRagLimits] = useState<RagLimits>(initialRagLimits || {}); const [ragLimits, setRagLimits] = useState<RagLimits>(initialRagLimits || {});
@ -131,7 +133,7 @@ export const DataSourceSettingsModal: React.FC<Props> = ({
if (!connectionId) return; if (!connectionId) return;
const newValue = !knowledgeOn; const newValue = !knowledgeOn;
if (!newValue) { 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; if (!ok) return;
} }
setSaving(true); setSaving(true);
@ -179,6 +181,8 @@ export const DataSourceSettingsModal: React.FC<Props> = ({
}; };
return ( return (
<>
<ConfirmDialog />
<div <div
onClick={onClose} onClick={onClose}
style={{ style={{
@ -314,6 +318,7 @@ export const DataSourceSettingsModal: React.FC<Props> = ({
</div> </div>
</div> </div>
</div> </div>
</>
); );
}; };

View file

@ -11,6 +11,7 @@ import {
logoutApi, logoutApi,
requestPasswordResetApi, requestPasswordResetApi,
resetPasswordApi, resetPasswordApi,
mfaVerifyApi,
type LoginResponse, type LoginResponse,
type RegisterResponse, type RegisterResponse,
type UsernameAvailabilityResponse, type UsernameAvailabilityResponse,
@ -31,19 +32,19 @@ export function useAuth() {
try { try {
const response = await loginApi({ username, password }); 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 // Tokens are automatically set in httpOnly cookies by backend
if (response.type === 'local_auth_success') { if (response.type === 'local_auth_success') {
if (response.authenticationAuthority) { if (response.authenticationAuthority) {
// Use sessionStorage for non-sensitive routing hint (cleared when tab closes)
sessionStorage.setItem('auth_authority', response.authenticationAuthority); sessionStorage.setItem('auth_authority', response.authenticationAuthority);
} }
// CRITICAL: Immediately fetch user data after successful login
try { try {
const userData = await fetchCurrentUserApi(); const userData = await fetchCurrentUserApi();
if (userData) { if (userData) {
// Cache user data in sessionStorage (cleared on tab close - more secure than localStorage)
setUserDataCache(userData as CachedUserData); setUserDataCache(userData as CachedUserData);
} }
} catch (userError) { } catch (userError) {
@ -59,7 +60,6 @@ export function useAuth() {
console.error('Login error:', error); console.error('Login error:', error);
if (error.response) { if (error.response) {
// Handle different error response formats
if (error.response.data?.detail) { if (error.response.data?.detail) {
if (Array.isArray(error.response.data.detail)) { if (Array.isArray(error.response.data.detail)) {
errorMessage = error.response.data.detail.map((err: any) => err.msg || err).join(', '); 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<LoginResponse> => {
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 { return {
login, login,
verifyMfa,
error, error,
isLoading isLoading
}; };
@ -95,6 +124,9 @@ export function useAuth() {
// Microsoft Authentication // Microsoft Authentication
interface MsalAuthResponse { interface MsalAuthResponse {
type?: string;
mfaToken?: string;
provisioningUri?: string;
accessToken: string; accessToken: string;
tokenType: string; tokenType: string;
user: { user: {
@ -149,24 +181,25 @@ export function useMsalAuth() {
return; 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!'); console.log('Login successful!');
const tokenData = event.data.token_data; const tokenData = event.data.token_data;
// Store the token FIRST
localStorage.setItem('authToken', tokenData.tokenAccess); localStorage.setItem('authToken', tokenData.tokenAccess);
// Set auth authority
if (event.data.authenticationAuthority) { if (event.data.authenticationAuthority) {
sessionStorage.setItem('auth_authority', event.data.authenticationAuthority); sessionStorage.setItem('auth_authority', event.data.authenticationAuthority);
} else { } else {
sessionStorage.setItem('auth_authority', 'msft'); sessionStorage.setItem('auth_authority', 'msft');
} }
// Configure axios to use the token
api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`; api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`;
// NOW fetch user data with proper authentication
setTimeout(async () => { setTimeout(async () => {
try { try {
const userData = await fetchCurrentUserApi('msft'); const userData = await fetchCurrentUserApi('msft');
@ -176,14 +209,12 @@ export function useMsalAuth() {
} catch (userError) { } catch (userError) {
console.error('Failed to fetch user data after Microsoft login:', 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); window.removeEventListener('message', messageListener);
popup.close(); popup.close();
setIsMsalLoading(false); setIsMsalLoading(false);
// Resolve with the response data
resolve(event.data); resolve(event.data);
} else if (event.data.type === 'msft_connection_error') { } else if (event.data.type === 'msft_connection_error') {
console.error('Login failed:', event.data.error); console.error('Login failed:', event.data.error);
@ -273,6 +304,9 @@ export function useRegister() {
// Google Authentication // Google Authentication
interface GoogleAuthResponse { interface GoogleAuthResponse {
type?: string;
mfaToken?: string;
provisioningUri?: string;
accessToken: string; accessToken: string;
tokenType: string; tokenType: string;
isNewUser?: boolean; isNewUser?: boolean;
@ -328,24 +362,25 @@ export function useGoogleAuth() {
return; 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!'); console.log('Login successful!');
const tokenData = event.data.token_data; const tokenData = event.data.token_data;
// Store the token FIRST
localStorage.setItem('authToken', tokenData.tokenAccess); localStorage.setItem('authToken', tokenData.tokenAccess);
// Set auth authority
if (event.data.authenticationAuthority) { if (event.data.authenticationAuthority) {
sessionStorage.setItem('auth_authority', event.data.authenticationAuthority); sessionStorage.setItem('auth_authority', event.data.authenticationAuthority);
} else { } else {
sessionStorage.setItem('auth_authority', 'google'); sessionStorage.setItem('auth_authority', 'google');
} }
// Configure axios to use the token
api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`; api.defaults.headers.common['Authorization'] = `Bearer ${tokenData.tokenAccess}`;
// NOW fetch user data with proper authentication
setTimeout(async () => { setTimeout(async () => {
try { try {
const userData = await fetchCurrentUserApi('google'); const userData = await fetchCurrentUserApi('google');
@ -355,14 +390,12 @@ export function useGoogleAuth() {
} catch (userError) { } catch (userError) {
console.error('Failed to fetch user data after Google login:', 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); window.removeEventListener('message', messageListener);
popup.close(); popup.close();
setIsGoogleLoading(false); setIsGoogleLoading(false);
// Resolve with the response data
resolve(event.data); resolve(event.data);
} else if (event.data.type === 'google_connection_error') { } else if (event.data.type === 'google_connection_error') {
console.error('Login failed:', event.data.error); console.error('Login failed:', event.data.error);

View file

@ -1,83 +1,100 @@
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa'; import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText, FaShieldAlt } from 'react-icons/fa';
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication'; import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage'; import { PENDING_INVITATION_KEY } from './InvitePage';
import OnboardingWizard from '../components/OnboardingWizard'; import OnboardingWizard from '../components/OnboardingWizard';
import { mfaConfirmApi } from '../api/authApi';
import styles from './Login.module.css'; import styles from './Login.module.css';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector'; import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
type LoginPhase = 'credentials' | 'mfa_code' | 'mfa_setup';
function Login() { function Login() {
const { t } = useLanguage(); const { t } = useLanguage();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
// Pre-fill username from invitation if provided via location.state
const invitationUsername = (location.state as any)?.invitationUsername || ''; const invitationUsername = (location.state as any)?.invitationUsername || '';
const [username, setUsername] = useState(invitationUsername); const [username, setUsername] = useState(invitationUsername);
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [usernameFocused, setUsernameFocused] = useState(false); const [usernameFocused, setUsernameFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = 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 { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false); const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
// Check for pending invitation const [phase, setPhase] = useState<LoginPhase>('credentials');
const [mfaToken, setMfaToken] = useState<string | null>(null);
const [mfaCode, setMfaCode] = useState('');
const [mfaError, setMfaError] = useState<string | null>(null);
const [mfaLoading, setMfaLoading] = useState(false);
const [provisioningUri, setProvisioningUri] = useState<string | null>(null);
const [mfaSetupStep, setMfaSetupStep] = useState<'qr' | 'confirm'>('qr');
const [mfaConfirmCode, setMfaConfirmCode] = useState('');
const mfaInputRef = useRef<HTMLInputElement>(null);
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken; const hasPendingInvitation = !!pendingInvitationToken;
const fromLocation = location.state?.from; const fromLocation = location.state?.from;
const from = (fromLocation?.pathname || "/") + (fromLocation?.search || ""); const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
// Set page title and generate CSRF token
useEffect(() => { useEffect(() => {
document.title = "PowerOn AI Platform - Login"; document.title = "PowerOn AI Platform - Login";
// Generate CSRF token for new security implementation
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
}, []); }, []);
// Check for autofilled inputs
useEffect(() => { useEffect(() => {
const checkAutofill = () => { const checkAutofill = () => {
const usernameInput = document.querySelector('input[type="text"]') as HTMLInputElement; const usernameInput = document.querySelector('input[type="text"]') as HTMLInputElement;
const passwordInput = document.querySelector('input[type="password"]') as HTMLInputElement; const passwordInput = document.querySelector('input[type="password"]') as HTMLInputElement;
if (usernameInput && usernameInput.value) setUsername(usernameInput.value);
if (usernameInput && usernameInput.value) { if (passwordInput && passwordInput.value) setPassword(passwordInput.value);
setUsername(usernameInput.value);
}
if (passwordInput && passwordInput.value) {
setPassword(passwordInput.value);
}
}; };
// Check immediately and after a short delay
checkAutofill(); checkAutofill();
const timer = setTimeout(checkAutofill, 100); const timer = setTimeout(checkAutofill, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
// Handle redirect after successful login useEffect(() => {
const handleSuccessfulLogin = () => { if (phase === 'mfa_code' && mfaInputRef.current) {
// If there's a pending invitation, redirect to accept it mfaInputRef.current.focus();
}
}, [phase]);
const handleSuccessfulLogin = useCallback(() => {
if (pendingInvitationToken) { if (pendingInvitationToken) {
navigate(`/invite/${pendingInvitationToken}`, { replace: true }); navigate(`/invite/${pendingInvitationToken}`, { replace: true });
} else { } else {
navigate(from, { replace: true }); 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 () => { const handleMsalLogin = async () => {
try { try {
console.log("Attempting MSAL login...");
const response = await loginWithMsal(); 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(); handleSuccessfulLogin();
} catch (error) { } catch (error) {
console.error("MSAL login failed:", error); console.error("MSAL login failed:", error);
@ -86,10 +103,12 @@ function Login() {
const handleGoogleLogin = async () => { const handleGoogleLogin = async () => {
try { try {
console.log("Attempting Google login...");
const response = await loginWithGoogle(); const response = await loginWithGoogle();
console.log("Google login successful:", response); if (response?.type === 'mfa_required' || response?.type === 'mfa_setup_required') {
if (response?.isNewUser) { await _handleMfaResponse(response);
return;
}
if ((response as any)?.isNewUser) {
setShowOnboardingWizard(true); setShowOnboardingWizard(true);
return; return;
} }
@ -100,34 +119,193 @@ function Login() {
}; };
const handleCredentialLogin = async (e?: React.MouseEvent) => { const handleCredentialLogin = async (e?: React.MouseEvent) => {
e?.preventDefault(); // Prevent default form submission e?.preventDefault();
try { try {
console.log("Attempting login with:", username); const response = await login(username, password);
await login(username, password); if (response.type === 'mfa_required' || response.type === 'mfa_setup_required') {
console.log("Login successful"); await _handleMfaResponse(response);
return;
}
handleSuccessfulLogin(); handleSuccessfulLogin();
} catch (error) { } catch (error) {
console.error("Login failed:", 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) { if (showOnboardingWizard) {
return ( return (
<OnboardingWizard <OnboardingWizard
onComplete={() => { onComplete={() => { setShowOnboardingWizard(false); handleSuccessfulLogin(); }}
setShowOnboardingWizard(false); onDismiss={() => { setShowOnboardingWizard(false); handleSuccessfulLogin(); }}
handleSuccessfulLogin();
}}
onDismiss={() => {
setShowOnboardingWizard(false);
handleSuccessfulLogin();
}}
/> />
); );
} }
const _renderMfaCodePhase = () => (
<>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: 'var(--color-primary, #2563eb)', marginBottom: 8 }} />
<h3 style={{ margin: '8px 0 4px', fontSize: '1.1rem' }}>
{t('Zwei-Faktor-Authentifizierung')}
</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--color-text-secondary, #666)', margin: 0 }}>
{t('Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.')}
</p>
</div>
{mfaError && <div className={styles.error}>{mfaError}</div>}
<div className={styles.floatingLabelInput}>
<input
ref={mfaInputRef}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder=" "
value={mfaCode}
onChange={(e) => {
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' }}
/>
<label className={mfaCode ? styles.focusedLabel : styles.label}>
{t('MFA-Code')}
</label>
</div>
<button
className={`${styles.button} ${styles.loginButton}`}
onClick={() => _handleMfaVerify()}
disabled={mfaLoading || mfaCode.length < 6}
>
{mfaLoading ? t('wird geprüft…') : t('Bestätigen')}
</button>
<div className={styles.passwordResetLink}>
<button className={styles.textButton} onClick={_handleBackToCredentials}>
{t('Zurück zur Anmeldung')}
</button>
</div>
</>
);
const _renderMfaSetupPhase = () => (
<>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: 'var(--color-primary, #2563eb)', marginBottom: 8 }} />
<h3 style={{ margin: '8px 0 4px', fontSize: '1.1rem' }}>
{t('MFA-Setup erforderlich')}
</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--color-text-secondary, #666)', margin: 0 }}>
{t('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z.B. Microsoft Authenticator, Google Authenticator).')}
</p>
</div>
{mfaError && <div className={styles.error}>{mfaError}</div>}
{mfaLoading && !provisioningUri && (
<div style={{ textAlign: 'center', padding: 24 }}>{t('wird geladen…')}</div>
)}
{mfaSetupStep === 'qr' && provisioningUri && (
<>
<div style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }}>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(provisioningUri)}`}
alt="MFA QR Code"
style={{ width: 200, height: 200, borderRadius: 8, border: '1px solid var(--color-border, #e5e7eb)' }}
/>
</div>
<button
className={`${styles.button} ${styles.loginButton}`}
onClick={() => setMfaSetupStep('confirm')}
>
{t('Weiter Code eingeben')}
</button>
</>
)}
{mfaSetupStep === 'confirm' && (
<>
<p style={{ fontSize: '0.85rem', color: 'var(--color-text-secondary, #666)', textAlign: 'center' }}>
{t('Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein, um das Setup abzuschliessen.')}
</p>
<div className={styles.floatingLabelInput}>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder=" "
value={mfaConfirmCode}
onChange={(e) => {
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' }}
/>
<label className={mfaConfirmCode ? styles.focusedLabel : styles.label}>
{t('Bestätigungscode')}
</label>
</div>
<button
className={`${styles.button} ${styles.loginButton}`}
onClick={() => _handleMfaSetupConfirm()}
disabled={mfaLoading || mfaConfirmCode.length < 6}
>
{mfaLoading ? t('wird geprüft…') : t('MFA aktivieren')}
</button>
</>
)}
<div className={styles.passwordResetLink}>
<button className={styles.textButton} onClick={_handleBackToCredentials}>
{t('Zurück zur Anmeldung')}
</button>
</div>
</>
);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
@ -144,113 +322,112 @@ function Login() {
<div className={styles.loginSection}> <div className={styles.loginSection}>
<div className={styles.loginBox}> <div className={styles.loginBox}>
<div className={styles.loginForm}> <div className={styles.loginForm}>
{/* Pending invitation notice */} {phase === 'mfa_code' && _renderMfaCodePhase()}
{hasPendingInvitation && ( {phase === 'mfa_setup' && _renderMfaSetupPhase()}
<div className={styles.invitationNotice}> {phase === 'credentials' && (
<FaEnvelopeOpenText className={styles.invitationIcon} /> <>
<span>{t('Sie haben eine ausstehende Einladung')}</span> {hasPendingInvitation && (
</div> <div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} />
<span>{t('Sie haben eine ausstehende Einladung')}</span>
</div>
)}
{(loginError || msalError || googleError) && (
<div className={styles.error}>{loginError || msalError || googleError}</div>
)}
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={username}
onChange={(e) => 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 : ''}`}
/>
<label className={usernameFocused || username ? styles.focusedLabel : styles.label}>{t('Benutzername')}</label>
</div>
<div className={styles.floatingLabelInput}>
<input
type="password"
placeholder=" "
value={password}
onChange={(e) => 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 : ''}`}
/>
<label className={passwordFocused || password ? styles.focusedLabel : styles.label}>{t('Passwort')}</label>
</div>
<div className={styles.disclaimer}>
<p>
{t('Mit der Anmeldung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.')}
</p>
</div>
<button
className={`${styles.button} ${styles.loginButton}`}
onClick={handleCredentialLogin}
disabled={isLoginLoading}
>
{isLoginLoading ? t('wird geladen…') : t('Anmelden')}
</button>
<div className={styles.passwordResetLink}>
<button
className={styles.textButton}
onClick={() => navigate("/password-reset-request")}
>
{t('Passwort vergessen?')}
</button>
</div>
<div className={styles.divider}>
<span>{t('oder')}</span>
</div>
<button
className={`${styles.button} ${styles.microsoftButton}`}
onClick={handleMsalLogin}
disabled={isMsalLoading}
>
<div className={styles.buttonContent}>
<FaMicrosoft />
{isMsalLoading ? t('Anmeldung läuft…') : t('Mit Microsoft anmelden')}
</div>
</button>
<button
className={`${styles.button} ${styles.googleButton}`}
onClick={handleGoogleLogin}
disabled={isGoogleLoading}
>
<div className={styles.buttonContent}>
<FaGoogle />
{isGoogleLoading ? t('Anmeldung läuft…') : t('Mit Google anmelden')}
</div>
</button>
<div className={styles.registerLink}>
<span>{t('Du hast noch kein Konto?')}</span>
</div>
<div className={styles.ctaSection}>
<button
type="button"
className={styles.ctaPrimary}
onClick={() => navigate('/register', { state: location.state })}
>
{t('Kostenlos registrieren')}
</button>
</div>
</>
)} )}
{(loginError || msalError || googleError) && (
<div className={styles.error}>{loginError || msalError || googleError}</div>
)}
<div className={styles.floatingLabelInput}>
<input
type="text"
placeholder=" "
value={username}
onChange={(e) => 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 : ''}`}
/>
<label className={usernameFocused || username ? styles.focusedLabel : styles.label}>{t('Benutzername')}</label>
</div>
<div className={styles.floatingLabelInput}>
<input
type="password"
placeholder=" "
value={password}
onChange={(e) => 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 : ''}`}
/>
<label className={passwordFocused || password ? styles.focusedLabel : styles.label}>{t('Passwort')}</label>
</div>
<div className={styles.disclaimer}>
<p>
{t('Mit der Anmeldung stimmen Sie unseren Datenschutzbestimmungen zur KI-Nutzung zu.')}
</p>
</div>
<button
className={`${styles.button} ${styles.loginButton}`}
onClick={handleCredentialLogin}
disabled={isLoginLoading}
>
{isLoginLoading ? t('wird geladen…') : t('Anmelden')}
</button>
<div className={styles.passwordResetLink}>
<button
className={styles.textButton}
onClick={() => navigate("/password-reset-request")}
>
{t('Passwort vergessen?')}
</button>
</div>
<div className={styles.divider}>
<span>{t('oder')}</span>
</div>
<button
className={`${styles.button} ${styles.microsoftButton}`}
onClick={handleMsalLogin}
disabled={isMsalLoading}
>
<div className={styles.buttonContent}>
<FaMicrosoft />
{isMsalLoading ? t('Anmeldung läuft…') : t('Mit Microsoft anmelden')}
</div>
</button>
<button
className={`${styles.button} ${styles.googleButton}`}
onClick={handleGoogleLogin}
disabled={isGoogleLoading}
>
<div className={styles.buttonContent}>
<FaGoogle />
{isGoogleLoading ? t('Anmeldung läuft…') : t('Mit Google anmelden')}
</div>
</button>
<div className={styles.registerLink}>
<span>{t('Du hast noch kein Konto?')}</span>
</div>
<div className={styles.ctaSection}>
<button
type="button"
className={styles.ctaPrimary}
onClick={() => navigate('/register', { state: location.state })}
>
{t('Kostenlos registrieren')}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -10,6 +10,7 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useApiRequest } from '../hooks/useApi'; import { useApiRequest } from '../hooks/useApi';
import { useConfirm } from '../hooks/useConfirm';
import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi'; import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi';
import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa'; import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
@ -19,6 +20,7 @@ import styles from './RagInventoryPage.module.css';
export const RagInventoryPage: React.FC = () => { export const RagInventoryPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { request } = useApiRequest(); const { request } = useApiRequest();
const { confirm, ConfirmDialog } = useConfirm();
const [mandates, setMandates] = useState<any[]>([]); const [mandates, setMandates] = useState<any[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true); const [mandatesLoading, setMandatesLoading] = useState(true);
@ -138,16 +140,18 @@ export const RagInventoryPage: React.FC = () => {
}; };
const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => { const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => {
if (!currentEnabled || window.confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'))) { if (currentEnabled) {
try { const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') });
await request({ if (!ok) return;
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled: !currentEnabled },
});
_fetchInventory();
} catch {}
} }
try {
await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled: !currentEnabled },
});
_fetchInventory();
} catch {}
}; };
const _formatRelative = useCallback((finishedAt: number | null | undefined): string => { const _formatRelative = useCallback((finishedAt: number | null | undefined): string => {
@ -195,6 +199,7 @@ export const RagInventoryPage: React.FC = () => {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<ConfirmDialog />
<header className={styles.pageHeader}> <header className={styles.pageHeader}>
<div className={styles.headerLeft}> <div className={styles.headerLeft}>
<FaDatabase className={styles.headerIcon} /> <FaDatabase className={styles.headerIcon} />

View file

@ -12,19 +12,21 @@ import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
import { useApiRequest } from '../hooks/useApi'; import { useApiRequest } from '../hooks/useApi';
import { useVoiceCatalog } from '../contexts/VoiceCatalogContext'; import { useVoiceCatalog } from '../contexts/VoiceCatalogContext';
import { mfaStatusApi, mfaSetupApi, mfaConfirmApi, mfaDisableApi } from '../api/authApi';
import styles from './Settings.module.css'; import styles from './Settings.module.css';
// ============================================================================= // =============================================================================
// TYPES // TYPES
// ============================================================================= // =============================================================================
type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; type SettingsTab = 'profile' | 'appearance' | 'voice' | 'security' | 'privacy';
function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] { function _getTabs(t: (key: string) => string): { key: SettingsTab; label: string }[] {
return [ return [
{ key: 'profile', label: t('Profil') }, { key: 'profile', label: t('Profil') },
{ key: 'appearance', label: t('Darstellung') }, { key: 'appearance', label: t('Darstellung') },
{ key: 'voice', label: t('Stimme & Sprache') }, { key: 'voice', label: t('Stimme & Sprache') },
{ key: 'security', label: t('Sicherheit') },
{ key: 'privacy', label: t('Datenschutz') }, { 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<string | null>(null);
const [setupUri, setSetupUri] = useState<string | null>(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 <section className={styles.section}><p>{t('wird geladen…')}</p></section>;
}
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>{t('Zwei-Faktor-Authentifizierung (MFA)')}</h2>
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
{t('Schützen Sie Ihr Konto mit einem zusätzlichen Bestätigungscode bei der Anmeldung.')}
</p>
{error && <div style={{ color: '#dc2626', marginBottom: 12, fontSize: 14 }}>{error}</div>}
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>{t('MFA-Status')}</label>
<p className={styles.settingDescription}>
{mfaEnabled ? t('MFA ist aktiv') : mfaRequired ? t('MFA ist Pflicht, aber noch nicht eingerichtet') : t('MFA ist nicht aktiv')}
</p>
</div>
<div className={styles.settingControl}>
{!mfaEnabled && (
<button className={styles.button} onClick={_handleStartSetup} disabled={actionLoading}>
{actionLoading ? t('wird geladen…') : t('MFA einrichten')}
</button>
)}
{mfaEnabled && !mfaRequired && (
<button className={styles.button} onClick={() => setShowDisable(true)} disabled={actionLoading} style={{ color: '#dc2626' }}>
{t('MFA deaktivieren')}
</button>
)}
{mfaEnabled && mfaRequired && (
<span style={{ fontSize: 13, color: 'var(--text-secondary, #888)' }}>{t('MFA-Pflicht kann nicht deaktiviert werden')}</span>
)}
</div>
</div>
{showSetup && setupUri && (
<div style={{ marginTop: 20, padding: 20, border: '1px solid var(--color-border, #e5e7eb)', borderRadius: 8 }}>
<h3 style={{ margin: '0 0 12px', fontSize: '1rem' }}>{t('Authenticator-App einrichten')}</h3>
<p style={{ fontSize: 14, color: 'var(--text-secondary, #666)', marginBottom: 12 }}>
{t('Scannen Sie diesen QR-Code mit Ihrer Authenticator-App.')}
</p>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 16 }}>
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupUri)}`}
alt="MFA QR Code"
style={{ width: 200, height: 200, borderRadius: 8, border: '1px solid var(--color-border, #e5e7eb)' }}
/>
</div>
<p style={{ fontSize: 14, marginBottom: 8 }}>{t('Geben Sie den 6-stelligen Code ein:')}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={confirmCode}
onChange={(e) => {
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 }}
/>
<button className={styles.button} onClick={() => _handleConfirmSetup()} disabled={actionLoading || confirmCode.length < 6}>
{actionLoading ? t('wird geprüft…') : t('Bestätigen')}
</button>
</div>
</div>
)}
{showDisable && (
<div style={{ marginTop: 20, padding: 20, border: '1px solid #fecaca', borderRadius: 8, background: '#fef2f2' }}>
<h3 style={{ margin: '0 0 12px', fontSize: '1rem', color: '#dc2626' }}>{t('MFA deaktivieren')}</h3>
<p style={{ fontSize: 14, marginBottom: 8 }}>{t('Geben Sie Ihren aktuellen MFA-Code ein, um zu bestätigen:')}</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={disableCode}
onChange={(e) => {
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 }}
/>
<button className={styles.button} onClick={() => _handleDisable()} disabled={actionLoading || disableCode.length < 6} style={{ color: '#dc2626' }}>
{actionLoading ? t('wird geprüft…') : t('Deaktivieren')}
</button>
<button className={styles.button} onClick={() => { setShowDisable(false); setDisableCode(''); }}>
{t('Abbrechen')}
</button>
</div>
</div>
)}
</section>
);
};
// ============================================================================= // =============================================================================
// SETTINGS PAGE // SETTINGS PAGE
// ============================================================================= // =============================================================================
@ -554,6 +742,8 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'voice' && <VoiceSettingsTab />} {activeTab === 'voice' && <VoiceSettingsTab />}
{activeTab === 'security' && <MfaSettingsTab />}
{activeTab === 'privacy' && ( {activeTab === 'privacy' && (
<> <>
<section className={styles.section}> <section className={styles.section}>