Merge pull request 'security and mfa' (#4) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
Reviewed-on: #4
This commit is contained in:
commit
30db1b8316
6 changed files with 642 additions and 183 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue