fix: 401-401 retry on non-401, move login DOM bindings into page lifecycle, add abort support
This commit is contained in:
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user