Files
oikos/public/pages/login.js
T
ulsklyc d1ec7367a0 fix(auth): resolve post-login navigate race condition and add version display (#68) (#70)
Root cause: when auth.me() failed during initial navigation, the catch block
called navigate('/login') without clearing _pendingLoginRedirect. The outer
finally then fired a second concurrent navigate('/login'), which held
isNavigating=true while running. If the user submitted the login form (or
iCloud Keychain autofilled credentials) before the second navigation
completed, navigate('/', user) was silently blocked by the isNavigating guard —
login appeared to succeed but the app never advanced to the dashboard.

Fix: clear _pendingLoginRedirect in the catch block so the finally handler
does not spawn the duplicate navigation.

Also adds a GET /api/v1/version endpoint (no auth required) and shows the
version on the login page, so users can verify their PWA has received the
latest cached JS.

Resolves #68

Co-authored-by: Ulas Kalayci <ulas.kalayci@googlemail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:19:53 +02:00

109 lines
3.1 KiB
JavaScript

/**
* Modul: Login-Seite
* Zweck: Anmeldeformular mit Username/Passwort, Fehlerbehandlung, Session-Start
* Abhängigkeiten: /api.js
*/
import { auth } from '/api.js';
import { t } from '/i18n.js';
const VERSION_URL = '/api/v1/version';
/**
* Rendert die Login-Seite in den gegebenen Container.
* @param {HTMLElement} container
*/
export async function render(container) {
container.innerHTML = `
<main class="login-page" id="main-content">
<div class="login-hero">
<h1 class="login-hero__title">Oikos</h1>
<p class="login-hero__tagline">${t('login.tagline')}</p>
</div>
<div class="login-card card card--padded">
<form class="login-form" id="login-form" novalidate>
<div class="form-group">
<label class="label" for="username">${t('login.usernameLabel')}</label>
<input
class="input"
type="text"
id="username"
name="username"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
placeholder="${t('login.usernamePlaceholder')}"
required
/>
</div>
<div class="form-group">
<label class="label" for="password">${t('login.passwordLabel')}</label>
<input
class="input"
type="password"
id="password"
name="password"
autocomplete="current-password"
placeholder="${t('login.passwordPlaceholder')}"
required
/>
</div>
<div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div>
<button type="submit" class="btn btn--primary login-form__submit" id="login-btn">
${t('login.loginButton')}
</button>
</form>
</div>
<p class="login-version" id="login-version"></p>
</main>
`;
const form = container.querySelector('#login-form');
const errorEl = container.querySelector('#login-error');
const submitBtn = container.querySelector('#login-btn');
const versionEl = container.querySelector('#login-version');
fetch(VERSION_URL)
.then((r) => r.json())
.then((d) => { versionEl.textContent = t('login.version', { version: d.version }); })
.catch(() => {});
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.hidden = true;
const username = form.username.value.trim();
const password = form.password.value;
if (!username || !password) {
showError(errorEl, t('common.allFieldsRequired'));
return;
}
submitBtn.disabled = true;
submitBtn.textContent = t('login.loggingIn');
try {
const result = await auth.login(username, password);
window.oikos.navigate('/', result.user);
} catch (err) {
showError(errorEl, err.status === 429
? t('login.tooManyAttempts')
: t('login.invalidCredentials')
);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = t('login.loginButton');
}
});
}
function showError(el, message) {
el.textContent = message;
el.hidden = false;
}