fix: 401-401 retry on non-401, move login DOM bindings into page lifecycle, add abort support

This commit is contained in:
2026-07-27 19:46:30 +00:00
parent cc5679a1cd
commit e48ba72b81
3 changed files with 137 additions and 103 deletions
+7
View File
@@ -180,6 +180,13 @@ export async function apiFetch(url, options = {}) {
if (retryRes.ok) { if (retryRes.ok) {
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: retryRes.status }; 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(); redirectLogin();
return { ok: false, data: null, error: 'Session expired', status: 401 }; return { ok: false, data: null, error: 'Session expired', status: 401 };
+113 -96
View File
@@ -5,15 +5,18 @@
* On success: stores tokens and navigates to dashboard. * 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 { import {
h,
definePage,
html,
apiFetch,
toast,
setAuthToken,
getAuthToken,
handleLoginSuccess, handleLoginSuccess,
webauthnSupported, webauthnSupported,
startAuthentication, startAuthentication,
} from '/static/hoover/components/auth.js'; } from '/static/hoover/index.js?v=12';
import { html } from '/static/hoover/html.js?v=9';
function LoginPage() { function LoginPage() {
const hasWebAuthn = webauthnSupported(); const hasWebAuthn = webauthnSupported();
@@ -56,22 +59,19 @@ function LoginPage() {
`; `;
} }
let _loginFormBound = false;
let _passkeyBound = false;
function handleLogin() { function handleLogin() {
if (_loginFormBound) return;
_loginFormBound = true;
const form = document.getElementById('loginForm'); const form = document.getElementById('loginForm');
if (!form) return; if (!form) return;
form.addEventListener('submit', async (e) => { form.removeEventListener('submit', loginFormHandler);
e.preventDefault(); form.addEventListener('submit', loginFormHandler);
await doPasswordLogin();
});
} }
const loginFormHandler = async (e) => {
e.preventDefault();
await doPasswordLogin();
};
async function doPasswordLogin() { async function doPasswordLogin() {
const username = document.getElementById('loginUsername').value.trim(); const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value; const password = document.getElementById('loginPassword').value;
@@ -96,95 +96,112 @@ async function doPasswordLogin() {
} }
function setupPasskeyButton() { function setupPasskeyButton() {
if (_passkeyBound) return;
_passkeyBound = true;
const passkeyBtn = document.getElementById('passkeyBtn'); const passkeyBtn = document.getElementById('passkeyBtn');
if (!passkeyBtn) return; if (!passkeyBtn) return;
const usernameInput = document.getElementById('loginUsername'); passkeyBtnRef.btn = passkeyBtn;
const passwordGroup = document.getElementById('loginPasswordGroup'); passkeyBtnRef.usernameInput = document.getElementById('loginUsername');
const loginBtn = document.getElementById('loginBtn'); passkeyBtnRef.passwordGroup = document.getElementById('loginPasswordGroup');
const errEl = document.getElementById('loginError'); passkeyBtnRef.errEl = document.getElementById('loginError');
passkeyBtn.addEventListener('click', async () => { passkeyBtn.removeEventListener('click', passkeyClickHandler);
errEl.textContent = ''; passkeyBtn.addEventListener('click', passkeyClickHandler);
const username = usernameInput.value.trim();
if (!username) { passkeyBtn.removeEventListener('mouseenter', passkeyMouseEnterHandler);
errEl.textContent = 'Enter your username first'; passkeyBtn.removeEventListener('mouseleave', passkeyMouseLeaveHandler);
usernameInput.focus(); 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; return;
} }
passkeyBtn.disabled = true; if (beginRes.data && beginRes.data.no_webauthn) {
passkeyBtn.textContent = 'Checking...'; errEl.textContent = 'No passkey registered for this account';
btn.disabled = false;
try { btn.textContent = 'Sign in with passkey';
const beginRes = await apiFetch('/api/auth/webauthn/authenticate-begin', { return;
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';
} }
});
passkeyBtn.addEventListener('mouseenter', () => { const authOptions = beginRes.data;
if (passwordGroup) { btn.textContent = 'Waiting for authenticator...';
passwordGroup.style.display = 'none';
}
});
passkeyBtn.addEventListener('mouseleave', () => { const assertionResponse = await startAuthentication(authOptions);
if (passwordGroup) {
passwordGroup.style.display = ''; 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({ const Page = definePage({
init() { init() {
@@ -192,6 +209,9 @@ const Page = definePage({
}, },
async load(state, abortController) { async load(state, abortController) {
handleLogin();
setupPasskeyButton();
if (getAuthToken()) { if (getAuthToken()) {
try { try {
const res = await apiFetch('/api/auth/session'); const res = await apiFetch('/api/auth/session');
@@ -210,7 +230,4 @@ const Page = definePage({
}, },
}); });
handleLogin();
setupPasskeyButton();
export default Page; export default Page;
+17 -7
View File
@@ -23,28 +23,38 @@ import {
Badge, Badge,
startRegistration, startRegistration,
webauthnSupported, webauthnSupported,
isModalProcessing,
setModalProcessing,
} from '/static/hoover/index.js?v=12'; } 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 }); 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; if (state.credentials.length) state.refreshing = true;
else state.loading = true; else state.loading = true;
state.error = null; state.error = null;
try { 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) { if (res.ok) {
state.credentials = res.data || []; state.credentials = res.data || [];
} else { } else {
state.error = res.error || 'Failed to load credentials'; state.error = res.error || 'Failed to load credentials';
} }
} catch (e) { } 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() { function addCredentialModal() {
@@ -277,7 +287,7 @@ const Page = definePage({
}, },
async load(s, abortController) { async load(s, abortController) {
await loadCredentials(); await loadCredentials(abortController);
}, },
render() { render() {