235 lines
6.9 KiB
JavaScript
235 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,
|
|
esc,
|
|
} from '/static/hoover/index.js';
|
|
|
|
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, ' + 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) {
|
|
handleLoginSuccess(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 {
|
|
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;
|