Files
SDSStundenerfassung/routes/admin-routes.js
2026-03-18 17:24:13 +01:00

591 lines
22 KiB
JavaScript

// 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;