Files
oikos/public/pages/login.js
T
Ulas Kalayci 55eca32db3 feat: birthday tracking, dashboard KPIs, and app name customization (#88)
- Add Birthdays module: CRUD with calendar/reminder auto-sync, photo upload, age notes
- Add DB migration 18 (birthdays table with calendar_event_id, trigger, indexes)
- Add dashboard widgets: birthdays, family participants, budget overview
- Add Settings > General: admins can set a custom app name (reflected in title/sidebar/login)
- Improve service worker: network-first caching for mutable JS/CSS assets
- Add translations for 16 locales (birthday keys)

Fixes applied during integration:
- innerHTML replaced with insertAdjacentHTML/replaceChildren throughout birthdays.js and dashboard.js
- docker-compose.yml personal dev changes reverted

Co-authored-by: Rafael Foster <rafaelgfoster@gmail.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 07:37:09 +02:00

158 lines
5.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';
const DEFAULT_APP_NAME = 'Oikos';
const APP_NAME_STORAGE_KEY = 'oikos-app-name';
function getStoredAppName() {
return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME;
}
function setAppBranding(appName) {
const name = String(appName || '').trim() || DEFAULT_APP_NAME;
document.title = name;
const titleEl = document.querySelector('.login-hero__title');
if (titleEl) titleEl.textContent = name;
}
/**
* Rendert die Login-Seite in den gegebenen Container.
* @param {HTMLElement} container
*/
export async function render(container) {
const storedAppName = getStoredAppName();
container.innerHTML = `
<main class="login-page" id="main-content">
<div class="login-hero">
<h1 class="login-hero__title">${storedAppName}</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">
<span class="login-btn__label">${t('login.loginButton')}</span>
</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');
setAppBranding(storedAppName);
fetch(VERSION_URL, { cache: 'no-store' })
.then((r) => r.json())
.then((d) => {
if (d?.app_name) {
try { localStorage.setItem(APP_NAME_STORAGE_KEY, d.app_name); } catch (_) {}
setAppBranding(d.app_name);
}
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;
const usernameInput = form.querySelector('#username');
const passwordInput = form.querySelector('#password');
const usernameGroup = usernameInput.closest('.form-group');
const passwordGroup = passwordInput.closest('.form-group');
usernameGroup.classList.toggle('form-group--error', !username);
passwordGroup.classList.toggle('form-group--error', !password);
usernameInput.setAttribute('aria-invalid', String(!username));
passwordInput.setAttribute('aria-invalid', String(!password));
if (!username || !password) {
if (!username) usernameInput.focus();
else passwordInput.focus();
return;
}
const labelEl = submitBtn.querySelector('.login-btn__label');
submitBtn.disabled = true;
labelEl.textContent = t('login.loggingIn');
const spinner = document.createElement('span');
spinner.className = 'login-spinner';
spinner.setAttribute('aria-hidden', 'true');
submitBtn.insertBefore(spinner, labelEl);
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;
labelEl.textContent = t('login.loginButton');
spinner.remove();
}
});
form.querySelector('#username').addEventListener('input', (e) => {
e.currentTarget.closest('.form-group').classList.remove('form-group--error');
e.currentTarget.removeAttribute('aria-invalid');
});
form.querySelector('#password').addEventListener('input', (e) => {
e.currentTarget.closest('.form-group').classList.remove('form-group--error');
e.currentTarget.removeAttribute('aria-invalid');
});
}
function showError(el, message) {
el.textContent = message;
el.hidden = false;
}