diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 35234fd..f8a9b49 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -46,6 +46,7 @@ function clearAuthTokens() { sessionStorage.removeItem('vw:refresh'); sessionStorage.removeItem('vw:access_ttl'); sessionStorage.removeItem('vw:session_id'); + sessionStorage.removeItem('vw:user'); localStorage.removeItem('vw:user'); if (typeof window.__authRefreshTimer__ !== 'undefined') { clearTimeout(window.__authRefreshTimer__); @@ -102,6 +103,7 @@ async function tryRefreshToken() { sessionStorage.setItem('vw:session_id', tokens.session_id); } if (json.data.user) { + sessionStorage.setItem('vw:user', JSON.stringify(json.data.user)); localStorage.setItem('vw:user', JSON.stringify(json.data.user)); } return true; diff --git a/webui/static/hoover/components/modal.js b/webui/static/hoover/components/modal.js index 35f7467..2703106 100644 --- a/webui/static/hoover/components/modal.js +++ b/webui/static/hoover/components/modal.js @@ -191,8 +191,15 @@ export function formModal(inner, title, fields, actions) { if (a.handler) { const origHandler = a.handler; btn.addEventListener('click', () => { - btn.disabled = true; - btn.innerHTML = ''; + // Only inline-disable if the handler doesn't use processing + // state. apiSubmit/formAction already call + // setModalProcessing + refreshModals which re-creates the + // button in processing state. Without the guard the old + // button is discarded before the handler even starts. + if (!a.processing) { + btn.disabled = true; + btn.innerHTML = ''; + } origHandler(); }); } diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 3b3ab7f..3d7b3c5 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -7,6 +7,7 @@ */ import { refreshByTopic } from './model.js?v=9'; +import { clearAuthTokens } from './api.js?v=12'; let _wsConn = null; let _wsReconnectMs = 0; @@ -16,8 +17,8 @@ let _wsFailCount = 0; const _directHandlers = []; /** - * Refresh the access token. Does NOT redirect on failure — the caller - * decides what to do when refresh fails. + * Refresh the access token. On failure, clears all tokens to prevent + * an infinite reconnection loop with a stale token. * * @returns {Promise} true if token was refreshed */ @@ -31,9 +32,15 @@ async function _tryRefreshToken() { body: JSON.stringify({ refresh_token: refresh }), credentials: 'same-origin', }); - if (res.status !== 200) return false; + if (res.status !== 200) { + clearAuthTokens(); + return false; + } const json = await res.json(); - if (!json.ok || !json.data?.tokens) return false; + if (!json.ok || !json.data?.tokens) { + clearAuthTokens(); + return false; + } const tokens = json.data.tokens; window.__auth_token__ = tokens.access_token; sessionStorage.setItem('vw:refresh', tokens.refresh_token); @@ -47,6 +54,7 @@ async function _tryRefreshToken() { } return true; } catch { + clearAuthTokens(); return false; } } diff --git a/webui/static/pages/login.js b/webui/static/pages/login.js index 09f9515..1777239 100644 --- a/webui/static/pages/login.js +++ b/webui/static/pages/login.js @@ -56,7 +56,13 @@ function LoginPage() { `; } +let _loginFormBound = false; +let _passkeyBound = false; + function handleLogin() { + if (_loginFormBound) return; + _loginFormBound = true; + const form = document.getElementById('loginForm'); if (!form) return; @@ -90,6 +96,9 @@ async function doPasswordLogin() { } function setupPasskeyButton() { + if (_passkeyBound) return; + _passkeyBound = true; + const passkeyBtn = document.getElementById('passkeyBtn'); if (!passkeyBtn) return; diff --git a/webui/static/pages/users.js b/webui/static/pages/users.js index 7c8e195..b3df558 100644 --- a/webui/static/pages/users.js +++ b/webui/static/pages/users.js @@ -5,7 +5,7 @@ * Requires auth: rw permission. */ -import { h, definePage, reactive, requestUpdate } from '/static/hoover/index.js?v=11'; +import { h, definePage, reactive } from '/static/hoover/index.js?v=11'; import { html, PageHeader, Table, Badge, ConfirmDelete, Empty, Card, openModal, closeModal, formModal, apiFetch, toast, esc } from '/static/hoover/index.js?v=11'; const SUBSYSTEMS = [