// Admin Routes const bcrypt = require('bcryptjs'); const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); const { db } = require('../database'); const { requireAdmin } = require('../middleware/auth'); const { testMssqlConnection } = require('../services/mssql-infra-service'); const { getPdfBaseDir } = require('../helpers/pdf-paths'); const YEAR_RE = /^\d{4}$/; const USER_ID_RE = /^\d+$/; const PDF_NAME_RE = /^[A-Za-z0-9._-]+\.pdf$/i; function resolveWithinBaseDir(baseDirResolved, ...parts) { const targetPath = path.resolve(baseDirResolved, ...parts); const prefix = baseDirResolved.endsWith(path.sep) ? baseDirResolved : baseDirResolved + path.sep; if (targetPath !== baseDirResolved && !targetPath.startsWith(prefix)) return null; return targetPath; } // Routes registrieren function registerAdminRoutes(app) { // Admin-Bereich app.get('/admin', requireAdmin, (req, res) => { db.all( 'SELECT id, username, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, arbeitstage, default_break_minutes, created_at FROM users ORDER BY created_at DESC', (err, users) => { // LDAP-Konfiguration, Sync-Log, Optionen und MSSQL-Konfiguration abrufen db.get('SELECT * FROM ldap_config WHERE id = 1', (err, ldapConfig) => { db.all('SELECT * FROM ldap_sync_log ORDER BY sync_started_at DESC LIMIT 10', (err, syncLogs) => { db.get('SELECT * FROM system_options WHERE id = 1', (err, options) => { db.get('SELECT * FROM mssql_config WHERE id = 1', (err, mssqlConfig) => { // Parse Rollen für jeden User const usersWithRoles = (users || []).map(u => { let roles = []; try { roles = JSON.parse(u.role); if (!Array.isArray(roles)) { roles = [u.role]; } } catch (e) { roles = [u.role || 'mitarbeiter']; } return { ...u, roles }; }); res.render('admin', { users: usersWithRoles, ldapConfig: ldapConfig || null, syncLogs: syncLogs || [], options: options || { saturday_percentage: 100, sunday_percentage: 100 }, mssqlConfig: mssqlConfig || null, user: { firstname: req.session.firstname, lastname: req.session.lastname, roles: req.session.roles || [], currentRole: req.session.currentRole || 'admin' } }); }); }); }); }); } ); }); // Benutzer erstellen app.post('/admin/users', requireAdmin, (req, res) => { const { username, password, firstname, lastname, roles, personalnummer, wochenstunden, urlaubstage, arbeitstage, default_break_minutes } = req.body; const hashedPassword = bcrypt.hashSync(password, 10); // Normalisiere die optionalen Felder const normalizedPersonalnummer = personalnummer && personalnummer.trim() !== '' ? personalnummer.trim() : null; const normalizedWochenstunden = wochenstunden && wochenstunden !== '' ? parseFloat(wochenstunden) : null; const normalizedUrlaubstage = urlaubstage && urlaubstage !== '' ? parseFloat(urlaubstage) : null; const normalizedArbeitstage = arbeitstage && arbeitstage !== '' ? parseInt(arbeitstage) : 5; const parsedBreak = default_break_minutes !== undefined && default_break_minutes !== '' ? parseInt(default_break_minutes, 10) : 30; const normalizedDefaultBreak = (!isNaN(parsedBreak) && parsedBreak >= 0) ? parsedBreak : 30; // Rollen verarbeiten: Erwarte Array, konvertiere zu JSON-String let rolesArray = []; if (Array.isArray(roles)) { rolesArray = roles.filter(r => r && ['mitarbeiter', 'verwaltung', 'admin'].includes(r)); } else if (roles) { // Fallback: Einzelne Rolle als Array rolesArray = [roles]; } // Mindestens eine Rolle erforderlich if (rolesArray.length === 0) { rolesArray = ['mitarbeiter']; // Standard-Rolle } const rolesJson = JSON.stringify(rolesArray); db.run('INSERT INTO users (username, password, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, arbeitstage, default_break_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [username, hashedPassword, firstname, lastname, rolesJson, normalizedPersonalnummer, normalizedWochenstunden, normalizedUrlaubstage, normalizedArbeitstage, normalizedDefaultBreak], (err) => { if (err) { return res.status(400).json({ error: 'Benutzername existiert bereits' }); } res.json({ success: true }); }); }); // Benutzer löschen app.delete('/admin/users/:id', requireAdmin, (req, res) => { const userId = req.params.id; // Admin darf sich nicht selbst löschen if (userId == req.session.userId) { return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' }); } db.run('DELETE FROM users WHERE id = ?', [userId], (err) => { if (err) { return res.status(500).json({ error: 'Fehler beim Löschen' }); } res.json({ success: true }); }); }); // Benutzer aktualisieren (Personalnummer, Wochenstunden, Urlaubstage, Rollen, Standard-Pause) app.put('/admin/users/:id', requireAdmin, (req, res) => { const userId = req.params.id; const { personalnummer, wochenstunden, urlaubstage, arbeitstage, roles, default_break_minutes } = req.body; const parsedBreak = default_break_minutes !== undefined && default_break_minutes !== '' ? parseInt(default_break_minutes, 10) : 30; const normalizedDefaultBreak = (!isNaN(parsedBreak) && parsedBreak >= 0) ? parsedBreak : 30; // Rollen verarbeiten falls vorhanden let rolesJson = null; if (roles !== undefined) { let rolesArray = []; if (Array.isArray(roles)) { rolesArray = roles.filter(r => r && ['mitarbeiter', 'verwaltung', 'admin'].includes(r)); } // Mindestens eine Rolle erforderlich if (rolesArray.length === 0) { return res.status(400).json({ error: 'Mindestens eine Rolle ist erforderlich' }); } rolesJson = JSON.stringify(rolesArray); } // SQL-Query dynamisch zusammenstellen if (rolesJson !== null) { // Aktualisiere auch Rollen db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, arbeitstage = ?, default_break_minutes = ?, role = ? WHERE id = ?', [ personalnummer || null, wochenstunden ? parseFloat(wochenstunden) : null, urlaubstage ? parseFloat(urlaubstage) : null, arbeitstage ? parseInt(arbeitstage) : 5, normalizedDefaultBreak, rolesJson, userId ], (err) => { if (err) { return res.status(500).json({ error: 'Fehler beim Aktualisieren' }); } res.json({ success: true }); }); } else { // Nur andere Felder aktualisieren db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, arbeitstage = ?, default_break_minutes = ? WHERE id = ?', [ personalnummer || null, wochenstunden ? parseFloat(wochenstunden) : null, urlaubstage ? parseFloat(urlaubstage) : null, arbeitstage ? parseInt(arbeitstage) : 5, normalizedDefaultBreak, userId ], (err) => { if (err) { return res.status(500).json({ error: 'Fehler beim Aktualisieren' }); } res.json({ success: true }); }); } }); // Optionen laden app.get('/admin/options', requireAdmin, (req, res) => { db.get('SELECT * FROM system_options WHERE id = 1', (err, options) => { if (err) { return res.status(500).json({ error: 'Fehler beim Laden der Optionen' }); } // Wenn keine Optionen vorhanden, Standardwerte zurückgeben if (!options) { return res.json({ config: { saturday_percentage: 100, sunday_percentage: 100, checkin_root_url: null } }); } res.json({ config: options }); }); }); // Optionen speichern app.post('/admin/options', requireAdmin, (req, res) => { const { saturday_percentage, sunday_percentage, checkin_root_url } = req.body; // Validierung const satPercent = parseFloat(saturday_percentage); const sunPercent = parseFloat(sunday_percentage); if (isNaN(satPercent) || isNaN(sunPercent)) { return res.status(400).json({ error: 'Ungültige Prozentsätze' }); } if (satPercent < 100 || satPercent > 200 || sunPercent < 100 || sunPercent > 200) { return res.status(400).json({ error: 'Prozentsätze müssen zwischen 100 und 200 liegen' }); } // Validierung der Root URL (optional, kann leer sein) let rootUrl = checkin_root_url ? checkin_root_url.trim() : null; if (rootUrl === '') { rootUrl = null; } // Prüfe ob Eintrag existiert db.get('SELECT id FROM system_options WHERE id = 1', (err, existing) => { if (err) { return res.status(500).json({ error: 'Fehler beim Prüfen der Optionen' }); } if (existing) { // Update db.run('UPDATE system_options SET saturday_percentage = ?, sunday_percentage = ?, checkin_root_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1', [satPercent, sunPercent, rootUrl], (err) => { if (err) { return res.status(500).json({ error: 'Fehler beim Speichern der Optionen' }); } res.json({ success: true }); }); } else { // Insert db.run('INSERT INTO system_options (id, saturday_percentage, sunday_percentage, checkin_root_url) VALUES (1, ?, ?, ?)', [satPercent, sunPercent, rootUrl], (err) => { if (err) { return res.status(500).json({ error: 'Fehler beim Speichern der Optionen' }); } res.json({ success: true }); }); } }); }); // MSSQL-Konfiguration laden app.get('/admin/mssql-config', requireAdmin, (req, res) => { db.get('SELECT server, database, username FROM mssql_config WHERE id = 1', (err, config) => { if (err) { return res.status(500).json({ error: 'Fehler beim Laden der MSSQL-Konfiguration' }); } if (!config) { return res.json({ config: { server: '', database: '', username: '' } }); } res.json({ config }); }); }); // MSSQL-Konfiguration speichern app.post('/admin/mssql-config', requireAdmin, (req, res) => { const { server, database, username, password } = req.body; const trimmedServer = server ? server.trim() : ''; const trimmedDatabase = database ? database.trim() : ''; const trimmedUsername = username ? username.trim() : ''; const trimmedPassword = password != null ? password.trim() : null; if (!trimmedServer || !trimmedDatabase || !trimmedUsername) { return res.status(400).json({ error: 'Server, Datenbankname und Benutzername sind erforderlich' }); } db.get('SELECT * FROM mssql_config WHERE id = 1', (err, existing) => { if (err) { return res.status(500).json({ error: 'Fehler beim Lesen der bestehenden MSSQL-Konfiguration' }); } const newPassword = trimmedPassword === null || trimmedPassword === '' ? (existing ? existing.password : '') : trimmedPassword; if (existing) { db.run( 'UPDATE mssql_config SET server = ?, database = ?, username = ?, password = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1', [trimmedServer, trimmedDatabase, trimmedUsername, newPassword], (updateErr) => { if (updateErr) { return res.status(500).json({ error: 'Fehler beim Speichern der MSSQL-Konfiguration' }); } return res.json({ success: true }); } ); } else { db.run( 'INSERT INTO mssql_config (id, server, database, username, password) VALUES (1, ?, ?, ?, ?)', [trimmedServer, trimmedDatabase, trimmedUsername, newPassword || ''], (insertErr) => { if (insertErr) { return res.status(500).json({ error: 'Fehler beim Speichern der MSSQL-Konfiguration' }); } return res.json({ success: true }); } ); } }); }); // MSSQL-Verbindung testen app.post('/admin/mssql-test-connection', requireAdmin, async (req, res) => { try { await testMssqlConnection(); return res.json({ success: true }); } catch (err) { console.error('MSSQL Testverbindung fehlgeschlagen:', err); return res.status(500).json({ success: false, error: 'Verbindung zur MSSQL-Datenbank fehlgeschlagen: ' + err.message }); } }); // Timesheet-Duplikate (mehr als ein Eintrag pro Benutzer und Datum) als Übersicht app.get('/admin/api/timesheet-duplicates', requireAdmin, (req, res) => { const sql = ` SELECT te.*, dup.entry_count, u.firstname, u.lastname, u.username FROM timesheet_entries te INNER JOIN ( SELECT user_id, date, COUNT(*) AS entry_count FROM timesheet_entries GROUP BY user_id, date HAVING COUNT(*) > 1 ) dup ON dup.user_id = te.user_id AND dup.date = te.date INNER JOIN users u ON u.id = te.user_id ORDER BY te.user_id, te.date, te.id `; db.all(sql, [], (err, rows) => { if (err) { console.error('Fehler beim Laden der Timesheet-Duplikate:', err); return res.status(500).json({ error: 'Fehler beim Laden der Timesheet-Duplikate' }); } const groupsMap = new Map(); (rows || []).forEach(row => { const key = `${row.user_id}|${row.date}`; if (!groupsMap.has(key)) { const userNameParts = []; if (row.firstname) userNameParts.push(row.firstname); if (row.lastname) userNameParts.push(row.lastname); const user_name = userNameParts.join(' ') || row.username || `User #${row.user_id}`; groupsMap.set(key, { user_id: row.user_id, user_name, username: row.username, date: row.date, entry_count: row.entry_count, entries: [] }); } const group = groupsMap.get(key); group.entries.push({ id: row.id, start_time: row.start_time, end_time: row.end_time, break_minutes: row.break_minutes, total_hours: row.total_hours, status: row.status, notes: row.notes, created_at: row.created_at, updated_at: row.updated_at }); }); const groups = Array.from(groupsMap.values()); res.json({ groups }); }); }); // Einzelnen Timesheet-Eintrag löschen (zur manuellen Bereinigung von Duplikaten) app.delete('/admin/api/timesheet-entry/:id', requireAdmin, (req, res) => { const entryId = parseInt(req.params.id, 10); if (!Number.isInteger(entryId) || entryId <= 0) { return res.status(400).json({ error: 'Ungültige Eintrags-ID' }); } db.run('DELETE FROM timesheet_entries WHERE id = ?', [entryId], function(err) { if (err) { console.error('Fehler beim Löschen des Timesheet-Eintrags:', err); return res.status(500).json({ error: 'Fehler beim Löschen des Timesheet-Eintrags' }); } if (this.changes === 0) { return res.status(404).json({ error: 'Timesheet-Eintrag nicht gefunden' }); } res.json({ success: true }); }); }); // ----------------------- // PDF-Archiv (Admin) // ----------------------- // Liste vorhandener Jahre unter PDF_BASE_DIR app.get('/admin/api/pdfs/years', requireAdmin, async (req, res) => { try { const baseDirResolved = path.resolve(getPdfBaseDir()); const entries = await fsp.readdir(baseDirResolved, { withFileTypes: true }).catch(() => []); const years = entries .filter(d => d.isDirectory() && YEAR_RE.test(d.name)) .map(d => d.name) .sort((a, b) => Number(b) - Number(a)); res.json({ years }); } catch (err) { console.error('Fehler beim Laden der PDF-Jahre:', err); res.status(500).json({ error: 'Fehler beim Laden der PDF-Jahre' }); } }); // Liste vorhandener User-Ordner für ein Jahr app.get('/admin/api/pdfs/users', requireAdmin, async (req, res) => { const year = String(req.query.year || '').trim(); if (!YEAR_RE.test(year)) return res.status(400).json({ error: 'Ungültiges Jahr' }); try { const baseDirResolved = path.resolve(getPdfBaseDir()); const yearDir = resolveWithinBaseDir(baseDirResolved, year); if (!yearDir) return res.status(400).json({ error: 'Ungültige Pfadanfrage' }); const entries = await fsp.readdir(yearDir, { withFileTypes: true }).catch(() => []); const userIds = entries .filter(d => d.isDirectory() && USER_ID_RE.test(d.name)) .map(d => d.name) .sort((a, b) => Number(b) - Number(a)); // Zusätzlich Namen aus DB laden, damit der Dropdown menschenlesbar ist. let usersWithNames = []; if (userIds.length > 0) { const placeholders = userIds.map(() => '?').join(','); const params = userIds.map(id => parseInt(id, 10)); const rows = await new Promise((resolve, reject) => { db.all( `SELECT id, firstname, lastname, username FROM users WHERE id IN (${placeholders})`, params, (err2, r) => { if (err2) return reject(err2); resolve(r || []); }, ); }); const map = new Map(); (rows || []).forEach(u => { const name = [u.firstname, u.lastname].filter(Boolean).join(' ').trim(); map.set(String(u.id), name || u.username || String(u.id)); }); usersWithNames = userIds.map(id => ({ id, name: map.get(String(id)) || String(id), })); } res.json({ userIds, users: usersWithNames }); } catch (err) { console.error('Fehler beim Laden der PDF-User-Ordner:', err); res.status(500).json({ error: 'Fehler beim Laden der PDF-User-Ordner' }); } }); // Liste PDFs für year/userId app.get('/admin/api/pdfs/files', requireAdmin, async (req, res) => { const year = String(req.query.year || '').trim(); const userId = String(req.query.userId || '').trim(); if (!YEAR_RE.test(year)) return res.status(400).json({ error: 'Ungültiges Jahr' }); if (!USER_ID_RE.test(userId)) return res.status(400).json({ error: 'Ungültiger User' }); try { const baseDirResolved = path.resolve(getPdfBaseDir()); const userDir = resolveWithinBaseDir(baseDirResolved, year, userId); if (!userDir) return res.status(400).json({ error: 'Ungültige Pfadanfrage' }); const entries = await fsp.readdir(userDir, { withFileTypes: true }).catch(() => []); const pdfEntries = entries.filter(d => d.isFile() && PDF_NAME_RE.test(d.name)); const filesWithMeta = await Promise.all( pdfEntries.map(async d => { const stat = await fsp.stat(path.join(userDir, d.name)); return { name: d.name, size: stat.size, mtime: stat.mtime.toISOString() }; }) ); filesWithMeta.sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime()); res.json({ files: filesWithMeta }); } catch (err) { console.error('Fehler beim Laden der PDF-Dateiliste:', err); res.status(500).json({ error: 'Fehler beim Laden der PDF-Dateiliste' }); } }); // Streamt eine einzelne PDF (Vorschau oder Download) app.get('/admin/api/pdfs/file', requireAdmin, async (req, res) => { const year = String(req.query.year || '').trim(); const userId = String(req.query.userId || '').trim(); const nameRaw = String(req.query.name || ''); const inline = String(req.query.inline || 'false') === 'true'; if (!YEAR_RE.test(year)) return res.status(400).json({ error: 'Ungültiges Jahr' }); if (!USER_ID_RE.test(userId)) return res.status(400).json({ error: 'Ungültiger User' }); const safeName = path.basename(nameRaw); if (!safeName || safeName !== nameRaw || !PDF_NAME_RE.test(safeName)) { return res.status(400).json({ error: 'Ungültiger Dateiname' }); } try { const baseDirResolved = path.resolve(getPdfBaseDir()); const absolutePath = resolveWithinBaseDir(baseDirResolved, year, userId, safeName); if (!absolutePath) return res.status(400).json({ error: 'Ungültige Pfadanfrage' }); let stat; try { stat = await fsp.stat(absolutePath); } catch (err) { if (err && err.code === 'ENOENT') return res.status(404).json({ error: 'PDF nicht gefunden' }); throw err; } res.setHeader('Content-Type', 'application/pdf'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Length', stat.size); if (inline) { res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('Content-Disposition', `inline; filename="${safeName}"`); } else { res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`); } const stream = fs.createReadStream(absolutePath); stream.on('error', (streamErr) => { console.error('Fehler beim Streamen der PDF:', streamErr); if (!res.headersSent) res.status(500); res.end('Fehler beim Lesen der PDF-Datei'); }); return stream.pipe(res); } catch (err) { console.error('Fehler beim Streamen der PDF:', err); if (!res.headersSent) res.status(500).json({ error: 'Fehler beim Streamen der PDF' }); } }); } module.exports = registerAdminRoutes;