// ----------------------------- // 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 Endpunkte mit requireRecht('a.benutzer') 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; // emailManuallyEdited: wie oben, für den E-Mail-Vorschlag (emailVorschlag()). let emailManuallyEdited = false; // usernameManuallyEdited: wie oben, für den Benutzernamen ("Vorname Nachname"). let usernameManuallyEdited = 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); } // systemRollen: die Rollen aus „Rollen & Rechte“ (GET /rollen) für die // Auswahl „Rolle“ und die Anzeige in der Tabelle. let systemRollen = []; function ladeSystemRollen(){ fetch(baseUrl() + '/rollen') .then(r => r.json()) .then(j => { if(!j || !j.ok) return; systemRollen = j.rollen || []; ['newUserRole', 'editUserRole'].forEach(id => { const sel = document.getElementById(id); if(!sel) return; const wert = sel.value; sel.innerHTML = systemRollen.map(r => ``).join(''); if(wert && systemRollen.some(r => r.key === wert)) sel.value = wert; }); loadAndRenderUsers(); }) .catch(err => console.error('Rollen laden fehlgeschlagen:', err)); } // nurWandtafelRolle: Rolle ohne „Anwendung nutzen“ — sieht nur die // Wandtafel und trägt keine Fachrolle (früher das Kiosk-Konto). function nurWandtafelRolle(key){ const r = systemRollen.find(x => x.key === key); return !!r && !r.fest && !(r.rechte || []).includes('app'); } function rollenName(key){ const r = systemRollen.find(x => x.key === key); return r ? r.name : systemRolleText(key); } // onUserRoleChange: wird beim Wechsel der Systemrolle in einem der beiden // Formulare aufgerufen (which = 'new'|'edit'). Ein Kiosk-Konto kann keine // Fachrolle haben (server.js weist das sonst mit 400 zurück) — die // Checkboxen werden deshalb schon hier abgehakt entfernt und gesperrt, // statt den Fehler erst nach dem Absenden zu zeigen. window.onUserRoleChange = function(which){ const istKiosk = nurWandtafelRolle(document.getElementById(which + 'UserRole').value); const hint = document.getElementById(which + 'UserRoleHint'); if(hint) hint.style.display = istKiosk ? '' : 'none'; document.querySelectorAll('#' + which + 'UserRoleCheckboxes input[type="checkbox"]').forEach(cb => { cb.disabled = istKiosk; if(istKiosk) cb.checked = false; }); }; // 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(){ ladeSystemRollen(); 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 deptLabel = (DEPARTMENTS[u.department] && DEPARTMENTS[u.department].label) || esc(u.department || ''); return ` ${esc(u.username)} ${esc(u.displayName)} ${esc(u.email || '')} ${esc(rollenName(u.role))} ${deptLabel} ${esc(u.initials || '')} ${u.capacityHours ? esc(fmtCapacity(u.capacityHours)) : 'voll'} ${esc(u.personId || '')} ${statusParts.join('
')} `; }).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('newUserFirstName').value = ''; document.getElementById('newUserLastName').value = ''; document.getElementById('newUserEmail').value = ''; document.getElementById('newUserRole').value = systemRollen.some(r => r.key === 'member') ? 'member' : ((systemRollen.find(r => !r.fest) || {}).key || ''); onUserRoleChange('new'); document.getElementById('newUserDepartment').value = DEFAULT_DEPARTMENT; document.getElementById('newUserInitials').value = ''; document.getElementById('newUserPersonId').value = ''; // Sonst übernahm das nächste Konto unbemerkt die Wochenstunden des // zuvor abgebrochenen Dialogs (Teilzeit ohne Absicht). document.getElementById('newUserCapacity').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; emailManuallyEdited = false; usernameManuallyEdited = false; document.getElementById('newUserModal').style.display = 'flex'; document.getElementById('newUserFirstName').focus(); }; window.closeNewUserModal = function(){ document.getElementById('newUserModal').style.display = 'none'; }; // onNewUserNameInput: schlägt beim Tippen von Vor-/Nachname E-Mail, // Benutzername (= Anfang der E-Mail, z. B. m.koerner), Person-ID // (personIdForName()) und Kürzel vor (js/utils.js) — jedes Feld nur, // solange es nicht bereits manuell bearbeitet wurde. window.onNewUserNameInput = function(){ const vorname = document.getElementById('newUserFirstName').value; const nachname = document.getElementById('newUserLastName').value; const name = (vorname.trim() + ' ' + nachname.trim()).trim(); if(!initialsManuallyEdited){ document.getElementById('newUserInitials').value = name.trim() ? initialsForName(name) : ''; } personIdAusKuerzel(); // Firmen-Adresse nach dem Schema v.nachname@rauschenberg-ing.de // (emailVorschlag(), js/utils.js) — wie Person-ID und Kürzel nur, // solange das Feld nicht von Hand geändert wurde. if(!emailManuallyEdited){ document.getElementById('newUserEmail').value = emailVorschlag(vorname, nachname); } benutzernameAusEmail(); }; // personIdAusKuerzel: Person-ID aus dem Kürzel ("MK" → "p_mk") — dasselbe // Schema, das alle Bestandskonten tragen (ihre IDs stammen aus der Zeit, // als die Personenliste über das Kürzel geführt wurde) und an dem ihre // Projektzuordnungen hängen. Vorher kam die ID aus dem vollen Namen // ("p_miriam_k_rner"), sodass neue und alte Konten verschieden aussahen. // Ist die ID schon vergeben, wird eine Ziffer angehängt (p_mk2, p_mk3 …), // denn person_id ist eindeutig. Folgt dem Kürzel, solange die ID nicht // von Hand geändert wurde. function personIdAusKuerzel(){ if(personIdManuallyEdited) return; const kuerzel = document.getElementById('newUserInitials').value.trim(); const feld = document.getElementById('newUserPersonId'); if(!kuerzel){ feld.value = ''; return; } const basis = personIdForName(kuerzel); const vergeben = new Set(usersCache.map(u => u.personId).filter(Boolean)); let id = basis, n = 2; while(vergeben.has(id)) id = basis + (n++); feld.value = id; } // benutzernameAusEmail: Benutzername = Anfang der E-Mail-Adresse. Folgt // der Adresse auch dann, wenn sie von Hand geändert wird (z. B. bei einem // Doppelnamen) — solange der Benutzername selbst nicht von Hand geändert // wurde. function benutzernameAusEmail(){ if(usernameManuallyEdited) return; const adresse = document.getElementById('newUserEmail').value.trim(); document.getElementById('newUserUsername').value = adresse.includes('@') ? adresse.slice(0, adresse.indexOf('@')) : adresse; } 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(); // Mit großem Anfangsbuchstaben gespeichert. const firstName = grossAnfang(document.getElementById('newUserFirstName').value); const lastName = grossAnfang(document.getElementById('newUserLastName').value); 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(!firstName || !lastName){ errEl.textContent = 'Vor- und Nachname sind erforderlich.'; return; } if(!username){ errEl.textContent = 'Benutzername ist 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. Für ein // Kiosk-Konto gilt das nicht — es soll gerade NICHT in Boards/Auslastung // auftauchen, server.js weist eine gesetzte Fachrolle dort sogar zurück. if(!nurWandtafelRolle(role) && (!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, firstName, lastName, 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(); // Der Server hat das Konto in den Teilnehmerkreis der aktuellen // Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis // zum nächsten Seitenaufruf in Projekten und im Assistenten. if(res.data.sitzungGeaendert && typeof load === 'function') load(true); 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)'; // Konten von vor der Umstellung tragen evtl. noch keinen Vor-/Nachnamen: // dann aus dem Anzeigenamen geteilt (erstes Wort / Rest). const teile = String(u.displayName || '').trim().split(/\s+/).filter(Boolean); document.getElementById('editUserFirstName').value = u.firstName || u.lastName ? (u.firstName || '') : (teile[0] || ''); document.getElementById('editUserLastName').value = u.firstName || u.lastName ? (u.lastName || '') : teile.slice(1).join(' '); document.getElementById('editUserEmail').value = u.email || ''; onEditUserNameInput(); 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); }); onUserRoleChange('edit'); 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 = { firstName: grossAnfang(document.getElementById('editUserFirstName').value), lastName: grossAnfang(document.getElementById('editUserLastName').value), 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(); // Der Server hat das Konto in den Teilnehmerkreis der aktuellen // Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis // zum nächsten Seitenaufruf in Projekten und im Assistenten. if(res.data.sitzungGeaendert && typeof load === 'function') load(true); }).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, ohneRueckfrage){ if(!ohneRueckfrage && !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, ohneRueckfrage){ const goingActive = !currentlyActive; if(!goingActive && !ohneRueckfrage && !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(); // Der Server hat das Konto in den Teilnehmerkreis der aktuellen // Sitzung eingetragen — neu laden, sonst fehlt die Person hier bis // zum nächsten Seitenaufruf in Projekten und im Assistenten. if(res.data.sitzungGeaendert && typeof load === 'function') load(true); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }; window.hardDeleteUser = function(id, username, ohneRueckfrage){ if(!ohneRueckfrage && !confirm('Benutzer "' + username + '" wirklich ENDGÜLTIG löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. 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(); // Der Server hat die Person aus der aktuellen Sitzung entfernt (Projekt- // zuordnungen, Aufgaben, Teilnehmerkreis). Neu laden, damit Projekte // und Auslastung in diesem Browser sofort ohne sie dastehen und das // nächste Speichern nicht auf einem veralteten Stand aufsetzt. if(typeof load === 'function') load(true); }).catch(err => alert('Verbindung zum Server fehlgeschlagen: ' + err.message)); }; // ---------- Drei-Punkte-Menü der Zeilen ---------- // „Bearbeiten" bleibt als Knopf in der Zeile, die selteneren Aktionen // liegen hinter den drei Punkten (Eriks Wahl „C" aus dem Vergleich der // Menü-Varianten). Was nicht rückgängig zu machen ist — Passwort // zurücksetzen, Deaktivieren, Endgültig löschen — fragt direkt im Menü // nach, statt ein Browser-Dialogfenster zu öffnen. // // Das Menü hängt an und steht `position: fixed`: Die Tabelle liegt // in einem quer scrollbaren Rahmen, der ein darin verankertes Menü // abschneiden würde. Reicht der Platz unter dem Knopf nicht, klappt es nach // oben auf. Scrollen oder Fenstergröße ändern schließt es. const PUNKTE_SVG = ''; const MENU_IC = { schluessel: '', aus: '', an: '', muell: '', }; // offenesMenu: { el, knopf, zeile, u } solange ein Menü offen ist. let offenesMenu = null; function menuEintrag(ic, text, aktion, opts){ opts = opts || {}; return ``; } function menuInhalt(u){ const isSelf = currentUser && currentUser.id === u.id; let h = menuEintrag('schluessel', 'Passwort zurücksetzen', 'passwort'); h += u.active ? menuEintrag('aus', 'Deaktivieren', 'deaktivieren', { gesperrt: isSelf, unter: isSelf ? 'Eigenes Konto' : '' }) : menuEintrag('an', 'Aktivieren', 'aktivieren'); if(!u.active){ h += '
' + menuEintrag('muell', 'Endgültig löschen', 'loeschen', { gefahr: true, gesperrt: isSelf }); } return h; } const RUECKFRAGEN = { passwort: u => ({ frage: 'Passwort von ' + esc(u.displayName || u.username) + ' zurücksetzen? Alle Sitzungen werden abgemeldet.', knopf: 'Zurücksetzen' }), deaktivieren: u => ({ frage: '' + esc(u.displayName || u.username) + ' deaktivieren? Alle Sitzungen werden sofort abgemeldet.', knopf: 'Deaktivieren' }), loeschen: u => ({ frage: '' + esc(u.displayName || u.username) + ' endgültig löschen? Die Person wird auch aus allen Projekten und Aufgaben der aktuellen Sitzung entfernt. Das lässt sich nicht rückgängig machen.', knopf: 'Löschen' }), }; function menuFokusziele(){ return offenesMenu ? Array.from(offenesMenu.el.querySelectorAll('button:not([aria-disabled="true"])')) : []; } function menuPositionieren(){ if(!offenesMenu) return; const { el, knopf } = offenesMenu; const r = knopf.getBoundingClientRect(); const m = el.getBoundingClientRect(); const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12; el.classList.toggle('nach-oben', nachOben); el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px'; el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px'; } function schliesseUserMenu(fokusZurueck){ if(!offenesMenu) return; const { el, knopf, zeile } = offenesMenu; offenesMenu = null; el.remove(); knopf.setAttribute('aria-expanded', 'false'); if(zeile) zeile.classList.remove('is-menu-open'); if(fokusZurueck && document.contains(knopf)) knopf.focus(); } function zeigeMenuEintraege(){ offenesMenu.el.innerHTML = menuInhalt(offenesMenu.u); menuPositionieren(); } function fuehreAus(aktion, u){ schliesseUserMenu(true); if(aktion === 'passwort') resetUserPassword(u.id, u.username, true); else if(aktion === 'deaktivieren') toggleUserActive(u.id, true, true); else if(aktion === 'aktivieren') toggleUserActive(u.id, false, true); else if(aktion === 'loeschen') hardDeleteUser(u.id, u.username, true); } // openUserActionMenu: öffnet (bzw. schließt bei erneutem Klick) das Menü // einer Zeile. Per Tastatur geöffnet (event.detail === 0) landet der Fokus // gleich auf dem ersten Eintrag. window.openUserActionMenu = function(event, id){ event.stopPropagation(); const knopf = event.currentTarget; if(offenesMenu && offenesMenu.knopf === knopf){ schliesseUserMenu(true); return; } schliesseUserMenu(false); const u = usersCache.find(x => x.id === id); if(!u) return; const el = document.createElement('div'); el.className = 'user-menu'; el.setAttribute('role', 'menu'); document.body.appendChild(el); const zeile = knopf.closest('tr'); offenesMenu = { el, knopf, zeile, u }; knopf.setAttribute('aria-expanded', 'true'); if(zeile) zeile.classList.add('is-menu-open'); zeigeMenuEintraege(); el.addEventListener('click', e => { e.stopPropagation(); const b = e.target.closest('button'); if(!b || b.getAttribute('aria-disabled') === 'true') return; if(b.dataset.zurueck){ zeigeMenuEintraege(); const z = menuFokusziele(); if(z[0]) z[0].focus(); return; } if(b.dataset.ausfuehren){ fuehreAus(b.dataset.ausfuehren, u); return; } const aktion = b.dataset.aktion; if(RUECKFRAGEN[aktion]){ const r = RUECKFRAGEN[aktion](u); el.innerHTML = `

