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 = [