Quellcode des ICON CRM (4)

This commit is contained in:
2026-09-14 16:55:36 +02:00
parent 28763fed6d
commit e383400d7c
25 changed files with 6030 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
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;