Files
vacuum-wall/webui/static/pages/login.js
T
wall d4213fb93b fix: auth reconnection loop, duplicate login listeners, modal double-disable
- websocket: clear tokens on refresh failure to prevent infinite 401 loop
- api: write vw:user to sessionStorage on refresh for consistency with WS
- api: remove vw:user from sessionStorage in clearAuthTokens
- login: guard listener setup with flags to prevent duplicate attachment
- modal: skip inline button disable when handler uses processing state
- users: remove unused requestUpdate import
2026-07-24 04:02:44 +00:00

217 lines
6.7 KiB
JavaScript

/**
* Login page.
*
* Username + password form, plus "Sign in with passkey" button.
* 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 {
handleLoginSuccess,
webauthnSupported,
startAuthentication,
} from '/static/hoover/components/auth.js';
import { html } from '/static/hoover/html.js?v=9';
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>
`;
}
let _loginFormBound = false;
let _passkeyBound = false;
function handleLogin() {
if (_loginFormBound) return;
_loginFormBound = true;
const form = document.getElementById('loginForm');
if (!form) return;
form.addEventListener('submit', 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() {
if (_passkeyBound) return;
_passkeyBound = true;
const passkeyBtn = document.getElementById('passkeyBtn');
if (!passkeyBtn) return;
const usernameInput = document.getElementById('loginUsername');
const passwordGroup = document.getElementById('loginPasswordGroup');
const loginBtn = document.getElementById('loginBtn');
const errEl = document.getElementById('loginError');
passkeyBtn.addEventListener('click', async () => {
errEl.textContent = '';
const username = usernameInput.value.trim();
if (!username) {
errEl.textContent = 'Enter your username first';
usernameInput.focus();
return;
}
passkeyBtn.disabled = true;
passkeyBtn.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';
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', () => {
if (passwordGroup) {
passwordGroup.style.display = 'none';
}
});
passkeyBtn.addEventListener('mouseleave', () => {
if (passwordGroup) {
passwordGroup.style.display = '';
}
});
}
const Page = definePage({
init() {
document.title = 'Login — Vacuum Wall';
},
async load(state, abortController) {
if (getAuthToken()) {
try {
const res = await apiFetch('/api/auth/session');
if (res.ok) {
window.location.hash = '/dashboard';
return;
}
} catch {
// auth check failed, show login
}
}
},
render() {
return h('div', null, LoginPage());
},
});
handleLogin();
setupPasskeyButton();
export default Page;