Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
// 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;
|