// ----------------------------- // 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, 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 fuer Administratoren ein. Dieser // Bereich fuehrt zu allen Admin-Aufgaben — Benutzerverwaltung, firmenweite // Vorgaben, Datenbank und Sitzungen. Es ist ein reines UI-Gate; die // eigentliche Sperre sitzt serverseitig in requireRole('admin') auf den // zugehoerigen Routen, und showTab() leitet Nicht-Administratoren zusaetzlich // vom Bereich „Admin" weg (js/tabs-auth.js). function renderUserBar(){ const info = document.getElementById('userBarInfo'); if(info && currentUser){ const roleLabel = systemRolleText(currentUser.role); info.textContent = (currentUser.displayName || currentUser.username) + ' (' + roleLabel + ')'; } const navAdminSection = document.getElementById('navAdminSection'); if(navAdminSection) navAdminSection.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none'; 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 (Projektleiter und Administratoren). 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 !!currentUser && (currentUser.role === 'admin' || currentUser.role === 'projektleiter'); } // 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(); if(currentUser && currentUser.mustChangePassword) openPasswordModal(true); } // ---------- 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.'); } // 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; } currentUser = 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'); } }; }