import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { useTheme } from '../contexts/ThemeContext'; import { X, Mail, Shield, Smartphone, Key } from 'lucide-react'; import { api, type LoginResponse, type OIDCProvider, type TokenPersistence } from '../api/client'; import { Card, CardHeader, CardContent } from '../components/Card'; import { Button } from '../components/Button'; type LoginStep = 'credentials' | '2fa' | 'reset-password'; // sessionStorage survives the OIDC provider round-trip; React state does not. // Read + remove in one try so all branches in the OIDC useEffect see the same // value and a subsequent page load does not replay the flag. const REMEMBER_ME_KEY = 'auth_remember_me'; function toPersistence(remember: boolean): TokenPersistence { return remember ? 'persistent' : 'session'; } function consumeSavedRememberMe(): boolean { try { const saved = sessionStorage.getItem(REMEMBER_ME_KEY) === '1'; sessionStorage.removeItem(REMEMBER_ME_KEY); return saved; } catch (err) { console.warn('consumeSavedRememberMe: sessionStorage unavailable, Remember Me preference lost across OIDC redirect', err); return false; } } /** * Single OIDC-provider login button. Extracted from the `.map()` body * because hooks can't be used inside a loop callback — the `iconFailed` * state is per-provider and must live in its own component instance. * * On `` load failure (provider deleted between page load and image * fetch, network blip, etc.) we flip to the Shield fallback rather than * showing the browser's broken-image glyph to anonymous users (#1333 review). */ function OIDCProviderButton({ provider, onClick, disabled, }: { provider: OIDCProvider; onClick: () => void; disabled: boolean; }) { const { t } = useTranslation(); const [iconFailed, setIconFailed] = useState(false); const showIcon = provider.has_icon && !iconFailed; return ( ); } export function LoginPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { t } = useTranslation(); const { login, loginWithToken } = useAuth(); const { showToast } = useToast(); const { mode } = useTheme(); // Credentials step state const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [showForgotPassword, setShowForgotPassword] = useState(false); const [forgotEmail, setForgotEmail] = useState(''); // 2FA step state const [step, setStep] = useState('credentials'); const [preAuthToken, setPreAuthToken] = useState(''); const [twoFAMethods, setTwoFAMethods] = useState([]); const [twoFAMethod, setTwoFAMethod] = useState<'totp' | 'email' | 'backup'>('totp'); const [twoFACode, setTwoFACode] = useState(''); const [emailOTPSent, setEmailOTPSent] = useState(false); const twoFAInputRef = useRef(null); const [rememberMe, setRememberMe] = useState(false); // H-6: Password reset step state const [resetToken, setResetToken] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); // Check if advanced auth is enabled const { data: advancedAuthStatus } = useQuery({ queryKey: ['advancedAuthStatus'], queryFn: () => api.getAdvancedAuthStatus(), }); // Fetch enabled OIDC providers for login buttons const { data: oidcProviders } = useQuery({ queryKey: ['oidcProviders'], queryFn: () => api.getOIDCProviders(), }); // M-B: Detect #reset_token=... in the URL fragment and switch to the reset step. // Fragments are never sent to the server so the token never appears in access-logs // or Referer headers — mirrors the H-4 treatment of the OIDC token. useEffect(() => { const hash = window.location.hash; const token = hash.startsWith('#reset_token=') ? hash.slice('#reset_token='.length) : null; if (token) { setResetToken(token); setStep('reset-password'); // Clear the fragment from the URL so it can't be bookmarked or re-triggered. navigate('/login', { replace: true }); } }, []); // eslint-disable-line react-hooks/exhaustive-deps // Handle OIDC callback: if #oidc_token=... is present in the fragment, exchange it. // H-4: Read from the URL fragment (#) — fragments are never sent to the server // so the exchange token stays out of access logs and Referer headers. useEffect(() => { const hash = window.location.hash; const oidcToken = hash.startsWith('#oidc_token=') ? hash.slice('#oidc_token='.length) : null; const oidcError = searchParams.get('oidc_error'); if (!oidcToken && !oidcError) return; const savedRememberMe = consumeSavedRememberMe(); if (oidcError) { // L-3: Whitelist known OIDC error codes so provider-controlled text is never // shown verbatim. Any unknown code falls back to a generic message. const KNOWN_OIDC_ERRORS: Record = { oidc_provider_error: t('login.oidcErrors.providerError'), missing_parameters: t('login.oidcErrors.missingParameters'), invalid_state: t('login.oidcErrors.invalidState'), state_expired: t('login.oidcErrors.stateExpired'), provider_not_found: t('login.oidcErrors.providerNotFound'), discovery_failed: t('login.oidcErrors.discoveryFailed'), invalid_discovery_document: t('login.oidcErrors.invalidDiscovery'), token_exchange_network_error: t('login.oidcErrors.networkError'), token_exchange_bad_response: t('login.oidcErrors.badResponse'), no_id_token: t('login.oidcErrors.noIdToken'), token_validation_failed: t('login.oidcErrors.validationFailed'), nonce_mismatch: t('login.oidcErrors.nonceMismatch'), missing_sub_claim: t('login.oidcErrors.missingSubClaim'), no_linked_account: t('login.oidcErrors.noLinkedAccount'), account_inactive: t('login.oidcErrors.accountInactive'), user_resolution_failed: t('login.oidcErrors.userResolutionFailed'), internal_error: t('login.oidcErrors.internalError'), }; // Dynamic codes like "token_exchange_" → generic message const errorMsg = KNOWN_OIDC_ERRORS[oidcError] ?? (oidcError.startsWith('token_exchange_') ? t('login.oidcErrors.tokenExchangeFailed') : t('login.oidcLoginFailed')); showToast(errorMsg, 'error'); navigate('/login', { replace: true }); return; } if (oidcToken) { api.exchangeOIDCToken(oidcToken).then((resp: LoginResponse) => { if (resp.requires_2fa && resp.pre_auth_token) { // OIDC user has 2FA enabled — redirect to 2FA step setRememberMe(savedRememberMe); setPreAuthToken(resp.pre_auth_token); const methods = resp.two_fa_methods ?? []; setTwoFAMethods(methods); if (methods.includes('totp')) setTwoFAMethod('totp'); else if (methods.includes('email')) setTwoFAMethod('email'); else setTwoFAMethod('backup'); setStep('2fa'); // Remove oidc_token from URL so page refresh doesn't re-trigger exchange navigate('/login', { replace: true }); } else if (resp.access_token && resp.user) { loginWithToken(resp.access_token, resp.user, toPersistence(savedRememberMe)); showToast(t('login.loginSuccess')); navigate('/', { replace: true }); } else { showToast(t('login.oidcLoginFailed'), 'error'); navigate('/login', { replace: true }); } }).catch((err: unknown) => { console.error('OIDC token exchange failed', err); showToast(t('login.oidcLoginFailed'), 'error'); navigate('/login', { replace: true }); }); } }, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps // --- Step 1: Credentials login --- const loginMutation = useMutation({ mutationFn: () => login(username, password, toPersistence(rememberMe)), onSuccess: (resp: LoginResponse) => { if (resp.requires_2fa && resp.pre_auth_token) { // 2FA required — switch to verification step setPreAuthToken(resp.pre_auth_token); const methods = resp.two_fa_methods ?? []; setTwoFAMethods(methods); // Pick a sensible default method if (methods.includes('totp')) setTwoFAMethod('totp'); else if (methods.includes('email')) setTwoFAMethod('email'); else setTwoFAMethod('backup'); setStep('2fa'); } else if (resp.access_token && resp.user) { showToast(t('login.loginSuccess')); navigate('/'); } }, onError: (error: Error) => { showToast(error.message || t('login.loginFailed'), 'error'); }, }); const forgotPasswordMutation = useMutation({ mutationFn: (email: string) => api.forgotPassword({ email }), onSuccess: (data) => { showToast(data.message, 'success'); setShowForgotPassword(false); setForgotEmail(''); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); // H-6: Mutation to set a new password using the reset token from the email link const resetPasswordMutation = useMutation({ mutationFn: () => api.forgotPasswordConfirm(resetToken, newPassword), onSuccess: (data) => { showToast(data.message, 'success'); setStep('credentials'); setResetToken(''); setNewPassword(''); setConfirmPassword(''); }, onError: (error: Error) => { showToast(error.message || t('login.resetPassword.resetFailed'), 'error'); }, }); // --- Step 2: 2FA verification --- const sendEmailOTPMutation = useMutation({ mutationFn: () => api.sendEmailOTP(preAuthToken), onSuccess: (data: { message: string; pre_auth_token?: string }) => { setEmailOTPSent(true); // Backend issues a fresh pre-auth token after consuming the original one if (data.pre_auth_token) setPreAuthToken(data.pre_auth_token); showToast(data.message, 'success'); }, onError: (error: Error) => { showToast(error.message || t('login.twoFA.sendCodeFailed'), 'error'); }, }); const verify2FAMutation = useMutation({ mutationFn: () => api.verify2FA({ pre_auth_token: preAuthToken, code: twoFACode, method: twoFAMethod }), onSuccess: (resp: LoginResponse) => { if (resp.access_token && resp.user) { loginWithToken(resp.access_token, resp.user, toPersistence(rememberMe)); showToast(t('login.loginSuccess')); navigate('/'); } else { console.error('2FA verify: unexpected response shape', resp); showToast(t('login.loginFailed'), 'error'); } }, onError: (error: Error) => { showToast(error.message || t('login.twoFA.invalidCode'), 'error'); setTwoFACode(''); }, }); // OIDC login const oidcLoginMutation = useMutation({ mutationFn: (providerId: number) => api.getOIDCAuthorizeUrl(providerId), onSuccess: (data) => { if (rememberMe) { try { sessionStorage.setItem(REMEMBER_ME_KEY, '1'); } catch (err) { console.warn('setItem auth_remember_me failed, Remember Me will not carry through OIDC redirect', err); } } window.location.href = data.auth_url; }, onError: (error: Error) => { showToast(error.message || t('login.oidcLoginFailed'), 'error'); }, }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!username || !password) { showToast(t('login.enterCredentials'), 'error'); return; } loginMutation.mutate(); }; const handle2FASubmit = (e: React.FormEvent) => { e.preventDefault(); if (!twoFACode.trim()) { showToast(t('login.twoFA.enterCode'), 'error'); return; } verify2FAMutation.mutate(); }; const handleForgotPassword = (e: React.FormEvent) => { e.preventDefault(); if (!forgotEmail) { showToast(t('login.enterEmail'), 'error'); return; } forgotPasswordMutation.mutate(forgotEmail); }; const handleMethodChange = (method: 'totp' | 'email' | 'backup') => { setTwoFAMethod(method); setTwoFACode(''); setEmailOTPSent(false); // Re-focus the code input after method switch (autoFocus only fires on mount) setTimeout(() => twoFAInputRef.current?.focus(), 0); }; // ---- Render: password-reset step (H-6) ---- if (step === 'reset-password') { const handleResetSubmit = (e: React.FormEvent) => { e.preventDefault(); if (newPassword !== confirmPassword) { showToast(t('login.resetPassword.passwordsDoNotMatch'), 'error'); return; } if (newPassword.length < 8) { showToast(t('login.resetPassword.passwordTooShort'), 'error'); return; } resetPasswordMutation.mutate(); }; return (

