diff --git a/database.js b/database.js index 3c9d037..f14f721 100644 --- a/database.js +++ b/database.js @@ -230,15 +230,32 @@ function initDatabase() { UNIQUE(schaltung_id, tarifoption_id) )`); - // Users for authentication + // Users for authentication. uuid is the stable external identifier exposed + // through the API so usernames can be renamed without breaking references. db.run(`CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT, is_admin INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); + addColumnIfMissing(db, 'users', 'uuid TEXT'); + // Backfill UUIDs for rows that pre-date the column. + db.all('SELECT id FROM users WHERE uuid IS NULL OR uuid = ""', (err, rows) => { + if (err) { + console.error('Backfill uuid lookup failed:', err.message); + return; + } + if (!rows || rows.length === 0) return; + const { randomUUID } = require('crypto'); + for (const row of rows) { + db.run('UPDATE users SET uuid = ? WHERE id = ?', [randomUUID(), row.id], (uerr) => { + if (uerr) console.error('Backfill uuid update failed:', uerr.message); + }); + } + }); // Seed data (idempotent) db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES diff --git a/public/index.html b/public/index.html index 0a48a10..1dec492 100644 --- a/public/index.html +++ b/public/index.html @@ -878,6 +878,7 @@ + @@ -1427,7 +1428,57 @@ - + + +
+

Benutzer-Verwaltung

