import { useState, useEffect, useMemo, useRef } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { useAuthStore } from '../../store/authStore' import { useSettingsStore, hasStoredLanguage } from '../../store/settingsStore' import { useTranslation, detectBrowserLanguage } from '../../i18n' import { startAuthentication } from '@simplewebauthn/browser' import { authApi, configApi } from '../../api/client' import { getApiErrorMessage } from '../../types' interface AppConfig { has_users: boolean allow_registration: boolean setup_complete: boolean demo_mode: boolean oidc_configured: boolean oidc_display_name?: string oidc_only_mode: boolean password_login: boolean password_registration: boolean oidc_login: boolean oidc_registration: boolean passkey_login?: boolean passkey_configured?: boolean env_override_oidc_only: boolean } /** * Login data hook — owns the whole auth surface: login/register/demo, the MFA * step-up, the must-change-password step, the OIDC code exchange + error * handling, the app-config probe (with cache fallback) and the language * detection chain. LoginPage is a pure wiring container that renders what this * returns. Behaviour is identical to the previous in-component logic. */ export function useLogin() { const { t } = useTranslation() const [mode, setMode] = useState<'login' | 'register'>('login') const [username, setUsername] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [rememberMe, setRememberMe] = useState(false) const [showPassword, setShowPassword] = useState(false) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState('') const [appConfig, setAppConfig] = useState(null) const [inviteToken, setInviteToken] = useState('') const [inviteValid, setInviteValid] = useState(false) const exchangeInitiated = useRef(false) const [langDropdownOpen, setLangDropdownOpen] = useState(false) const [showTakeoff, setShowTakeoff] = useState(false) const [mfaStep, setMfaStep] = useState(false) const [mfaToken, setMfaToken] = useState('') const [mfaCode, setMfaCode] = useState('') const [passwordChangeStep, setPasswordChangeStep] = useState(false) const [savedLoginPassword, setSavedLoginPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const { login, register, demoLogin, completeMfaLogin, loadUser } = useAuthStore() const { setLanguageLocal, setLanguageTransient } = useSettingsStore() const navigate = useNavigate() const location = useLocation() const noRedirect = !!(location.state as { noRedirect?: boolean } | null)?.noRedirect const redirectTarget = useMemo(() => { const params = new URLSearchParams(window.location.search) const redirect = params.get('redirect') // Only allow relative paths starting with / to prevent open redirect attacks if (redirect && redirect.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')) { return redirect } return '/dashboard' }, []) useEffect(() => { if (redirectTarget !== '/dashboard') { sessionStorage.setItem('oidc_redirect', redirectTarget) } }, [redirectTarget]) useEffect(() => { const params = new URLSearchParams(window.location.search) const invite = params.get('invite') const oidcCode = params.get('oidc_code') const oidcError = params.get('oidc_error') if (invite) { setInviteToken(invite) setMode('register') authApi.validateInvite(invite).then(() => { setInviteValid(true) }).catch(() => { setError(t('login.invalidInviteLink')) }) window.history.replaceState({}, '', window.location.pathname) } if (oidcCode) { if (exchangeInitiated.current) return exchangeInitiated.current = true setIsLoading(true) fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode), { credentials: 'include' }) .then(r => r.json()) .then(async data => { window.history.replaceState({}, '', '/login') if (data.token) { await loadUser() const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard' sessionStorage.removeItem('oidc_redirect') navigate(savedRedirect, { replace: true }) } else { setError(data.error || t('login.oidcFailed')) } }) .catch(() => { window.history.replaceState({}, '', '/login') setError(t('login.oidcFailed')) }) .finally(() => setIsLoading(false)) return } if (oidcError) { const errorMessages: Record = { registration_disabled: t('login.oidc.registrationDisabled'), no_email: t('login.oidc.noEmail'), token_failed: t('login.oidc.tokenFailed'), invalid_state: t('login.oidc.invalidState'), } setError(errorMessages[oidcError] || oidcError) sessionStorage.removeItem('oidc_redirect') window.history.replaceState({}, '', '/login') return } const CONFIG_CACHE_KEY = 'trek_app_config_cache' authApi.getAppConfig?.() .then((config: AppConfig) => { try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ } return { config, fromCache: false } }) .catch(() => { try { const raw = localStorage.getItem(CONFIG_CACHE_KEY) return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false } } catch { return { config: null as AppConfig | null, fromCache: false } } }) .then(({ config, fromCache }) => { if (config) { setAppConfig(config) if (!config.has_users) setMode('register') // Skip auto-redirect when config is from cache — network is unreliable // and auto-redirecting to the IdP could loop if the proxy changed. if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) { window.location.href = '/api/auth/oidc/login' } } }) }, [navigate, t, noRedirect]) // Language detection chain (runs once on mount, only if user has no saved preference): // 1. localStorage → already in store initial state, skip // 2. Browser/OS language (navigator.languages) // 3. Server default (DEFAULT_LANGUAGE env var) // 4. 'en' → hardcoded fallback already in store useEffect(() => { if (hasStoredLanguage()) return const detected = detectBrowserLanguage() if (detected) { setLanguageTransient(detected) return } configApi.getPublicConfig() .then(({ defaultLanguage }) => { if (defaultLanguage) setLanguageTransient(defaultLanguage) }) .catch((err) => console.warn('Failed to fetch default language config:', err)) }, [setLanguageTransient]) useEffect(() => { if (!langDropdownOpen) return const close = () => setLangDropdownOpen(false) document.addEventListener('click', close) return () => document.removeEventListener('click', close) }, [langDropdownOpen]) const handleDemoLogin = async (): Promise => { setError('') setIsLoading(true) try { await demoLogin() setShowTakeoff(true) setTimeout(() => navigate(redirectTarget), 2600) } catch (err: unknown) { setError(err instanceof Error ? err.message : t('login.demoFailed')) } finally { setIsLoading(false) } } const handlePasskeyLogin = async (): Promise => { setError('') setIsLoading(true) try { const options = await authApi.passkey.loginOptions() const assertion = await startAuthentication({ optionsJSON: options }) await authApi.passkey.loginVerify(assertion) await loadUser({ silent: true }) setShowTakeoff(true) setTimeout(() => navigate(redirectTarget), 2600) } catch (err: unknown) { // The user dismissing the native prompt isn't an error worth surfacing. const name = (err as { name?: string })?.name if (name === 'NotAllowedError' || name === 'AbortError') { setIsLoading(false) return } setError(getApiErrorMessage(err, t('login.passkey.failed'))) setIsLoading(false) } } const handleSubmit = async (e: React.FormEvent): Promise => { e.preventDefault() setError('') setIsLoading(true) try { if (passwordChangeStep) { if (!newPassword) { setError(t('settings.passwordRequired')); setIsLoading(false); return } if (newPassword.length < 8) { setError(t('settings.passwordTooShort')); setIsLoading(false); return } if (newPassword !== confirmPassword) { setError(t('settings.passwordMismatch')); setIsLoading(false); return } await authApi.changePassword({ current_password: savedLoginPassword, new_password: newPassword }) await loadUser({ silent: true }) setShowTakeoff(true) setTimeout(() => navigate(redirectTarget), 2600) return } if (mode === 'login' && mfaStep) { if (!mfaCode.trim()) { setError(t('login.mfaCodeRequired')) setIsLoading(false) return } const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe) if ('user' in mfaResult && mfaResult.user?.must_change_password) { setSavedLoginPassword(password) setPasswordChangeStep(true) setIsLoading(false) return } setShowTakeoff(true) setTimeout(() => navigate(redirectTarget), 2600) return } if (mode === 'register') { if (!username.trim()) { setError(t('login.usernameRequired')); setIsLoading(false); return } if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return } await register(username, email, password, inviteToken || undefined) } else { const result = await login(email, password, rememberMe) if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) { setMfaToken(result.mfa_token) setMfaStep(true) setMfaCode('') setIsLoading(false) return } if ('user' in result && result.user?.must_change_password) { setSavedLoginPassword(password) setPasswordChangeStep(true) setIsLoading(false) return } } setShowTakeoff(true) setTimeout(() => navigate(redirectTarget), 2600) } catch (err: unknown) { setError(getApiErrorMessage(err, t('login.error'))) setIsLoading(false) } } const showRegisterOption = (appConfig?.password_registration || !appConfig?.has_users || inviteValid) && (appConfig?.setup_complete !== false || !appConfig?.has_users) // In OIDC-only mode, show a minimal page that redirects directly to the IdP const oidcOnly = !appConfig?.password_login && appConfig?.oidc_login && appConfig?.oidc_configured return { navigate, mode, setMode, username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword, isLoading, error, setError, appConfig, inviteToken, langDropdownOpen, setLangDropdownOpen, setLanguageLocal, showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode, passwordChangeStep, newPassword, setNewPassword, confirmPassword, setConfirmPassword, noRedirect, showRegisterOption, oidcOnly, handleDemoLogin, handleSubmit, handlePasskeyLogin, } }