import express from 'express'; import bcrypt from 'bcryptjs'; import { pool } from '../db.js'; import { requireAuth, requireAdmin } from '../middleware/auth.js'; import { publicUser } from './auth.js'; import { normalizeAvatar, normalizeColor } from '../userFields.js'; import { logHistory } from '../history.js'; const router = express.Router(); router.use(requireAuth); /* * Ausnahme von der Admin-Regel: die Benutzerliste brauchen alle Rollen - * fuer @-Erwaehnungen, Zustaendigkeiten, Farben und die Historie. Der * Endpunkt liefert deshalb bewusst nur die Anzeigefelder. */ router.get('/directory', async (req, res, next) => { try { const [rows] = await pool.query( `SELECT id, username, name, role, color, avatar, is_active FROM icon_users ORDER BY name` ); res.json({ users: rows.map((row) => ({ ...row, is_active: Boolean(row.is_active) })) }); } catch (err) { next(err); } }); router.use(requireAdmin); router.get('/', async (req, res, next) => { try { const [rows] = await pool.query( `SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active, created_at, updated_at FROM icon_users ORDER BY name` ); res.json({ users: rows.map(publicUser) }); } catch (err) { next(err); } }); router.post('/', async (req, res, next) => { const username = String(req.body?.username || '').trim(); const name = String(req.body?.name || '').trim(); const email = String(req.body?.email || '').trim().toLowerCase() || null; const role = req.body?.role === 'admin' ? 'admin' : 'user'; const password = String(req.body?.password || ''); if (!username || !name) return res.status(400).json({ error: 'Benutzername und Name sind erforderlich.' }); if (password.length < 8) return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen haben.' }); const color = normalizeColor(req.body?.color); if (color.error) return res.status(400).json({ error: color.error }); const avatar = normalizeAvatar(req.body?.avatar); if (avatar.error) return res.status(400).json({ error: avatar.error }); try { const [result] = await pool.query( `INSERT INTO icon_users (username, email, name, role, password_hash, color, avatar, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, [username, email, name, role, await bcrypt.hash(password, 12), color.value, avatar.value] ); await logHistory({ entityType: 'user', entityId: result.insertId, entityLabel: name, action: 'created', text: `Benutzer ${name} angelegt`, userId: req.user.id, }); const [rows] = await pool.query('SELECT * FROM icon_users WHERE id = ?', [result.insertId]); res.status(201).json({ user: publicUser(rows[0]) }); } catch (err) { if (err.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'Benutzername oder E-Mail ist bereits vergeben.' }); } next(err); } }); router.patch('/:id', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' }); try { const [rows] = await pool.query('SELECT * FROM icon_users WHERE id = ? LIMIT 1', [id]); const target = rows[0]; if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); const fields = []; const values = []; if (req.body?.name !== undefined) { const name = String(req.body.name).trim(); if (!name) return res.status(400).json({ error: 'Der Name darf nicht leer sein.' }); fields.push('name = ?'); values.push(name); } if (req.body?.email !== undefined) { fields.push('email = ?'); values.push(String(req.body.email).trim().toLowerCase() || null); } if (req.body?.color !== undefined) { const color = normalizeColor(req.body.color); if (color.error) return res.status(400).json({ error: color.error }); fields.push('color = ?'); values.push(color.value); } if (req.body?.avatar !== undefined) { const avatar = normalizeAvatar(req.body.avatar); if (avatar.error) return res.status(400).json({ error: avatar.error }); fields.push('avatar = ?'); values.push(avatar.value); } if (req.body?.password) { const password = String(req.body.password); if (password.length < 8) return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen haben.' }); fields.push('password_hash = ?'); values.push(await bcrypt.hash(password, 12)); } // Sicherheitsnetz: der letzte aktive Administrator darf sich weder // degradieren noch abschalten - sonst kommt niemand mehr an die // Nutzerverwaltung. const losesAdmin = (req.body?.role !== undefined && req.body.role !== 'admin' && target.role === 'admin') || (req.body?.is_active !== undefined && !req.body.is_active && target.role === 'admin'); if (losesAdmin && (await countOtherActiveAdmins(id)) === 0) { return res.status(400).json({ error: 'Der letzte aktive Administrator kann nicht geändert werden.' }); } if (req.body?.role !== undefined) { fields.push('role = ?'); values.push(req.body.role === 'admin' ? 'admin' : 'user'); } if (req.body?.is_active !== undefined) { fields.push('is_active = ?'); values.push(req.body.is_active ? 1 : 0); } if (fields.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); await pool.query(`UPDATE icon_users SET ${fields.join(', ')} WHERE id = ?`, [...values, id]); await logHistory({ entityType: 'user', entityId: id, entityLabel: target.name, action: 'updated', text: `Benutzer ${target.name} bearbeitet`, userId: req.user.id, }); const [updated] = await pool.query('SELECT * FROM icon_users WHERE id = ?', [id]); res.json({ user: publicUser(updated[0]) }); } catch (err) { if (err.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'Benutzername oder E-Mail ist bereits vergeben.' }); } next(err); } }); router.delete('/:id', async (req, res, next) => { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungültige Kennung.' }); if (id === req.user.id) return res.status(400).json({ error: 'Das eigene Konto kann nicht gelöscht werden.' }); try { const [rows] = await pool.query('SELECT * FROM icon_users WHERE id = ? LIMIT 1', [id]); const target = rows[0]; if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); if (target.role === 'admin' && (await countOtherActiveAdmins(id)) === 0) { return res.status(400).json({ error: 'Der letzte aktive Administrator kann nicht gelöscht werden.' }); } await pool.query('DELETE FROM icon_users WHERE id = ?', [id]); await logHistory({ entityType: 'user', entityId: null, entityLabel: target.name, action: 'deleted', text: `Benutzer ${target.name} gelöscht`, userId: req.user.id, }); res.json({ ok: true }); } catch (err) { next(err); } }); async function countOtherActiveAdmins(excludeId) { const [rows] = await pool.query( "SELECT COUNT(*) AS count FROM icon_users WHERE role = 'admin' AND is_active = 1 AND id <> ?", [excludeId] ); return Number(rows[0].count); } export default router;