{t('login.resetPassword.title')}

{t('login.resetPassword.subtitle')}

setNewPassword(e.target.value)} className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('login.resetPassword.newPasswordPlaceholder')} autoFocus autoComplete="new-password" minLength={8} />
setConfirmPassword(e.target.value)} className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('login.resetPassword.confirmPasswordPlaceholder')} autoComplete="new-password" />
); } // ---- Render: 2FA step ---- if (step === '2fa') { return (

{t('login.twoFA.title')}

{t('login.twoFA.subtitle')}

{/* Method selector — only show if multiple methods available */} {twoFAMethods.length > 1 && (
{twoFAMethods.includes('totp') && ( )} {twoFAMethods.includes('email') && ( )} {twoFAMethods.includes('backup') && ( )}
)}
{/* Method-specific instructions */} {twoFAMethod === 'totp' && (

{t('login.twoFA.instructionsTotp')}

)} {twoFAMethod === 'email' && (

{emailOTPSent ? t('login.twoFA.instructionsEmail') : t('login.twoFA.instructionsEmailNotSent')}

{!emailOTPSent && ( )} {emailOTPSent && ( )}
)} {twoFAMethod === 'backup' && (

{t('login.twoFA.instructionsBackup')}

)}
setTwoFACode(e.target.value.trim())} disabled={twoFAMethod === 'email' && !emailOTPSent} className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-center tracking-widest text-xl font-mono focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-40" placeholder={twoFAMethod === 'backup' ? t('login.twoFA.backupCodePlaceholder') : t('login.twoFA.codePlaceholder')} maxLength={twoFAMethod === 'backup' ? 8 : 6} autoFocus />
); } // ---- Render: credentials step ---- return (
Bambuddy