+
+ +
+ + + + + + + + + + + +
UsernameAnzeigenameAdminErstelltAktionen
+
+ + + +
diff --git a/public/script.js b/public/script.js index 77e226b..45a2850 100644 --- a/public/script.js +++ b/public/script.js @@ -48,6 +48,10 @@ document.addEventListener('DOMContentLoaded', async function() { function renderUserBadge() { if (!currentUser) return; + // Show admin-only navigation elements (e.g. the Benutzer tab) once we know the role. + document.querySelectorAll('.admin-only').forEach(el => { + el.style.display = currentUser.isAdmin ? '' : 'none'; + }); let badge = document.getElementById('userBadge'); if (!badge) { badge = document.createElement('div'); @@ -141,6 +145,8 @@ function showTab(tabName) { if (!document.getElementById('anbieterList') || !document.getElementById('anbieterList').innerHTML.trim()) { loadAdminData(); } + } else if (tabName === 'users') { + loadUsers(); } } @@ -3751,3 +3757,117 @@ document.addEventListener('DOMContentLoaded', function() { }); } }); + +// ============================================================================ +// Benutzer-Verwaltung (admin only). Backend: /api/users/* +// ============================================================================ + +async function loadUsers() { + const tbody = document.getElementById('usersTableBody'); + if (!tbody) return; + tbody.innerHTML = 'Lade...'; + try { + const users = await apiRequest('/api/users'); + tbody.innerHTML = ''; + if (!users.length) { + tbody.innerHTML = 'Keine Benutzer'; + return; + } + users.forEach(u => { + const tr = document.createElement('tr'); + const isSelf = currentUser && currentUser.username === u.username; + tr.innerHTML = ` + ${escapeHtml(u.username)}${isSelf ? ' (Sie)' : ''} + ${escapeHtml(u.displayName || '')} + ${u.isAdmin ? '✓' : ''} + ${escapeHtml(u.createdAt || '')} + + + + + `; + tr.querySelectorAll('button[data-user-action]').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.userAction; + const uuid = btn.dataset.uuid; + if (action === 'edit') openUserModal(u); + else if (action === 'delete') deleteUser(uuid, btn.dataset.username); + }); + }); + tbody.appendChild(tr); + }); + } catch (err) { + tbody.innerHTML = 'Fehler beim Laden'; + } +} + +function openUserModal(user) { + const isEdit = !!user; + document.getElementById('userModalTitle').textContent = isEdit ? 'Benutzer bearbeiten' : 'Neuer Benutzer'; + document.getElementById('userModalUuid').value = isEdit ? user.uuid : ''; + document.getElementById('userModalUsername').value = isEdit ? user.username : ''; + document.getElementById('userModalDisplay').value = isEdit ? (user.displayName || '') : ''; + document.getElementById('userModalPassword').value = ''; + document.getElementById('userModalIsAdmin').checked = isEdit ? !!user.isAdmin : false; + document.getElementById('userModalPasswordHint').style.display = isEdit ? 'none' : ''; + document.getElementById('userModalPasswordEditHint').style.display = isEdit ? '' : 'none'; + document.getElementById('userModal').style.display = 'block'; +} + +function closeUserModal() { + document.getElementById('userModal').style.display = 'none'; +} + +async function saveUserFromModal() { + const uuid = document.getElementById('userModalUuid').value; + const username = document.getElementById('userModalUsername').value.trim(); + const displayName = document.getElementById('userModalDisplay').value.trim(); + const password = document.getElementById('userModalPassword').value; + const isAdmin = document.getElementById('userModalIsAdmin').checked; + + if (!username) { + showMessage('Username ist erforderlich', 'error'); + return; + } + + try { + if (uuid) { + // Update existing user + const body = { username, displayName, isAdmin }; + if (password) body.newPassword = password; + await apiRequest(`/api/users/${encodeURIComponent(uuid)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + showMessage(`Benutzer "${username}" aktualisiert`); + } else { + // Create new user + if (!password || password.length < 8) { + showMessage('Passwort muss mindestens 8 Zeichen lang sein', 'error'); + return; + } + await apiRequest('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, displayName, isAdmin }), + }); + showMessage(`Benutzer "${username}" angelegt`); + } + closeUserModal(); + loadUsers(); + } catch (err) { + // apiRequest already shows the error message + } +} + +async function deleteUser(uuid, username) { + if (!confirm(`Benutzer "${username}" wirklich löschen?`)) return; + try { + await apiRequest(`/api/users/${encodeURIComponent(uuid)}`, { method: 'DELETE' }); + showMessage(`Benutzer "${username}" gelöscht`); + loadUsers(); + } catch (err) { + // apiRequest already shows the error message + } +} diff --git a/routes/auth.js b/routes/auth.js index 8f7b79c..27d6e9c 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,5 +1,6 @@ const express = require('express'); const bcrypt = require('bcryptjs'); +const { randomUUID } = require('crypto'); const rateLimit = require('express-rate-limit'); const db = require('../db'); const asyncHandler = require('../middleware/asyncHandler'); @@ -35,9 +36,10 @@ router.post('/setup', asyncHandler(async (req, res) => { return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' }); } const hash = await bcrypt.hash(password, 10); + const uuid = randomUUID(); const result = await db.run( - 'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, 1)', - [username, hash, displayName || username] + 'INSERT INTO users (uuid, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, 1)', + [uuid, username, hash, displayName || username] ); req.session.userId = result.lastID; req.session.username = username; diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..135593f --- /dev/null +++ b/routes/users.js @@ -0,0 +1,132 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { randomUUID } = require('crypto'); +const db = require('../db'); +const asyncHandler = require('../middleware/asyncHandler'); +const { requireAdmin } = require('../middleware/auth'); + +const router = express.Router(); + +// All routes here are admin-only. +router.use(requireAdmin); + +function publicShape(row) { + if (!row) return null; + return { + uuid: row.uuid, + username: row.username, + displayName: row.display_name, + isAdmin: !!row.is_admin, + createdAt: row.created_at, + }; +} + +async function countAdmins() { + const row = await db.get('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1'); + return row ? row.n : 0; +} + +router.get('/', asyncHandler(async (req, res) => { + const rows = await db.all( + 'SELECT id, uuid, username, display_name, is_admin, created_at FROM users ORDER BY username' + ); + res.json(rows.map(publicShape)); +})); + +router.post('/', asyncHandler(async (req, res) => { + const { username, password, displayName, isAdmin } = req.body || {}; + if (!username || !username.trim()) return res.status(400).json({ error: 'Username ist erforderlich' }); + if (!password || password.length < 8) return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' }); + + const hash = await bcrypt.hash(password, 10); + const uuid = randomUUID(); + try { + const result = await db.run( + 'INSERT INTO users (uuid, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)', + [uuid, username.trim(), hash, displayName ? displayName.trim() : username.trim(), isAdmin ? 1 : 0] + ); + const row = await db.get('SELECT id, uuid, username, display_name, is_admin, created_at FROM users WHERE id = ?', [result.lastID]); + res.json(publicShape(row)); + } catch (err) { + if (/UNIQUE/.test(err.message)) { + return res.status(400).json({ error: 'Username existiert bereits' }); + } + throw err; + } +})); + +router.put('/:uuid', asyncHandler(async (req, res) => { + const { uuid } = req.params; + const { username, displayName, isAdmin, newPassword } = req.body || {}; + + const user = await db.get('SELECT * FROM users WHERE uuid = ?', [uuid]); + if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + + const isSelf = user.id === req.session.userId; + + // Protect against revoking the last admin's status. + if (isAdmin !== undefined && !isAdmin && user.is_admin) { + const admins = await countAdmins(); + if (admins <= 1) { + return res.status(400).json({ error: 'Der letzte Administrator kann nicht degradiert werden' }); + } + } + // Don't let an admin demote themselves accidentally. + if (isSelf && isAdmin === false) { + return res.status(400).json({ error: 'Eigene Admin-Rechte können nicht entzogen werden' }); + } + + const nextUsername = (username !== undefined && username !== null) ? String(username).trim() : user.username; + if (!nextUsername) return res.status(400).json({ error: 'Username darf nicht leer sein' }); + + const nextDisplay = (displayName !== undefined) ? (displayName ? String(displayName).trim() : null) : user.display_name; + const nextIsAdmin = (isAdmin !== undefined) ? (isAdmin ? 1 : 0) : user.is_admin; + + let nextHash = user.password_hash; + if (newPassword) { + if (newPassword.length < 8) return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen lang sein' }); + nextHash = await bcrypt.hash(newPassword, 10); + } + + try { + await db.run( + 'UPDATE users SET username = ?, display_name = ?, is_admin = ?, password_hash = ? WHERE uuid = ?', + [nextUsername, nextDisplay, nextIsAdmin, nextHash, uuid] + ); + } catch (err) { + if (/UNIQUE/.test(err.message)) { + return res.status(400).json({ error: 'Username existiert bereits' }); + } + throw err; + } + + // Keep session in sync if the current user renamed themselves or changed admin. + if (isSelf) { + req.session.username = nextUsername; + req.session.isAdmin = !!nextIsAdmin; + } + + const fresh = await db.get('SELECT id, uuid, username, display_name, is_admin, created_at FROM users WHERE uuid = ?', [uuid]); + res.json(publicShape(fresh)); +})); + +router.delete('/:uuid', asyncHandler(async (req, res) => { + const { uuid } = req.params; + const user = await db.get('SELECT * FROM users WHERE uuid = ?', [uuid]); + if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + + if (user.id === req.session.userId) { + return res.status(400).json({ error: 'Eigener Account kann nicht gelöscht werden' }); + } + if (user.is_admin) { + const admins = await countAdmins(); + if (admins <= 1) { + return res.status(400).json({ error: 'Der letzte Administrator kann nicht gelöscht werden' }); + } + } + + await db.run('DELETE FROM users WHERE uuid = ?', [uuid]); + res.json({ ok: true }); +})); + +module.exports = router; diff --git a/scripts/create-user.js b/scripts/create-user.js index f98acb0..8e3e116 100644 --- a/scripts/create-user.js +++ b/scripts/create-user.js @@ -3,6 +3,7 @@ // Useful for adding additional users outside the web UI. const bcrypt = require('bcryptjs'); +const { randomUUID } = require('crypto'); const { initDatabase, getDatabase, closeDatabase } = require('../database'); async function main() { @@ -27,17 +28,18 @@ async function main() { const db = getDatabase(); const hash = await bcrypt.hash(password, 10); + const uuid = randomUUID(); await new Promise((resolve, reject) => { db.run( - 'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?)', - [username, hash, username, isAdmin ? 1 : 0], + 'INSERT INTO users (uuid, username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)', + [uuid, username, hash, username, isAdmin ? 1 : 0], function (err) { if (err) reject(err); else resolve(this.lastID); } ); }).then((id) => { - console.log(`User '${username}' angelegt (id=${id}, admin=${isAdmin}).`); + console.log(`User '${username}' angelegt (id=${id}, uuid=${uuid}, admin=${isAdmin}).`); }).catch((err) => { if (/UNIQUE/.test(err.message)) { console.error(`User '${username}' existiert bereits.`); diff --git a/server.js b/server.js index 7cfe02e..1cc5bd6 100644 --- a/server.js +++ b/server.js @@ -129,12 +129,14 @@ const dokumenteRouter = require('./routes/dokumente'); const provisionenRouter = require('./routes/provisionen'); const provisionsregelnRouter = require('./routes/provisionsregeln'); const mitarbeiterRouter = require('./routes/mitarbeiter'); +const usersRouter = require('./routes/users'); app.use('/api/anbieter', anbieterRouter); app.use('/api/tarife', tarifeRouter); app.use('/api/schaltungen', schaltungenRouter); app.use('/api/tarifoptionen', tarifoptionenRouter); app.use('/api/mitarbeiter-prozentsaetze', mitarbeiterRouter); +app.use('/api/users', usersRouter); app.use('/api', dokumenteRouter); // /api/schaltungen/:id/dokumente, /api/dokumente/:id/* app.use('/api', provisionenRouter); // /api/provisionen, /api/schaltungen/:id/provision, /api/geloeschte-schaltungen app.use('/api', provisionsregelnRouter); // /api/provisionsregeln, /api/anbieter-provisionsregeln