- Admin → Update: neuere Versionen (Tags) vom Git-Server anzeigen und per Knopf einspielen; ausgeführt vom Dienst auf dem Rechner (Docker/update-einrichten.sh, Docker/update-ausfuehren.sh), nur vorwärts, Fehler bei ausbleibendem Neustart, abgebrochene Läufe sperren nicht - Tests (npm test): Zusammenführen paralleler Änderungen, Rechteprüfung beim Speichern; SESSIONS_DIR lenkt den Datenordner für Testserver um - node_modules, Sicherungen und Uploads aus dem Repo genommen - cors und requireRole() entfernt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
768 lines
38 KiB
JavaScript
768 lines
38 KiB
JavaScript
// -----------------------------
|
||
// 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 <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);
|
||
}
|
||
|
||
// 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 => `<option value="${esc(r.key)}">${esc(r.name)}${nurWandtafelRolle(r.key) ? ' (nur Wandtafel)' : ''}</option>`).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 =
|
||
'<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 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>${esc(rollenName(u.role))}</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">
|
||
<span class="user-aktionen">
|
||
<button class="secondary small" onclick="openEditUserModal(${u.id})">Bearbeiten</button>
|
||
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
|
||
aria-label="Weitere Aktionen für ${esc(u.displayName || u.username)}"
|
||
onclick="openUserActionMenu(event, ${u.id})">${PUNKTE_SVG}</button>
|
||
</span>
|
||
</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, '"')
|
||
.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 <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('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 = '<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, 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 <body> 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 = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
|
||
const MENU_IC = {
|
||
schluessel: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="5.5" cy="10.5" r="3"/><path d="M7.6 8.4L13.5 2.5M11 5l1.8 1.8M12.5 3.5l1.5 1.5"/></svg>',
|
||
aus: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M4 12L12 4"/></svg>',
|
||
an: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M5.5 8.2l1.8 1.8 3.4-3.6"/></svg>',
|
||
muell: '<svg class="user-menu-ic" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 4.5h11M6 4.5V3h4v1.5M4 4.5l.7 8.5h6.6l.7-8.5"/></svg>',
|
||
};
|
||
|
||
// offenesMenu: { el, knopf, zeile, u } solange ein Menü offen ist.
|
||
let offenesMenu = null;
|
||
|
||
function menuEintrag(ic, text, aktion, opts){
|
||
opts = opts || {};
|
||
return `<button type="button" role="menuitem" class="user-menu-item${opts.gefahr ? ' is-danger' : ''}"
|
||
data-aktion="${aktion}" ${opts.gesperrt ? 'aria-disabled="true"' : ''}>
|
||
${MENU_IC[ic]}<span class="user-menu-text"><span>${esc(text)}</span>${opts.unter ? `<span class="user-menu-sub">${esc(opts.unter)}</span>` : ''}</span>
|
||
</button>`;
|
||
}
|
||
|
||
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 += '<hr class="user-menu-trenner">'
|
||
+ menuEintrag('muell', 'Endgültig löschen', 'loeschen', { gefahr: true, gesperrt: isSelf });
|
||
}
|
||
return h;
|
||
}
|
||
|
||
const RUECKFRAGEN = {
|
||
passwort: u => ({ frage: 'Passwort von <b>' + esc(u.displayName || u.username) + '</b> zurücksetzen? Alle Sitzungen werden abgemeldet.', knopf: 'Zurücksetzen' }),
|
||
deaktivieren: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> deaktivieren? Alle Sitzungen werden sofort abgemeldet.', knopf: 'Deaktivieren' }),
|
||
loeschen: u => ({ frage: '<b>' + esc(u.displayName || u.username) + '</b> 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 = `<div class="user-menu-confirm"><p>${r.frage}</p><div class="user-menu-confirm-row">
|
||
<button type="button" class="secondary small" data-zurueck="1">Abbrechen</button>
|
||
<button type="button" class="danger small" data-ausfuehren="${aktion}">${esc(r.knopf)}</button>
|
||
</div></div>`;
|
||
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; });
|
||
}
|
||
});
|
||
|
||
})();
|