{t('login.title')}

{t('login.subtitle')}

setUsername(e.target.value)} className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={advancedAuthStatus?.advanced_auth_enabled ? t('login.usernameOrEmailPlaceholder') : t('login.usernamePlaceholder')} autoComplete="username" />
setPassword(e.target.value)} className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('login.passwordPlaceholder')} autoComplete="current-password" />
setRememberMe(e.target.checked)} className="h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer" />
{/* OIDC provider buttons */} {oidcProviders && oidcProviders.length > 0 && (
{t('login.twoFA.orContinueWith')}
{oidcProviders.map((provider) => ( oidcLoginMutation.mutate(provider.id)} disabled={oidcLoginMutation.isPending} /> ))}
)}
{/* Forgot Password Modal */} {showForgotPassword && (
setShowForgotPassword(false)} > e.stopPropagation()} >

{t('login.forgotPasswordTitle')}

{advancedAuthStatus?.advanced_auth_enabled ? (

{t('login.forgotPasswordEmailMessage')}

setForgotEmail(e.target.value)} className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('login.emailPlaceholder')} />
) : (

{t('login.forgotPasswordMessage')}

{t('login.howToReset')}

  1. {t('login.resetStep1')}
  2. {t('login.resetStep2')}
  3. {t('login.resetStep3')}
  4. {t('login.resetStep4')}
)}
)}
); }