- Neue Rolle "Projektleiter": Projekte anlegen und löschen nur noch für Projektleiter und Administratoren (serverseitig in /save-week geprüft) - Papierkorb: gelöschte Projekte 30 Tage durch Admins wiederherstellbar - Benutzerverwaltung: Aktionen im Drei-Punkte-Menü mit Rückfrage im Menü, getrennte Felder für Vor- und Nachname, E-Mail-Vorschlag v.nachname@rauschenberg-ing.de, Benutzername = Anfang der E-Mail, Person-ID aus dem Kürzel - Endgültiges Löschen eines Benutzers entfernt ihn auch aus den Projekten und Aufgaben der aktuellen Sitzung; Dateien bleiben als "Gelöschter Benutzer" - Login-Seite neu (Design "Kalenderwoche") mit Logo als Wasserzeichen; Anmeldung ohne Groß-/Kleinschreibung, auch mit voller E-Mail-Adresse Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
13 KiB
JavaScript
291 lines
13 KiB
JavaScript
// -----------------------------
|
|
// 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 '<li' + (istHeute ? ' class="ist-heute" aria-current="date"' : '') + '><span>' + n + '</span><b>' + d.getDate() + '</b></li>';
|
|
}).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');
|
|
}
|
|
};
|
|
}
|