/**
* 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,
doLogin,
webauthnSupported,
startAuthentication,
esc,
} from '/static/hoover/index.js';
function LoginPage() {
const hasWebAuthn = webauthnSupported();
return html`
Vacuum Wall
Sign in to continue
${hasWebAuthn ? html`
or
` : ''}
`;
}
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) {
doLogin(res.data);
toast('Welcome, ' + esc(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) {
doLogin(finishRes.data);
toast('Welcome, ' + esc(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 {
if (btn) {
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';
},
load() {
handleLogin();
setupPasskeyButton();
},
render() {
return h('div', null, LoginPage());
},
});
export default Page;