// -----------------------------
// Projektübersicht — Authentifizierung (Login-Overlay, Session, Logout)
// -----------------------------
// Wird von App.html direkt nach
// js/server.js geladen, da checkAuthAndInit() baseUrl() benötigt. Ersetzt den
// früheren rein clientseitigen Passwortschutz (js/tabs-auth.js) durch eine
// echte serverseitige Session (server.js: /login, /logout, /whoami). Ohne
// gültige Session zeigt checkAuthAndInit() das blockierende Login-Overlay
// (#loginOverlay/#loginForm, siehe App.html); nach
// erfolgreichem Login läuft der eigentliche Seiteneinstieg (per Callback
// übergeben) wie gewohnt weiter.
//
// Hier liegt außerdem der Dialog zum Ändern des eigenen Passworts
// (#passwordModal, POST /change-password). Er hat zwei Betriebsarten:
// freiwillig (aus dem Bereich „Einstellungen" heraus, mit Abbrechen) und
// ERZWUNGEN — wenn das Konto must_change_password trägt, was der Server bei
// jedem neu angelegten Konto und nach jedem Zurücksetzen setzt. Bis zu diesem
// Umbau gab es zu dem Endpunkt gar keine Oberfläche: Die Benutzerverwaltung
// zeigte „Passwortwechsel erforderlich" an, erfüllen konnte ihn niemand.
// currentUser: der aktuell angemeldete Benutzer ({id, username, displayName,
// email, role, rolleName, rechte, personId, mustChangePassword}) oder null
// vor dem Login.
let currentUser = null;
// renderUserBar: zeigt Namen + Rolle des angemeldeten Benutzers im Menueband
// an (Element #userBarInfo) und blendet den gesamten Admin-Bereich der
// Seitenleiste (#navAdminSection) nur bei mindestens einem Admin-Recht ein
// (Rollen & Rechte). Es ist ein reines UI-Gate; die eigentliche Sperre sitzt
// serverseitig in requireRecht() auf den zugehoerigen Routen, und showTab()
// leitet ohne Recht zusaetzlich vom Bereich „Admin" weg (js/tabs-auth.js).
function renderUserBar(){
const info = document.getElementById('userBarInfo');
if(info && currentUser){
const roleLabel = currentUser.rolleName || systemRolleText(currentUser.role);
info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')';
}
const navAdminSection = document.getElementById('navAdminSection');
if(navAdminSection) navAdminSection.style.display = hatAdminRecht() ? '' : 'none';
// Wandtafel-Eintrag nur mit Recht „Wandtafel anzeigen“
document.querySelectorAll('[data-tab="wand"], .nur-wandtafel-recht').forEach(el => { el.hidden = !hatRecht('wand'); });
if(typeof adminNavAbgleichen === 'function') adminNavAbgleichen();
// Allgemeines UI-Gate: Elemente mit class="nur-recht" data-recht="…"
// data-recht="a|b": eines der Rechte genügt.
document.querySelectorAll('.nur-recht[data-recht]').forEach(el => { el.hidden = !el.dataset.recht.split('|').some(r => hatRecht(r)); });
document.querySelectorAll('.nur-projektleitung').forEach(el => { el.hidden = !darfProjektlisteAendern(); });
}
// systemRolleText: Anzeigename einer Systemrolle (users.role). Auch von der
// Benutzerverwaltung (js/admin-users.js) genutzt.
function systemRolleText(role){
return role === 'admin' ? 'Administrator'
: role === 'projektleiter' ? 'Projektleiter'
: role === 'kiosk' ? 'Kiosk' : 'Mitglied';
}
// darfProjektlisteAendern: ob das angemeldete Konto Projekte anlegen und aus
// der Projektliste löschen darf (Recht „Projekte anlegen und löschen“). Reines
// UI-Gate — dieselbe Regel setzt /save-week serverseitig durch
// (darfProjektlisteAendern() in server.js), dort mit 403. Knöpfe zum
// Anlegen tragen die Klasse .nur-projektleitung und werden von
// renderUserBar() ausgeblendet.
function darfProjektlisteAendern(){
return hatRecht('proj.anlegen');
}
// hatRecht: ob die Rolle des angemeldeten Kontos dieses Recht hat (Admin →
// Rollen & Rechte). /whoami liefert die Liste mit (currentUser.rechte).
// Reines UI-Gate — die Grenze ziehen requireRecht()/hatRecht() im Server.
function hatRecht(recht){
return !!currentUser && Array.isArray(currentUser.rechte) && currentUser.rechte.includes(recht);
}
// hatAdminRecht: mindestens ein Recht aus dem Admin-Bereich (a.*) — dann
// erscheint „Admin“ in der Seitenleiste, mit den erlaubten Abschnitten.
function hatAdminRecht(){
return !!currentUser && Array.isArray(currentUser.rechte) && currentUser.rechte.some(r => r.startsWith('a.'));
}
// logout: beendet die Server-Session und lädt die Seite neu (zeigt danach
// wieder das Login-Overlay, da /whoami dann {user:null} liefert).
async function logout(){
try { await fetch(baseUrl() + '/logout', { method:'POST' }); } catch(e){}
window.location.reload();
}
// showLoginOverlay / hideLoginOverlay: blenden die blockierende Login-Seite ein/aus.
function showLoginOverlay(){
renderLoginWoche();
document.getElementById('loginOverlay').style.display = 'flex';
const feld = document.getElementById('loginUsername');
if(feld && !feld.value) feld.focus();
}
// renderLoginWoche: füllt die rechte Seite der Login-Seite — laufende
// Kalenderwoche (getWeekNumber(), js/utils.js), Jahr der ISO-Woche, die
// Arbeitstage Mo-Fr mit dem heutigen Tag markiert und das Datum.
function renderLoginWoche(){
const heute = new Date();
const kw = getWeekNumber(heute);
// ISO-Jahr: das Jahr des Donnerstags dieser Woche (Ende Dezember/Anfang
// Januar weicht es vom Kalenderjahr ab, z. B. KW 01 / 2027 am 30.12.2026).
const donnerstag = new Date(heute); donnerstag.setDate(heute.getDate() + 3 - ((heute.getDay() + 6) % 7));
const zahl = document.getElementById('loginKwZahl');
if(!zahl) return;
zahl.textContent = String(kw).padStart(2, '0');
document.getElementById('loginKwJahr').textContent = donnerstag.getFullYear();
document.getElementById('loginDatum').textContent =
heute.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
const montag = new Date(heute); montag.setDate(heute.getDate() - ((heute.getDay() + 6) % 7));
document.getElementById('loginTage').innerHTML = ['Mo', 'Di', 'Mi', 'Do', 'Fr'].map((n, i) => {
const d = new Date(montag); d.setDate(montag.getDate() + i);
const istHeute = d.toDateString() === heute.toDateString();
return '
' + n + '' + d.getDate() + '';
}).join('');
}
// verdrahteLoginHilfen: „Anzeigen" für das Passwort und der Hinweis auf
// eine aktive Feststelltaste. Einmalig.
let loginHilfenVerdrahtet = false;
function verdrahteLoginHilfen(){
if(loginHilfenVerdrahtet) return;
loginHilfenVerdrahtet = true;
const pw = document.getElementById('loginPassword');
const toggle = document.getElementById('loginPwToggle');
const caps = document.getElementById('loginCaps');
if(toggle) toggle.addEventListener('click', () => {
const sichtbar = pw.type === 'text';
pw.type = sichtbar ? 'password' : 'text';
toggle.textContent = sichtbar ? 'Anzeigen' : 'Verbergen';
toggle.setAttribute('aria-pressed', String(!sichtbar));
pw.focus();
});
const capsPruefen = e => { if(caps && e.getModifierState) caps.hidden = !e.getModifierState('CapsLock'); };
pw.addEventListener('keydown', capsPruefen);
pw.addEventListener('keyup', capsPruefen);
pw.addEventListener('blur', () => { if(caps) caps.hidden = true; });
['loginUsername', 'loginPassword'].forEach(id => {
document.getElementById(id).addEventListener('input', e => {
e.target.closest('.login-feld').classList.remove('hat-fehler');
document.getElementById('loginError').hidden = true;
});
});
}
function hideLoginOverlay(){
document.getElementById('loginOverlay').style.display = 'none';
}
// afterAuthenticated: gemeinsamer Nachlauf beider Anmeldewege (bestehende
// Session laut /whoami bzw. frisch abgeschicktes Login-Formular). Startet die
// Seite und erzwingt danach — erst danach, damit die Anwendung hinter dem
// Dialog schon steht — den Passwortwechsel, falls das Konto ihn verlangt.
// Parameter: onAuthenticated (Funktion). Keine Rückgabe.
function afterAuthenticated(onAuthenticated){
hideLoginOverlay();
renderUserBar();
onAuthenticated();
// "Termin aus Mail" (js/plan/plan-mail-eintrag.js) nur mit eingerichteter KI.
if(typeof mailKiStatusLaden === 'function') mailKiStatusLaden();
if(currentUser && currentUser.mustChangePassword) openPasswordModal(true);
// Rundgang (js/einfuehrung.js): wartet selbst, bis die Daten geladen sind
// und kein Dialog offen ist — nach einem Pflicht-Passwortwechsel also erst
// danach.
else if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf();
}
// ---------- Eigenes Passwort ändern (#passwordModal) ----------
// passwordChangeForced: true, solange der Dialog wegen must_change_password
// offen ist. Dann gibt es kein Abbrechen — sonst bliebe die Kennzeichnung im
// Konto stehen und das (oft per Zettel weitergegebene) Startpasswort gültig.
let passwordChangeForced = false;
// openPasswordModal: zeigt den Dialog. Parameter: forced (Boolean) — im
// erzwungenen Fall ohne Abbrechen-Schaltfläche und mit Hinweistext.
// Keine Rückgabe.
function openPasswordModal(forced){
const modal = document.getElementById('passwordModal');
if(!modal) return;
passwordChangeForced = !!forced;
['passwordCurrent','passwordNew','passwordRepeat'].forEach(id => {
const el = document.getElementById(id);
if(el) el.value = '';
});
const errEl = document.getElementById('passwordError');
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
const forcedHint = document.getElementById('passwordForcedHint');
if(forcedHint) forcedHint.hidden = !passwordChangeForced;
const cancelBtn = document.getElementById('passwordCancelBtn');
if(cancelBtn) cancelBtn.style.display = passwordChangeForced ? 'none' : '';
modal.style.display = 'flex';
const first = document.getElementById('passwordCurrent');
if(first) first.focus();
}
// closePasswordModal: schließt den Dialog — im erzwungenen Fall verweigert er
// sich. Keine Parameter, keine Rückgabe.
function closePasswordModal(){
if(passwordChangeForced) return;
const modal = document.getElementById('passwordModal');
if(modal) modal.style.display = 'none';
}
// submitPasswordChange: schickt den Wechsel an POST /change-password. Der
// Server verlangt immer das aktuelle Passwort (auch im erzwungenen Fall) und
// mindestens 8 Zeichen; die Wiederholung prüft nur der Client, damit ein
// Vertipper nicht erst nach dem Speichern auffällt.
// Parameter: ev (Submit-Event). Rückgabe: keine.
async function submitPasswordChange(ev){
if(ev) ev.preventDefault();
const errEl = document.getElementById('passwordError');
const show = msg => { if(errEl){ errEl.textContent = msg; errEl.hidden = false; } };
const currentPassword = (document.getElementById('passwordCurrent') || {}).value || '';
const newPassword = (document.getElementById('passwordNew') || {}).value || '';
const repeat = (document.getElementById('passwordRepeat') || {}).value || '';
if(errEl){ errEl.textContent = ''; errEl.hidden = true; }
if(newPassword.length < 8) return show('Das neue Passwort muss mindestens 8 Zeichen haben.');
if(newPassword !== repeat) return show('Die Wiederholung stimmt nicht mit dem neuen Passwort überein.');
if(newPassword === currentPassword) return show('Das neue Passwort muss sich vom bisherigen unterscheiden.');
try {
const r = await fetch(baseUrl() + '/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ currentPassword, newPassword })
});
const d = await r.json();
if(!r.ok || !d.ok) return show(d && d.error ? d.error : 'Passwort konnte nicht geändert werden.');
} catch(err){
console.error('Passwortwechsel fehlgeschlagen:', err);
return show('Verbindung zum Server fehlgeschlagen.');
}
if(currentUser) currentUser.mustChangePassword = false;
passwordChangeForced = false;
closePasswordModal();
// Der Bereich „Einstellungen" zeigt die Kennzeichnung mit an.
if(typeof renderSettings === 'function' && typeof currentTab !== 'undefined' && currentTab === 'settings') renderSettings();
alert('Das Passwort wurde geändert.');
if(typeof einfuehrungBeiBedarf === 'function') einfuehrungBeiBedarf();
}
// checkAuthAndInit: prüft per GET /whoami, ob eine gültige Session besteht.
// Falls ja, wird currentUser gesetzt, das Overlay ausgeblendet und der
// übergebene Callback (der eigentliche Seiteneinstieg) ausgeführt. Falls
// nein, wird das Login-Overlay gezeigt und das Login-Formular gebunden; nach
// erfolgreichem Login läuft derselbe Callback an. Parameter: onAuthenticated
// (Funktion ohne Argumente).
async function checkAuthAndInit(onAuthenticated){
let data = null;
try {
const res = await fetch(baseUrl() + '/whoami');
data = await res.json();
} catch(err){
// Ohne diesen Zweig blieb bei nicht erreichbarem Server (oder einer
// HTML-Antwort einer Zwischeninstanz) eine leere, tote Seite stehen: Das
// Promise wurde abgelehnt, das Login-Fenster nie gezeigt.
console.error('Anmeldestatus konnte nicht geprüft werden:', err);
alert('Der Server unter ' + baseUrl() + ' ist nicht erreichbar.\n\n'
+ 'Bitte prüfen, ob er läuft, und die Seite anschließend neu laden.');
return;
}
if(data && data.user){
currentUser = data.user;
afterAuthenticated(onAuthenticated);
return;
}
showLoginOverlay();
verdrahteLoginHilfen();
const form = document.getElementById('loginForm');
const errEl = document.getElementById('loginError');
const btn = document.getElementById('loginSubmit');
const zeigeFehler = (text, feldId) => {
errEl.textContent = text;
errEl.hidden = false;
if(feldId){
const feld = document.getElementById(feldId);
feld.closest('.login-feld').classList.add('hat-fehler');
feld.focus();
}
};
form.onsubmit = async function(ev){
ev.preventDefault();
errEl.hidden = true;
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
if(!username) return zeigeFehler('Bitte den Benutzernamen eingeben, z. B. m.mustermann.', 'loginUsername');
if(!password) return zeigeFehler('Bitte das Passwort eingeben.', 'loginPassword');
btn.disabled = true; btn.classList.add('laedt');
try {
const r = await fetch(baseUrl() + '/login', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username, password })
});
const d = await r.json();
if(!r.ok || !d.ok){
zeigeFehler(d.error || 'Anmeldung fehlgeschlagen.', 'loginPassword');
document.getElementById('loginPassword').select();
return;
}
// Das vollständige Konto (Rechte, Rollenname, Gewerk, Rundgang …)
// liefert nur /whoami — die Antwort von /login enthält nur das Nötigste.
// Ohne diesen Abruf fehlten nach dem Anmelden alle Einträge der
// Seitenleiste, die ein Recht brauchen, bis man die Seite neu lud.
currentUser = d.user;
try {
const w = await fetch(baseUrl() + '/whoami');
const wd = await w.json();
if(wd && wd.user) currentUser = wd.user;
} catch(e){ /* Rückfall auf d.user */ }
document.getElementById('loginPassword').value = '';
afterAuthenticated(onAuthenticated);
} catch(err){
zeigeFehler('Der Server ist nicht erreichbar. Bitte später erneut versuchen.');
} finally {
btn.disabled = false; btn.classList.remove('laedt');
}
};
}