import express from 'express'; import { createHash } from 'node:crypto'; import bcrypt from 'bcryptjs'; import { pool } from '../db.js'; import { signToken, requireAuth } from '../middleware/auth.js'; import { normalizeAvatar, normalizeColor, normalizeTheme, normalizeThemeMode } from '../userFields.js'; const router = express.Router(); export function publicUser(row) { /* * Ein Design, das es nicht mehr gibt, wird als "nicht gesetzt" ausgeliefert. * * Die Prüfung beim Schreiben reicht dafür nicht: sie greift erst ab dem * Zeitpunkt, zu dem ein Wert aus der Liste fliegt. Wer ihn vorher gewählt * hatte, trägt ihn weiter in der Datenbank - und die Oberfläche bekäme einen * Namen, zu dem sie keine Beschriftung kennt. */ const theme = normalizeTheme(row.theme ?? null); return { id: row.id, username: row.username, email: row.email ?? null, name: row.name, role: row.role, color: row.color ?? null, avatar: row.avatar ?? null, theme: theme.error ? null : theme.value, theme_mode: row.theme_mode ?? null, is_active: Boolean(row.is_active), created_at: row.created_at, updated_at: row.updated_at, }; } // POST /api/auth/login { identifier, password } router.post('/login', async (req, res, next) => { const identifier = String(req.body?.identifier || '').trim(); const password = String(req.body?.password || ''); if (!identifier || !password) { return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' }); } try { const [rows] = await pool.query( 'SELECT * FROM icon_users WHERE username = ? OR email = ? LIMIT 1', [identifier, identifier.toLowerCase()] ); const user = rows[0]; // Bewusst dieselbe Meldung fuer "kein Benutzer" und "falsches Passwort", // damit sich ueber die API keine Benutzernamen aufzaehlen lassen. const invalid = () => res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' }); if (!user) return invalid(); const ok = await verifyPassword(password, user); if (!ok) return invalid(); if (!user.is_active) { return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' }); } res.json({ token: signToken(user), user: publicUser(user) }); } catch (err) { next(err); } }); /* * Passwortpruefung mit Uebergang aus dem alten CRM. * * Dort lagen die Passwoerter als ungesalzenes SHA-256 in Supabase. Solche * Hashes sind an ihrer Form erkennbar (64 Hexziffern) - ein bcrypt-Hash * beginnt immer mit $2. Stimmt ein Altpasswort, wird es im selben Zug auf * bcrypt umgeschrieben: beim naechsten Mal gilt nur noch der starke Hash. * * Das ist bewusst kein dauerhafter zweiter Weg, sondern eine Einbahnstrasse - * neu vergeben wird ausschliesslich bcrypt. */ async function verifyPassword(password, user) { const hash = String(user.password_hash ?? ''); if (/^[a-f0-9]{64}$/i.test(hash)) { const legacy = createHash('sha256').update(password, 'utf8').digest('hex'); if (legacy.toLowerCase() !== hash.toLowerCase()) return false; await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [ await bcrypt.hash(password, 12), user.id, ]); console.log(`[ICON CRM] Passwort von "${user.username}" auf bcrypt umgestellt.`); return true; } return bcrypt.compare(password, hash); } // GET /api/auth/me router.get('/me', requireAuth, (req, res) => { res.json({ user: publicUser(req.user) }); }); // PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? } // Jeder darf seine eigene Farbe, sein Foto und seine Darstellung setzen. // null bzw. "" setzt das Feld zurueck. router.patch('/me', requireAuth, async (req, res, next) => { const fields = []; const values = []; const changed = {}; for (const [field, normalize] of [ ['color', normalizeColor], ['avatar', normalizeAvatar], ['theme', normalizeTheme], ['theme_mode', normalizeThemeMode], ]) { if (req.body?.[field] === undefined) continue; const result = normalize(req.body[field]); if (result.error) return res.status(400).json({ error: result.error }); fields.push(`${field} = ?`); values.push(result.value); changed[field] = result.value; } if (fields.length === 0) { return res.status(400).json({ error: 'Keine Änderungen übergeben.' }); } try { await pool.query(`UPDATE icon_users SET ${fields.join(', ')} WHERE id = ?`, [...values, req.user.id]); res.json({ user: publicUser({ ...req.user, ...changed }) }); } catch (err) { next(err); } }); // POST /api/auth/change-password { current_password, new_password } router.post('/change-password', requireAuth, async (req, res, next) => { const current = String(req.body?.current_password || ''); const next_ = String(req.body?.new_password || ''); if (!current || !next_) { return res.status(400).json({ error: 'Aktuelles und neues Passwort sind erforderlich.' }); } if (next_.length < 8) { return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' }); } try { const [rows] = await pool.query('SELECT password_hash FROM icon_users WHERE id = ? LIMIT 1', [ req.user.id, ]); if (!rows[0]) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); const ok = await bcrypt.compare(current, rows[0].password_hash); if (!ok) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' }); await pool.query('UPDATE icon_users SET password_hash = ? WHERE id = ?', [ await bcrypt.hash(next_, 12), req.user.id, ]); res.json({ ok: true }); } catch (err) { next(err); } }); export default router;