${r.frage}

`; menuPositionieren(); el.querySelector('[data-ausfuehren]').focus(); return; } fuehreAus(aktion, u); }); if(event.detail === 0){ const z = menuFokusziele(); if(z[0]) z[0].focus(); } }; document.addEventListener('click', () => schliesseUserMenu(false)); document.addEventListener('keydown', e => { if(!offenesMenu) return; const ziele = menuFokusziele(); const i = ziele.indexOf(document.activeElement); if(e.key === 'Escape'){ e.preventDefault(); schliesseUserMenu(true); } else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); } else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); } else if(e.key === 'Tab'){ schliesseUserMenu(false); } }); window.addEventListener('resize', () => schliesseUserMenu(false)); window.addEventListener('scroll', () => schliesseUserMenu(false), true); // onEditUserNameInput: Im Bearbeiten-Dialog wird eine vorhandene Adresse // nie überschrieben; ist das E-Mail-Feld leer, steht der Vorschlag als // Platzhalter darin und folgt Vor-/Nachname. window.onEditUserNameInput = function(){ const feld = document.getElementById('editUserEmail'); if(!feld) return; feld.placeholder = emailVorschlag(document.getElementById('editUserFirstName').value, document.getElementById('editUserLastName').value) || 'v.nachname@' + EMAIL_DOMAIN; }; // 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; personIdAusKuerzel(); }); } const emailInput = document.getElementById('newUserEmail'); if(emailInput){ emailInput.addEventListener('input', function(){ emailManuallyEdited = true; benutzernameAusEmail(); }); } const usernameInput = document.getElementById('newUserUsername'); if(usernameInput){ usernameInput.addEventListener('input', function(){ usernameManuallyEdited = true; }); } }); })();