HERMES-GIELIE/static/login.js
bsgdigital fa0ac9f3e7 fix(login): retry connectivity probe every 3s, auto-reload when server recovers
When the server is unreachable (VPN/Tailscale off), the login page now
polls /health every 3 seconds instead of failing silently. Once the
server becomes reachable, the page reloads automatically so the user
doesn't have to manually refresh.
2026-05-01 19:54:47 +00:00

110 lines
3.7 KiB
JavaScript

/* Login page — external script, no inline handlers.
* Loaded by the /login route. Reads data attributes from the form for
* i18n strings so the server does not need to inject JS literals.
*/
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('login-form');
var input = document.getElementById('pw');
if (!form || !input) return;
var invalidPw = form.getAttribute('data-invalid-pw') || 'Invalid password';
var connFailed = form.getAttribute('data-conn-failed') || 'Connection failed';
function showErr(msg) {
var err = document.getElementById('err');
if (err) { err.textContent = msg; err.style.display = 'block'; }
}
function hideErr() {
var err = document.getElementById('err');
if (err) { err.style.display = 'none'; }
}
// Return the ?next= redirect path if present and safe, otherwise './'
// Guards against open-redirect: rejects protocol-relative (//evil.com),
// absolute URLs, backslash variants, and control characters.
function _safeNextPath() {
try {
var raw = new URL(window.location.href).searchParams.get('next');
if (!raw) return './';
if (raw.charAt(0) !== '/') return './'; // must be path-absolute
if (raw.charAt(1) === '/' || raw.charAt(1) === '\\') return './'; // reject // and \\
if (/[\x00-\x1f\x7f\s]/.test(raw)) return './'; // reject control chars / whitespace
return raw;
} catch (_) { return './'; }
}
async function doLogin(e) {
e.preventDefault();
var pw = input.value;
hideErr();
try {
var res = await fetch('api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
credentials: 'include',
});
var data = {};
try { data = await res.json(); } catch (_) {}
if (res.ok && data.ok) {
window.location.href = _safeNextPath();
} else {
showErr(data.error || invalidPw);
}
} catch (ex) {
showErr(connFailed);
}
}
form.addEventListener('submit', doLogin);
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
doLogin(e);
}
});
// On page load, probe the server so we can distinguish "can't reach server"
// (Tailscale off, wrong network) from "session expired / need to log in".
// Uses /health — a public endpoint, no auth required.
// If unreachable, retries every 3 s and auto-reloads once the server is back.
(function checkConnectivity() {
var retryTimer = null;
function setFormDisabled(disabled) {
if (input) input.disabled = disabled;
var btn = form.querySelector('button');
if (btn) btn.disabled = disabled;
}
function probe() {
fetch('health', { method: 'GET', credentials: 'omit' })
.then(function (r) {
if (r.ok) {
// Server is reachable — if we were in retry mode, reload so the
// page reflects the correct auth state (expired session, etc.).
if (retryTimer !== null) {
clearTimeout(retryTimer);
retryTimer = null;
window.location.reload();
}
} else {
showErr(connFailed + ' (server error ' + r.status + ')');
}
})
.catch(function () {
showErr('Cannot reach server — check your VPN / Tailscale connection.');
setFormDisabled(true);
// Keep retrying so the page auto-recovers once the network is back.
if (retryTimer === null) {
retryTimer = setInterval(probe, 3000);
}
});
}
probe();
})();
});