mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
a876fb2634
* feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales.
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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<string>('')
|
|
const [email, setEmail] = useState<string>('')
|
|
const [password, setPassword] = useState<string>('')
|
|
const [showPassword, setShowPassword] = useState<boolean>(false)
|
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
|
const [error, setError] = useState<string>('')
|
|
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
|
const [inviteToken, setInviteToken] = useState<string>('')
|
|
const [inviteValid, setInviteValid] = useState<boolean>(false)
|
|
const exchangeInitiated = useRef(false)
|
|
|
|
const [langDropdownOpen, setLangDropdownOpen] = useState<boolean>(false)
|
|
|
|
const [showTakeoff, setShowTakeoff] = useState<boolean>(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<string, string> = {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<HTMLFormElement>): Promise<void> => {
|
|
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)
|
|
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)
|
|
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, 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,
|
|
}
|
|
}
|