diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 8b4cdfa..dd951f4 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -180,6 +180,13 @@ export async function apiFetch(url, options = {}) { if (retryRes.ok) { return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: retryRes.status }; } + if (retryRes.status === 401) { + redirectLogin(); + return { ok: false, data: null, error: 'Session expired', status: 401 }; + } + if (!retryRes.ok) { + return { ok: false, data: null, error: json.error || `HTTP ${retryRes.status}`, status: retryRes.status }; + } } redirectLogin(); return { ok: false, data: null, error: 'Session expired', status: 401 }; diff --git a/webui/static/pages/login.js b/webui/static/pages/login.js index 1777239..e5fadc9 100644 --- a/webui/static/pages/login.js +++ b/webui/static/pages/login.js @@ -5,15 +5,18 @@ * On success: stores tokens and navigates to dashboard. */ -import { h, definePage } from '/static/hoover/index.js?v=11'; -import { apiFetch, toast, setAuthToken, getAuthToken } from '/static/hoover/api.js?v=12'; import { + h, + definePage, + html, + apiFetch, + toast, + setAuthToken, + getAuthToken, handleLoginSuccess, webauthnSupported, startAuthentication, -} from '/static/hoover/components/auth.js'; - -import { html } from '/static/hoover/html.js?v=9'; +} from '/static/hoover/index.js?v=12'; function LoginPage() { const hasWebAuthn = webauthnSupported(); @@ -56,22 +59,19 @@ function LoginPage() { `; } -let _loginFormBound = false; -let _passkeyBound = false; - function handleLogin() { - if (_loginFormBound) return; - _loginFormBound = true; - const form = document.getElementById('loginForm'); if (!form) return; - form.addEventListener('submit', async (e) => { - e.preventDefault(); - await doPasswordLogin(); - }); + form.removeEventListener('submit', loginFormHandler); + form.addEventListener('submit', loginFormHandler); } +const loginFormHandler = async (e) => { + e.preventDefault(); + await doPasswordLogin(); +}; + async function doPasswordLogin() { const username = document.getElementById('loginUsername').value.trim(); const password = document.getElementById('loginPassword').value; @@ -96,95 +96,112 @@ async function doPasswordLogin() { } function setupPasskeyButton() { - if (_passkeyBound) return; - _passkeyBound = true; - const passkeyBtn = document.getElementById('passkeyBtn'); if (!passkeyBtn) return; - const usernameInput = document.getElementById('loginUsername'); - const passwordGroup = document.getElementById('loginPasswordGroup'); - const loginBtn = document.getElementById('loginBtn'); - const errEl = document.getElementById('loginError'); + passkeyBtnRef.btn = passkeyBtn; + passkeyBtnRef.usernameInput = document.getElementById('loginUsername'); + passkeyBtnRef.passwordGroup = document.getElementById('loginPasswordGroup'); + passkeyBtnRef.errEl = document.getElementById('loginError'); - passkeyBtn.addEventListener('click', async () => { - errEl.textContent = ''; - const username = usernameInput.value.trim(); - if (!username) { - errEl.textContent = 'Enter your username first'; - usernameInput.focus(); + passkeyBtn.removeEventListener('click', passkeyClickHandler); + passkeyBtn.addEventListener('click', passkeyClickHandler); + + passkeyBtn.removeEventListener('mouseenter', passkeyMouseEnterHandler); + passkeyBtn.removeEventListener('mouseleave', passkeyMouseLeaveHandler); + passkeyBtn.addEventListener('mouseenter', passkeyMouseEnterHandler); + passkeyBtn.addEventListener('mouseleave', passkeyMouseLeaveHandler); +} + +const passkeyBtnRef = { + btn: null, + usernameInput: null, + passwordGroup: null, + errEl: null, +}; + +const passkeyClickHandler = async () => { + const { btn, usernameInput, passwordGroup, errEl } = passkeyBtnRef; + if (!btn) return; + + errEl.textContent = ''; + const username = usernameInput.value.trim(); + if (!username) { + errEl.textContent = 'Enter your username first'; + usernameInput.focus(); + return; + } + + btn.disabled = true; + btn.textContent = 'Checking...'; + + try { + const beginRes = await apiFetch('/api/auth/webauthn/authenticate-begin', { + method: 'POST', + body: { username }, + }); + + if (!beginRes.ok) { + errEl.textContent = beginRes.error || 'Failed to start authentication'; + btn.disabled = false; + btn.textContent = 'Sign in with passkey'; return; } - passkeyBtn.disabled = true; - passkeyBtn.textContent = 'Checking...'; - - try { - const beginRes = await apiFetch('/api/auth/webauthn/authenticate-begin', { - method: 'POST', - body: { username }, - }); - - if (!beginRes.ok) { - errEl.textContent = beginRes.error || 'Failed to start authentication'; - passkeyBtn.disabled = false; - passkeyBtn.textContent = 'Sign in with passkey'; - return; - } - - if (beginRes.data && beginRes.data.no_webauthn) { - errEl.textContent = 'No passkey registered for this account'; - passkeyBtn.disabled = false; - passkeyBtn.textContent = 'Sign in with passkey'; - return; - } - - const authOptions = beginRes.data; - passkeyBtn.textContent = 'Waiting for authenticator...'; - - const assertionResponse = await startAuthentication(authOptions); - - passkeyBtn.textContent = 'Verifying...'; - - const finishRes = await apiFetch('/api/auth/webauthn/authenticate-finish', { - method: 'POST', - body: { - username, - assertion_response: assertionResponse, - auth_options: authOptions, - }, - }); - - if (finishRes.ok) { - handleLoginSuccess(finishRes.data); - toast('Welcome, ' + username, 'success'); - } else { - errEl.textContent = finishRes.error || 'Passkey authentication failed'; - } - } catch (err) { - if (err.message && err.message.toLowerCase().includes('user cancelled')) { - errEl.textContent = 'Authentication cancelled'; - } else { - errEl.textContent = err.message || 'Passkey authentication failed'; - } - } finally { - passkeyBtn.disabled = false; - passkeyBtn.textContent = 'Sign in with passkey'; + if (beginRes.data && beginRes.data.no_webauthn) { + errEl.textContent = 'No passkey registered for this account'; + btn.disabled = false; + btn.textContent = 'Sign in with passkey'; + return; } - }); - passkeyBtn.addEventListener('mouseenter', () => { - if (passwordGroup) { - passwordGroup.style.display = 'none'; - } - }); + const authOptions = beginRes.data; + btn.textContent = 'Waiting for authenticator...'; - passkeyBtn.addEventListener('mouseleave', () => { - if (passwordGroup) { - passwordGroup.style.display = ''; + const assertionResponse = await startAuthentication(authOptions); + + btn.textContent = 'Verifying...'; + + const finishRes = await apiFetch('/api/auth/webauthn/authenticate-finish', { + method: 'POST', + body: { + username, + assertion_response: assertionResponse, + auth_options: authOptions, + }, + }); + + if (finishRes.ok) { + handleLoginSuccess(finishRes.data); + toast('Welcome, ' + username, 'success'); + } else { + errEl.textContent = finishRes.error || 'Passkey authentication failed'; } - }); -} + } catch (err) { + if (err.message && err.message.toLowerCase().includes('user cancelled')) { + errEl.textContent = 'Authentication cancelled'; + } else { + errEl.textContent = err.message || 'Passkey authentication failed'; + } + } finally { + btn.disabled = false; + btn.textContent = 'Sign in with passkey'; + } +}; + +const passkeyMouseEnterHandler = () => { + const { passwordGroup } = passkeyBtnRef; + if (passwordGroup) { + passwordGroup.style.display = 'none'; + } +}; + +const passkeyMouseLeaveHandler = () => { + const { passwordGroup } = passkeyBtnRef; + if (passwordGroup) { + passwordGroup.style.display = ''; + } +}; const Page = definePage({ init() { @@ -192,6 +209,9 @@ const Page = definePage({ }, async load(state, abortController) { + handleLogin(); + setupPasskeyButton(); + if (getAuthToken()) { try { const res = await apiFetch('/api/auth/session'); @@ -210,7 +230,4 @@ const Page = definePage({ }, }); -handleLogin(); -setupPasskeyButton(); - export default Page; diff --git a/webui/static/pages/passkeys.js b/webui/static/pages/passkeys.js index 8cdb663..7a795cf 100644 --- a/webui/static/pages/passkeys.js +++ b/webui/static/pages/passkeys.js @@ -23,28 +23,38 @@ import { Badge, startRegistration, webauthnSupported, + isModalProcessing, + setModalProcessing, } from '/static/hoover/index.js?v=12'; -import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9'; const state = reactive({ credentials: [], loading: true, refreshing: false, error: null }); -async function loadCredentials() { +async function loadCredentials(abortController) { + if (abortController?.signal?.aborted) return; if (state.credentials.length) state.refreshing = true; else state.loading = true; state.error = null; try { - const res = await apiFetch('/api/auth/webauthn/credentials'); + const res = await apiFetch('/api/auth/webauthn/credentials', { + signal: abortController?.signal, + }); + if (abortController?.signal?.aborted) return; if (res.ok) { state.credentials = res.data || []; } else { state.error = res.error || 'Failed to load credentials'; } } catch (e) { - state.error = e.message || 'Failed to load credentials'; + if (!abortController?.signal?.aborted) { + state.error = e.message || 'Failed to load credentials'; + } + } finally { + if (!abortController?.signal?.aborted) { + state.loading = false; + state.refreshing = false; + } } - state.loading = false; - state.refreshing = false; } function addCredentialModal() { @@ -277,7 +287,7 @@ const Page = definePage({ }, async load(s, abortController) { - await loadCredentials(); + await loadCredentials(abortController); }, render() {