// ----------------------------- // Benutzerverwaltung (Abschnitt „Benutzerverwaltung" des Bereichs #admin) // ----------------------------- // Laedt und rendert die Benutzerliste ueber die admin-only Endpunkte aus // server.js (GET/POST/PATCH/DELETE /admin/users, POST // /admin/users/:id/reset-password) und stellt die Dialoge bereit: neuen // Benutzer anlegen, bestehenden bearbeiten (inkl. Wochenstunden und // Abwesenheiten), Passwort zuruecksetzen, aktivieren/deaktivieren und // endgueltig loeschen. // // Das war bis zum Umbau eine EIGENE SEITE (public/admin-users.html) mit // eigenem Seiteneinstieg, eigener Anmeldepruefung und einer nachgebauten, // abgespeckten Kopfzeile statt der Seitenleiste. Jetzt ist es ein Abschnitt // des Bereichs „Admin" wie jeder andere: renderAdminUsers() wird von // showAdminSection() gerufen, sobald der Abschnitt sichtbar wird, und die // Seitenleiste bleibt stehen. Eine eigene Anmeldepruefung braucht es hier // nicht mehr — der Bereich ist ohnehin nur fuer Administratoren erreichbar // (showTab() in js/tabs-auth.js), und die eigentliche Sicherheitsgrenze sind // die requireRole('admin')-Endpunkte auf dem Server. (function(){ // usersCache: zuletzt vom Server geladene Benutzerliste (für die Modale, // damit sie nicht erneut nachgeladen werden muss). let usersCache = []; // editingUserId: id des im Bearbeiten-Modal geöffneten Benutzers. let editingUserId = null; // personIdManuallyEdited: verhindert, dass eine manuelle Eingabe im // Person-ID-Feld des Neu-Anlegen-Modals von der Auto-Vorschau überschrieben wird. let personIdManuallyEdited = false; // fmtCapacity: Wochenstunden in deutscher Schreibweise, ohne überflüssige // Nachkommastelle (40 statt 40,0 — aber 32,5 bleibt 32,5). // Parameter: h (Zahl). Rückgabe: String. function fmtCapacity(h){ const n = Number(h) || 0; return (Number.isInteger(n) ? String(n) : n.toFixed(1).replace('.', ',')) + ' h'; } // initialsManuallyEdited: gleiches Muster für das Kürzel-Feld. let initialsManuallyEdited = false; // editUserAbsencesCache: zuletzt geladene Abwesenheiten des im // Bearbeiten-Modal geöffneten Benutzers (für Rendern/Löschen ohne erneuten // Request pro Aktion). let editUserAbsencesCache = []; // renderDepartmentOptions: befüllt ein ${esc(r)}`).join(''); } // checkedRoleValues: liest die angehakten Fachrollen-Checkboxen eines // Containers als String-Array aus. function checkedRoleValues(containerId){ return Array.from(document.querySelectorAll('#' + containerId + ' input[type="checkbox"]:checked')).map(cb => cb.value); } // formulareVorbereitet: Abteilungs-Auswahl und Rollen-Ankreuzfelder muessen // nur einmal aufgebaut werden; renderAdminUsers() laeuft aber bei jedem // Wechsel in den Abschnitt. let formulareVorbereitet = false; // renderAdminUsers: Einstiegspunkt des Abschnitts, von showAdminSection() // gerufen. Baut beim ersten Mal die Auswahlfelder der Dialoge auf und laedt // danach die Benutzerliste. Eine Rollenpruefung findet hier nicht mehr // statt — der Bereich „Admin" ist fuer Nicht-Administratoren gar nicht // erreichbar (showTab() leitet sie um) und die Endpunkte sind serverseitig // abgesichert. Keine Parameter, keine Rueckgabe. window.renderAdminUsers = function(){ if(!formulareVorbereitet){ renderDepartmentOptions('newUserDepartment'); renderDepartmentOptions('editUserDepartment'); renderRoleCheckboxes('newUserRoleCheckboxes'); renderRoleCheckboxes('editUserRoleCheckboxes'); formulareVorbereitet = true; } loadAndRenderUsers(); }; // apiFetch: kleiner fetch-Wrapper für die /admin/users*-Endpunkte. Liefert // immer das geparste JSON zurück (auch bei Fehlerstatus), damit die // Aufrufer serverseitige Fehlermeldungen ({ok:false, error}) anzeigen können. function apiFetch(path, options){ return fetch(baseUrl() + path, Object.assign({ headers: { 'Content-Type': 'application/json; charset=utf-8' } }, options || {})).then(r => r.json().then(data => ({ status: r.status, ok: r.ok, data }))); } // loadAndRenderUsers: lädt die Benutzerliste und rendert die Tabelle. function loadAndRenderUsers(){ apiFetch('/admin/users').then(res => { if(!res.ok || !res.data || !res.data.ok){ document.getElementById('usersTableBody').innerHTML = 'Fehler beim Laden: ' + esc((res.data && res.data.error) || ('HTTP ' + res.status)) + ''; return; } usersCache = res.data.users || []; renderUsersTable(); }).catch(err => { document.getElementById('usersTableBody').innerHTML = 'Verbindung zum Server fehlgeschlagen: ' + esc(err.message) + ''; }); } // renderUsersTable: baut die Tabellenzeilen aus usersCache auf. function renderUsersTable(){ const body = document.getElementById('usersTableBody'); if(usersCache.length === 0){ body.innerHTML = 'Keine Benutzer vorhanden.'; return; } body.innerHTML = usersCache.map(u => { const statusParts = []; statusParts.push(u.active ? 'Aktiv' : 'Deaktiviert'); if(u.mustChangePassword) statusParts.push('Passwortwechsel erforderlich'); const toggleLabel = u.active ? 'Deaktivieren' : 'Aktivieren'; const isSelf = currentUser && currentUser.id === u.id; const deleteBtn = (!u.active) ? `` : ''; const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || ''); return ` ${esc(u.username)} ${esc(u.displayName)} ${esc(u.email || '')} ${u.role === 'admin' ? 'Administrator' : 'Mitglied'} ${deptLabel} ${esc(u.initials || '')} ${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : 'voll'} ${esc(u.personId || '')} ${statusParts.join('
')} ${deleteBtn} `; }).join(''); } // escJs: escaped einen String für die Einbettung in ein einfach gequotetes // JS-Literal innerhalb eines onclick-Attributs. Das doppelte // Anführungszeichen MUSS mit maskiert werden: Der Handler steht in einem // doppelt gequoteten HTML-Attribut, und ein Benutzername wie // `test" onmouseover="…` brach es sonst auf. Dasselbe gilt für spitze // Klammern, die das Attribut vorzeitig beenden könnten. function escJs(s){ return String(s || '') .replace(/\\/g, '\\\\') .replace(/'/g, "\\'") .replace(/"/g, '"') .replace(//g, '>'); } // showGeneratedPassword: zeigt ein neu generiertes Passwort einmalig und gut // sichtbar in einer Box oben auf der Seite an (zusätzlich zum alert(), da es // sonst nirgends gespeichert/angezeigt wird). function showGeneratedPassword(username, password){ const box = document.getElementById('generatedPasswordBox'); box.innerHTML = 'Neues Passwort für ' + esc(username) + ': ' + esc(password) + ' ' + '(bitte jetzt notieren/weitergeben — wird nicht erneut angezeigt).'; box.style.display = ''; alert('Neues Passwort für ' + username + ':\n\n' + password + '\n\nBitte jetzt notieren, es wird nicht erneut angezeigt.'); } // ---------- Neuer-Benutzer-Modal ---------- window.openNewUserModal = function(){ document.getElementById('newUserUsername').value = ''; document.getElementById('newUserDisplayName').value = ''; document.getElementById('newUserEmail').value = ''; document.getElementById('newUserRole').value = 'member'; document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT; document.getElementById('newUserInitials').value = ''; document.getElementById('newUserPersonId').value = ''; document.querySelectorAll('#newUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = false; }); document.getElementById('newUserPassword').value = ''; document.getElementById('newUserPassword').style.display = 'none'; document.querySelector('input[name="newUserPwMode"][value="generate"]').checked = true; document.getElementById('newUserError').textContent = ''; personIdManuallyEdited = false; initialsManuallyEdited = false; document.getElementById('newUserModal').style.display = 'flex'; document.getElementById('newUserUsername').focus(); }; window.closeNewUserModal = function(){ document.getElementById('newUserModal').style.display = 'none'; }; // onNewUserDisplayNameInput: schlägt beim Tippen des Anzeigenamens eine // Person-ID über personIdForName() vor (js/utils.js) — nur solange das Feld // nicht bereits manuell bearbeitet wurde (Kollisionsvermeidung laut Plan). window.onNewUserDisplayNameInput = function(){ const name = document.getElementById('newUserDisplayName').value; if(!personIdManuallyEdited){ document.getElementById('newUserPersonId').value = name.trim() ? personIdForName(name) : ''; } if(!initialsManuallyEdited){ document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : ''; } }; window.onNewUserPwModeChange = function(){ const manual = document.querySelector('input[name="newUserPwMode"]:checked').value === 'manual'; document.getElementById('newUserPassword').style.display = manual ? '' : 'none'; }; window.saveNewUser = function(){ const username = document.getElementById('newUserUsername').value.trim(); const displayName = document.getElementById('newUserDisplayName').value.trim(); const email = document.getElementById('newUserEmail').value.trim(); const role = document.getElementById('newUserRole').value; const department = document.getElementById('newUserDepartment').value; const initials = document.getElementById('newUserInitials').value.trim(); const capacityHours = document.getElementById('newUserCapacity').value.trim(); const personId = document.getElementById('newUserPersonId').value.trim(); const roles = checkedRoleValues('newUserRoleCheckboxes'); const pwMode = document.querySelector('input[name="newUserPwMode"]:checked').value; const errEl = document.getElementById('newUserError'); errEl.textContent = ''; if(!username || !displayName){ errEl.textContent = 'Benutzername und Anzeigename sind erforderlich.'; return; } if(!department){ errEl.textContent = 'Abteilung ist erforderlich.'; return; } // Ohne Fachrolle ist der Benutzer in der ganzen Anwendung unsichtbar: // peopleByRole() (js/utils.js) gruppiert ausschließlich über die Rollen — // das Konto erschiene weder als Board-Spalte noch in der Auslastung noch // im PDF-Export, ohne dass es dafür irgendeinen Hinweis gäbe. if(!roles || roles.length === 0){ errEl.textContent = 'Bitte mindestens eine Rolle wählen (Planung, Projektleitung oder Bauüberwachung) — sonst erscheint der Benutzer in keinem Projektboard.'; return; } // Ohne Personen-Kennung lässt sich das Konto keiner Person in den // Sitzungen zuordnen (Aufgaben im Dashboard, Zuordnungen im Board). if(!personId){ errEl.textContent = 'Bitte eine Personen-Kennung vergeben — ohne sie kann der Benutzer keiner Sitzung und keinen Aufgaben zugeordnet werden.'; return; } const body = { username, displayName, email, role, personId, department, initials, capacityHours, roles }; if(pwMode === 'manual'){ const password = document.getElementById('newUserPassword').value; if(password.length < 8){ errEl.textContent = 'Manuelles Passwort muss mindestens 8 Zeichen haben.'; return; } body.password = password; } else { body.generatePassword = true; } apiFetch('/admin/users', { method: 'POST', body: JSON.stringify(body) }).then(res => { if(!res.ok || !res.data || !res.data.ok){ errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'); return; } closeNewUserModal(); loadAndRenderUsers(); if(res.data.generatedPassword){ showGeneratedPassword(username, res.data.generatedPassword); } }).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); }; // ---------- Benutzer-bearbeiten-Modal ---------- window.openEditUserModal = function(id){ const u = usersCache.find(x => x.id === id); if(!u) return; editingUserId = id; document.getElementById('editUserUsernameLabel').textContent = 'Benutzername: ' + u.username + ' (nicht änderbar)'; document.getElementById('editUserDisplayName').value = u.displayName || ''; document.getElementById('editUserEmail').value = u.email || ''; document.getElementById('editUserRole').value = u.role; document.getElementById('editUserDepartment').value = u.department || DEFAULT_DEPARTMENT; document.getElementById('editUserInitials').value = u.initials || ''; document.getElementById('editUserCapacity').value = u.capacityHours ? u.capacityHours : ''; document.getElementById('editUserPersonId').value = u.personId || ''; const userRoles = u.roles || []; document.querySelectorAll('#editUserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.checked = userRoles.includes(cb.value); }); document.getElementById('editUserError').textContent = ''; document.getElementById('editUserAbsenceError').textContent = ''; document.getElementById('editUserAbsenceStart').value = ''; document.getElementById('editUserAbsenceEnd').value = ''; document.getElementById('editUserAbsenceNote').value = ''; editUserAbsencesCache = []; renderEditUserAbsences(); loadEditUserAbsences(id); document.getElementById('editUserModal').style.display = 'flex'; }; window.closeEditUserModal = function(){ document.getElementById('editUserModal').style.display = 'none'; editingUserId = null; editUserAbsencesCache = []; }; window.saveEditUser = function(){ if(editingUserId === null) return; const errEl = document.getElementById('editUserError'); errEl.textContent = ''; const department = document.getElementById('editUserDepartment').value; if(!department){ errEl.textContent = 'Abteilung ist erforderlich.'; return; } const body = { displayName: document.getElementById('editUserDisplayName').value.trim(), email: document.getElementById('editUserEmail').value.trim(), role: document.getElementById('editUserRole').value, department, initials: document.getElementById('editUserInitials').value.trim(), capacityHours: document.getElementById('editUserCapacity').value.trim(), personId: document.getElementById('editUserPersonId').value.trim(), roles: checkedRoleValues('editUserRoleCheckboxes'), }; apiFetch('/admin/users/' + editingUserId, { method: 'PATCH', body: JSON.stringify(body) }).then(res => { if(!res.ok || !res.data || !res.data.ok){ errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'); return; } closeEditUserModal(); loadAndRenderUsers(); }).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); }; // ---------- Abwesenheiten (Bearbeiten-Modal) ---------- // GET /admin/users liefert bewusst keine Abwesenheiten mit (server.js), daher // werden sie hier separat über GET /admin/users/:id/absences nachgeladen und // Änderungen über die bereits bestehenden requireAuth-Endpunkte // POST/DELETE /users/:id/absences vorgenommen. // loadEditUserAbsences: lädt die Abwesenheiten des gerade bearbeiteten // Benutzers und rendert die Liste im Bearbeiten-Modal. function loadEditUserAbsences(id){ apiFetch('/admin/users/' + id + '/absences').then(res => { if(editingUserId !== id) return; // Modal wurde inzwischen gewechselt/geschlossen if(!res.ok || !res.data || !res.data.ok){ document.getElementById('editUserAbsenceError').textContent = (res.data && res.data.error) || ('Fehler beim Laden (HTTP ' + res.status + ')'); return; } editUserAbsencesCache = res.data.absences || []; renderEditUserAbsences(); }).catch(err => { document.getElementById('editUserAbsenceError').textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); } // renderEditUserAbsences: baut die Chip-Liste der Abwesenheiten aus // editUserAbsencesCache auf (gleiche Markup-/CSS-Struktur wie die // Abwesenheitsliste im Projektbeteiligte-Tab: .absence-list/.absence-chip). function renderEditUserAbsences(){ const el = document.getElementById('editUserAbsenceList'); if(editUserAbsencesCache.length === 0){ el.innerHTML = 'Keine Abwesenheiten erfasst.'; return; } el.innerHTML = editUserAbsencesCache.map(a => `
${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))} ${a.note ? `${esc(a.note)}` : ''}
`).join(''); } // addEditUserAbsence: legt über POST /users/:id/absences eine neue // Abwesenheit für den gerade bearbeiteten Benutzer an und lädt die Liste neu. window.addEditUserAbsence = function(){ if(editingUserId === null) return; const errEl = document.getElementById('editUserAbsenceError'); errEl.textContent = ''; const start = document.getElementById('editUserAbsenceStart').value; const end = document.getElementById('editUserAbsenceEnd').value; const type = document.getElementById('editUserAbsenceType').value; const note = document.getElementById('editUserAbsenceNote').value.trim(); if(!start || !end){ errEl.textContent = 'Start- und Enddatum erforderlich.'; return; } apiFetch('/users/' + editingUserId + '/absences', { method: 'POST', body: JSON.stringify({ start, end, type, note }) }).then(res => { if(!res.ok || !res.data || !res.data.ok){ errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'); return; } document.getElementById('editUserAbsenceStart').value = ''; document.getElementById('editUserAbsenceEnd').value = ''; document.getElementById('editUserAbsenceNote').value = ''; loadEditUserAbsences(editingUserId); }).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); }; // deleteEditUserAbsence: löscht eine Abwesenheit über DELETE // /users/:id/absences/:absenceId und lädt die Liste neu. window.deleteEditUserAbsence = function(absenceId){ if(editingUserId === null) return; const errEl = document.getElementById('editUserAbsenceError'); errEl.textContent = ''; apiFetch('/users/' + editingUserId + '/absences/' + absenceId, { method: 'DELETE' }).then(res => { if(!res.ok || !res.data || !res.data.ok){ errEl.textContent = (res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')'); return; } loadEditUserAbsences(editingUserId); }).catch(err => { errEl.textContent = 'Verbindung zum Server fehlgeschlagen: ' + err.message; }); }; // ---------- Zeilen-Aktionen (Passwort-Reset, Aktivieren/Deaktivieren, Löschen) ---------- window.resetUserPassword = function(id, username){ if(!confirm('Passwort für "' + username + '" wirklich zurücksetzen? Bisherige Sitzungen dieses Benutzers werden beendet.')) return; apiFetch('/admin/users/' + id + '/reset-password', { method: 'POST' }).then(res => { if(!res.ok || !res.data || !res.data.ok){ alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); return; } showGeneratedPassword(username, res.data.generatedPassword); loadAndRenderUsers(); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }; window.toggleUserActive = function(id, currentlyActive){ const goingActive = !currentlyActive; if(!goingActive && !confirm('Benutzer wirklich deaktivieren? Bestehende Sitzungen werden sofort beendet.')) return; apiFetch('/admin/users/' + id, { method: 'PATCH', body: JSON.stringify({ active: goingActive }) }).then(res => { if(!res.ok || !res.data || !res.data.ok){ alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); return; } loadAndRenderUsers(); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }; window.hardDeleteUser = function(id, username){ if(!confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Das kann nicht rückgängig gemacht werden.')) return; apiFetch('/admin/users/' + id + '?hard=1', { method: 'DELETE' }).then(res => { if(!res.ok || !res.data || !res.data.ok){ alert((res.data && res.data.error) || ('Fehler (HTTP ' + res.status + ')')); return; } loadAndRenderUsers(); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }; // personIdManuallyEdited wird gesetzt, sobald der Nutzer das Person-ID-Feld // selbst bearbeitet (nach dem Laden des DOM gebunden). document.addEventListener('DOMContentLoaded', function(){ const personIdInput = document.getElementById('newUserPersonId'); if(personIdInput){ personIdInput.addEventListener('input', function(){ personIdManuallyEdited = true; }); } const initialsInput = document.getElementById('newUserInitials'); if(initialsInput){ initialsInput.addEventListener('input', function(){ initialsManuallyEdited = true; }); } }); })();