ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
234 lines
6.9 KiB
JavaScript
234 lines
6.9 KiB
JavaScript
/**
|
|
* Login page.
|
|
*
|
|
* Username + password form, plus "Sign in with passkey" button.
|
|
* On success: stores tokens and navigates to dashboard.
|
|
*/
|
|
|
|
import {
|
|
h,
|
|
definePage,
|
|
html,
|
|
apiFetch,
|
|
toast,
|
|
setAuthToken,
|
|
getAuthToken,
|
|
handleLoginSuccess,
|
|
webauthnSupported,
|
|
startAuthentication,
|
|
} from '/static/hoover/index.js?v=14';
|
|
|
|
function LoginPage() {
|
|
const hasWebAuthn = webauthnSupported();
|
|
|
|
return html`
|
|
<div class="login-page">
|
|
<div class="login-card">
|
|
<h2 class="login-title">Vacuum Wall</h2>
|
|
<p class="login-subtitle">Sign in to continue</p>
|
|
<form id="loginForm" class="login-form">
|
|
<div class="form-group">
|
|
<input
|
|
type="text"
|
|
id="loginUsername"
|
|
autocomplete="username"
|
|
placeholder="Username"
|
|
required
|
|
/>
|
|
</div>
|
|
<div id="loginPasswordGroup" class="form-group">
|
|
<input
|
|
type="password"
|
|
id="loginPassword"
|
|
autocomplete="current-password"
|
|
placeholder="Password"
|
|
required
|
|
/>
|
|
</div>
|
|
<div id="loginError" class="login-error"></div>
|
|
<button type="submit" class="btn btn-primary btn-login" id="loginBtn">Sign in</button>
|
|
</form>
|
|
${hasWebAuthn ? html`
|
|
<div class="login-divider">or</div>
|
|
<button type="button" class="btn btn-outline btn-passkey" id="passkeyBtn">
|
|
Sign in with passkey
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function handleLogin() {
|
|
const form = document.getElementById('loginForm');
|
|
if (!form) return;
|
|
|
|
form.removeEventListener('submit', loginFormHandler);
|
|
form.addEventListener('submit', loginFormHandler);
|
|
}
|
|
|
|
const loginFormHandler = async (e) => {
|
|
e.preventDefault();
|
|
await doPasswordLogin();
|
|
};
|
|
|
|
async function doPasswordLogin() {
|
|
const username = document.getElementById('loginUsername').value.trim();
|
|
const password = document.getElementById('loginPassword').value;
|
|
const errEl = document.getElementById('loginError');
|
|
if (!username || !password) {
|
|
errEl.textContent = 'Username and password are required';
|
|
return;
|
|
}
|
|
errEl.textContent = '';
|
|
|
|
const res = await apiFetch('/api/auth/login', {
|
|
method: 'POST',
|
|
body: { username, password },
|
|
});
|
|
|
|
if (res.ok) {
|
|
handleLoginSuccess(res.data);
|
|
toast('Welcome, ' + username, 'success');
|
|
} else {
|
|
errEl.textContent = res.error || 'Login failed';
|
|
}
|
|
}
|
|
|
|
function setupPasskeyButton() {
|
|
const passkeyBtn = document.getElementById('passkeyBtn');
|
|
if (!passkeyBtn) return;
|
|
|
|
passkeyBtnRef.btn = passkeyBtn;
|
|
passkeyBtnRef.usernameInput = document.getElementById('loginUsername');
|
|
passkeyBtnRef.passwordGroup = document.getElementById('loginPasswordGroup');
|
|
passkeyBtnRef.errEl = document.getElementById('loginError');
|
|
|
|
passkeyBtn.removeEventListener('click', passkeyClickHandler);
|
|
passkeyBtn.addEventListener('click', passkeyClickHandler);
|
|
|
|
passkeyBtn.removeEventListener('mouseenter', passkeyMouseEnterHandler);
|
|
passkeyBtn.removeEventListener('mouseleave', passkeyMouseLeaveHandler);
|
|
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;
|
|
}
|
|
|
|
if (beginRes.data && beginRes.data.no_webauthn) {
|
|
errEl.textContent = 'No passkey registered for this account';
|
|
btn.disabled = false;
|
|
btn.textContent = 'Sign in with passkey';
|
|
return;
|
|
}
|
|
|
|
const authOptions = beginRes.data;
|
|
btn.textContent = 'Waiting for authenticator...';
|
|
|
|
const assertionResponse = await startAuthentication(authOptions);
|
|
|
|
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({
|
|
init() {
|
|
document.title = 'Login — Vacuum Wall';
|
|
},
|
|
|
|
async load(state, abortController) {
|
|
handleLogin();
|
|
setupPasskeyButton();
|
|
|
|
if (getAuthToken()) {
|
|
try {
|
|
const res = await apiFetch('/api/auth/session', { signal: abortController?.signal });
|
|
if (res.ok) {
|
|
window.location.hash = '/dashboard';
|
|
return;
|
|
}
|
|
} catch {
|
|
// auth check failed, show login
|
|
}
|
|
}
|
|
},
|
|
|
|
render() {
|
|
return h('div', null, LoginPage());
|
|
},
|
|
});
|
|
|
|
export default Page;
|