ui-nyla/src/pages/Login.tsx
ValueOn AG d579df1c92
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
panel ui
2026-06-11 16:43:53 +02:00

443 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useNavigate, useLocation } from 'react-router-dom';
import { useState, useEffect, useRef, useCallback } from 'react';
import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText, FaShieldAlt } from 'react-icons/fa';
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage';
import OnboardingWizard from '../components/OnboardingWizard';
import { mfaConfirmApi } from '../api/authApi';
import styles from './Login.module.css';
import { LanguageSelector } from '../components/UiComponents/LanguageSelector';
import { useLanguage } from '../providers/language/LanguageContext';
import { useDocumentTitle } from '../hooks/useDocumentTitle';
type LoginPhase = 'credentials' | 'mfa_code' | 'mfa_setup';
function Login() {
const { t } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
const invitationUsername = (location.state as any)?.invitationUsername || '';
const [username, setUsername] = useState(invitationUsername);
const [password, setPassword] = useState('');
const [usernameFocused, setUsernameFocused] = useState(false);
const [passwordFocused, setPasswordFocused] = useState(false);
const { login, verifyMfa, error: loginError, isLoading: isLoginLoading } = useAuth();
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
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 hasPendingInvitation = !!pendingInvitationToken;
const fromLocation = location.state?.from;
const from = (fromLocation?.pathname || "/") + (fromLocation?.search || "");
useDocumentTitle(t('Login'));
useEffect(() => {
generateAndStoreCSRFToken();
}, []);
useEffect(() => {
const checkAutofill = () => {
const usernameInput = document.querySelector('input[type="text"]') as HTMLInputElement;
const passwordInput = document.querySelector('input[type="password"]') as HTMLInputElement;
if (usernameInput && usernameInput.value) setUsername(usernameInput.value);
if (passwordInput && passwordInput.value) setPassword(passwordInput.value);
};
checkAutofill();
const timer = setTimeout(checkAutofill, 100);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (phase === 'mfa_code' && mfaInputRef.current) {
mfaInputRef.current.focus();
}
}, [phase]);
const handleSuccessfulLogin = useCallback(() => {
if (pendingInvitationToken) {
navigate(`/invite/${pendingInvitationToken}`, { replace: true });
} else {
navigate(from, { replace: true });
}
}, [pendingInvitationToken, navigate, from]);
const _handleMfaResponse = useCallback(async (response: any) => {
if (response.type === 'mfa_required') {
setMfaToken(response.mfaToken);
setPhase('mfa_code');
} else if (response.type === 'mfa_setup_required') {
setMfaToken(response.mfaToken);
setProvisioningUri(response.provisioningUri || null);
setPhase('mfa_setup');
setMfaSetupStep('qr');
}
}, [t]);
const handleMsalLogin = async () => {
try {
const response = await loginWithMsal();
if (response?.type === 'mfa_required' || response?.type === 'mfa_setup_required') {
await _handleMfaResponse(response);
return;
}
handleSuccessfulLogin();
} catch (error) {
console.error("MSAL login failed:", error);
}
};
const handleGoogleLogin = async () => {
try {
const response = await loginWithGoogle();
if (response?.type === 'mfa_required' || response?.type === 'mfa_setup_required') {
await _handleMfaResponse(response);
return;
}
if ((response as any)?.isNewUser) {
setShowOnboardingWizard(true);
return;
}
handleSuccessfulLogin();
} catch (error) {
console.error("Google login failed:", error);
}
};
const handleCredentialLogin = async (e?: React.MouseEvent) => {
e?.preventDefault();
try {
const response = await login(username, password);
if (response.type === 'mfa_required' || response.type === 'mfa_setup_required') {
await _handleMfaResponse(response);
return;
}
handleSuccessfulLogin();
} catch (error) {
console.error("Login failed:", error);
}
};
const _handleMfaVerify = async (code?: string) => {
const c = code ?? mfaCode;
if (!mfaToken || c.length < 6) return;
setMfaError(null);
setMfaLoading(true);
try {
await verifyMfa(mfaToken, c);
handleSuccessfulLogin();
} catch {
setMfaError(t('Ungültiger MFA-Code'));
setMfaCode('');
} finally {
setMfaLoading(false);
}
};
const _handleMfaSetupConfirm = async (code?: string) => {
const c = code ?? mfaConfirmCode;
if (c.length < 6) return;
setMfaError(null);
setMfaLoading(true);
try {
await mfaConfirmApi(c, mfaToken || undefined);
setPhase('mfa_code');
setMfaCode('');
setMfaError(null);
} catch {
setMfaError(t('Ungültiger Code bitte erneut versuchen'));
setMfaConfirmCode('');
} finally {
setMfaLoading(false);
}
};
const _handleBackToCredentials = () => {
setPhase('credentials');
setMfaToken(null);
setMfaCode('');
setMfaError(null);
setProvisioningUri(null);
setMfaConfirmCode('');
};
if (showOnboardingWizard) {
return (
<OnboardingWizard
onComplete={() => { setShowOnboardingWizard(false); 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 (
<div className={styles.container}>
<div className={styles.mainContent}>
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem 1rem 0' }}>
<LanguageSelector />
</div>
<div className={styles.logo}>
<img
src="/logos/poweron-logo.png"
alt="PowerOn"
className={styles.logoImage}
/>
</div>
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<div className={styles.loginForm}>
{phase === 'mfa_code' && _renderMfaCodePhase()}
{phase === 'mfa_setup' && _renderMfaSetupPhase()}
{phase === 'credentials' && (
<>
{hasPendingInvitation && (
<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>
</>
)}
</div>
</div>
</div>
</div>
</div>
);
}
export default Login;