Files
projektuebersicht/public/js/admin-users.js
erik 5b20adee48 All new
alle new
2026-10-01 06:46:51 +02:00

481 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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 <select> mit einer <option> pro
// Eintrag in DEPARTMENTS (js/globals.js) — bewusst ohne Leer-Option, die
// Abteilung ist ein Pflichtfeld.
function renderDepartmentOptions(selectId){
const sel = document.getElementById(selectId);
if(!sel) return;
sel.innerHTML = Object.keys(DEPARTMENTS).map(key =>
`<option value="${esc(key)}">${esc(DEPARTMENTS[key].label)}</option>`).join('');
}
// renderRoleCheckboxes: befüllt einen Container mit einer Checkbox pro
// Eintrag in ROLES (js/globals.js) — die fachlichen Rollen
// (Planung/Projektleitung/Bauüberwachung), nicht zu verwechseln mit der
// Systemrolle (Mitglied/Administrator) im "Rolle"-Feld.
function renderRoleCheckboxes(containerId){
const el = document.getElementById(containerId);
if(!el) return;
el.innerHTML = ROLES.map(r =>
`<label><input type="checkbox" value="${esc(r)}"> ${esc(r)}</label>`).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 =
'<tr><td colspan="9">Fehler beim Laden: ' + esc((res.data && res.data.error) || ('HTTP ' + res.status)) + '</td></tr>';
return;
}
usersCache = res.data.users || [];
renderUsersTable();
}).catch(err => {
document.getElementById('usersTableBody').innerHTML =
'<tr><td colspan="9">Verbindung zum Server fehlgeschlagen: ' + esc(err.message) + '</td></tr>';
});
}
// renderUsersTable: baut die Tabellenzeilen aus usersCache auf.
function renderUsersTable(){
const body = document.getElementById('usersTableBody');
if(usersCache.length === 0){
body.innerHTML = '<tr><td colspan="9">Keine Benutzer vorhanden.</td></tr>';
return;
}
body.innerHTML = usersCache.map(u => {
const statusParts = [];
statusParts.push(u.active ? '<span class="badge-active">Aktiv</span>' : '<span class="badge-inactive">Deaktiviert</span>');
if(u.mustChangePassword) statusParts.push('<span class="badge-warn">Passwortwechsel erforderlich</span>');
const toggleLabel = u.active ? 'Deaktivieren' : 'Aktivieren';
const isSelf = currentUser && currentUser.id === u.id;
const deleteBtn = (!u.active)
? `<button class="danger small" ${isSelf ? 'disabled' : ''} onclick="hardDeleteUser(${u.id}, '${escJs(u.username)}')">Endgültig löschen</button>`
: '';
const deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || '');
return `<tr>
<td>${esc(u.username)}</td>
<td>${esc(u.displayName)}</td>
<td>${esc(u.email || '')}</td>
<td>${u.role === 'admin' ? 'Administrator' : 'Mitglied'}</td>
<td>${deptLabel}</td>
<td>${esc(u.initials || '')}</td>
<td>${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : '<span class="hint" style="margin:0">voll</span>'}</td>
<td>${esc(u.personId || '')}</td>
<td>${statusParts.join('<br>')}</td>
<td class="actions-cell">
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
<button class="secondary small" onclick="resetUserPassword(${u.id}, '${escJs(u.username)}')">Passwort zurücksetzen</button>
<button class="secondary small" ${isSelf ? 'disabled title="Sie können sich nicht selbst deaktivieren."' : ''} onclick="toggleUserActive(${u.id}, ${u.active})">${toggleLabel}</button>
${deleteBtn}
</td>
</tr>`;
}).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, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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 <b>' + esc(username) + '</b>: <b>' + esc(password) + '</b> ' +
'(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 = '<span class="hint">Keine Abwesenheiten erfasst.</span>';
return;
}
el.innerHTML = editUserAbsencesCache.map(a => `<div class="absence-chip">
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
${a.note ? `<small>${esc(a.note)}</small>` : ''}
<button class="danger small" onclick="deleteEditUserAbsence('${escJs(a.id)}')">×</button>
</div>`).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; });
}
});
})();