fix: auth reconnection loop, duplicate login listeners, modal double-disable

- websocket: clear tokens on refresh failure to prevent infinite 401 loop
- api: write vw:user to sessionStorage on refresh for consistency with WS
- api: remove vw:user from sessionStorage in clearAuthTokens
- login: guard listener setup with flags to prevent duplicate attachment
- modal: skip inline button disable when handler uses processing state
- users: remove unused requestUpdate import
This commit is contained in:
wall
2026-07-24 04:02:44 +00:00
parent edaf16a433
commit d4213fb93b
5 changed files with 33 additions and 7 deletions
+2
View File
@@ -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;
+9 -2
View File
@@ -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 = '<span class="btn-spinner"></span>';
// 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 = '<span class="btn-spinner"></span>';
}
origHandler();
});
}
+12 -4
View File
@@ -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<boolean>} 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;
}
}
+9
View File
@@ -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;
+1 -1
View File
@@ -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 = [