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
+
+
+
+
+
+
+ | Username |
+ Anzeigename |
+ Admin |
+ Erstellt |
+ Aktionen |
+
+
+
+
+
+
+
+
+
+
Neuer Benutzer
+
+
+
+
+
+
+
+
+
+
+
+
+ Leer lassen, um Passwort unverändert zu lassen
+
+
+
+
+
+
+
+
+
+
+
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