import express from 'express'; import { requireRole, ROLES } from '../middleware/auth.js'; import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js'; import { ssoConfigured, testConnection } from '../lib/sso.js'; import { sendDocument } from '../export/render.js'; const router = express.Router(); // Fuer alle Angemeldeten: was die Oberflaeche zum Rechnen braucht. router.get('/public', async (req, res, next) => { try { const s = await getSettings(); res.json({ weekly_hours: Number(s.weekly_hours), hours_per_day: Number(s.hours_per_day), budget_warn_percent: Number(s.budget_warn_percent), budget_warn_days: Number(s.budget_warn_days), company_name: s.company_name, project_code_pattern: s.project_code_pattern, project_name_pattern: s.project_name_pattern, }); } catch (err) { next(err); } }); router.get('/', requireRole('admin'), async (req, res, next) => { try { const s = await getSettings(); // Geheimnisse werden nie ausgeliefert, nur die Information, dass sie stehen. res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) }, hrworks: { ...s.hrworks, secret_access_key: '', secret_set: Boolean(s.hrworks.secret_access_key), configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key), }, }); } catch (err) { next(err); } }); const text = (v, max) => String(v ?? '').slice(0, max); router.put('/', requireRole('admin'), async (req, res, next) => { try { const body = req.body || {}; const current = await getSettings(); const errors = []; const numbers = { weekly_hours: [1, 80], hours_per_day: [1, 24], budget_warn_percent: [1, 100], budget_warn_days: [0, 365], }; for (const [key, [min, max]] of Object.entries(numbers)) { if (body[key] === undefined) continue; const value = Number(body[key]); if (!(value >= min && value <= max)) errors.push(`${key}: Wert zwischen ${min} und ${max}.`); else await saveSetting(key, value); } if (body.company_name !== undefined) await saveSetting('company_name', text(body.company_name, 160)); if (body.project_code_pattern !== undefined) { const pattern = text(body.project_code_pattern, 60).trim(); if (!pattern.includes('{NR}')) errors.push('Das Codemuster braucht {NR}, sonst sind Codes nicht eindeutig.'); else await saveSetting('project_code_pattern', pattern); } if (body.project_name_pattern !== undefined) { const pattern = text(body.project_name_pattern, 60).trim(); if (!pattern.includes('{PROJEKT}')) errors.push('Das Namensmuster braucht {PROJEKT}.'); else await saveSetting('project_name_pattern', pattern); } if (body.export) { const next_ = { ...current.export }; for (const key of Object.keys(DEFAULTS.export)) { if (body.export[key] === undefined) continue; if (key === 'show_signature') next_[key] = Boolean(body.export[key]); else if (key === 'logo') { const logo = body.export.logo; if (logo === null || logo === '') next_.logo = null; else if (/^data:image\/(png|jpe?g);base64,/.test(logo) && logo.length < 2_500_000) next_.logo = logo; else errors.push('Logo: PNG oder JPG bis ca. 1,8 MB.'); } else if (key === 'accent') { if (/^#[0-9a-f]{6}$/i.test(body.export.accent)) next_.accent = body.export.accent; else errors.push('Akzentfarbe ist ungültig.'); } else next_[key] = text(body.export[key], 300); } await saveSetting('export', next_); } if (body.hrworks) { const next_ = { ...current.hrworks }; for (const key of ['access_key', 'project_code', 'holiday_task']) { if (body.hrworks[key] !== undefined) next_[key] = text(body.hrworks[key], 200).trim(); } if (body.hrworks.enabled !== undefined) next_.enabled = Boolean(body.hrworks.enabled); if (body.hrworks.window_days !== undefined) { const days = Number(body.hrworks.window_days); if (days >= 1 && days <= 365) next_.window_days = Math.round(days); else errors.push('HRworks: Fenster zwischen 1 und 365 Tagen.'); } if (body.hrworks.half_day_hours !== undefined) { const hours = Number(body.hrworks.half_day_hours); if (hours >= 0 && hours <= 12) next_.half_day_hours = hours; else errors.push('HRworks: Stunden für einen halben Tag zwischen 0 und 12.'); } if (body.hrworks.type_map && typeof body.hrworks.type_map === 'object') { const map = {}; for (const [key, value] of Object.entries(body.hrworks.type_map)) { if (/^(absence|sick):[A-Za-z0-9_-]{1,20}$/.test(key)) map[key] = text(value, 160).trim(); } next_.type_map = map; } // Leer = unveraendert; der geheime Teil wird nie ausgeliefert. if (body.hrworks.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim(); if (body.hrworks.clear_secret) next_.secret_access_key = ''; if (next_.enabled && !(next_.access_key && next_.secret_access_key)) { errors.push('HRworks kann erst aktiviert werden, wenn Access Key und Secret gesetzt sind.'); } if (!errors.length) await saveSetting('hrworks', next_); } if (body.sso) { const next_ = { ...current.sso }; for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) { if (body.sso[key] !== undefined) next_[key] = text(body.sso[key], 300).trim(); } for (const key of ['enabled', 'local_login']) { if (body.sso[key] !== undefined) next_[key] = Boolean(body.sso[key]); } // Leer = unveraendert lassen; das Secret wird nie ausgeliefert. if (body.sso.client_secret) next_.client_secret = text(body.sso.client_secret, 500).trim(); if (body.sso.clear_secret) next_.client_secret = ''; if (next_.enabled && !ssoConfigured(next_)) errors.push('SSO kann erst aktiviert werden, wenn Tenant, Client-ID, Secret und Redirect-URI gesetzt sind.'); if (next_.enabled && !ssoConfigured(next_)) next_.enabled = false; if (!next_.enabled) next_.local_login = true; // ohne SSO braucht es die normale Anmeldung await saveSetting('sso', next_); } if (errors.length) return res.status(400).json({ error: errors.join(' ') }); res.json({ ok: true }); } catch (err) { next(err); } }); router.post('/sso/test', requireRole('admin'), async (req, res) => { try { const { sso } = await getSettings(); if (!ssoConfigured(sso)) return res.status(400).json({ error: 'Tenant, Client-ID und Secret fehlen.' }); const result = await testConnection(sso); res.json({ ok: true, ...result }); } catch (err) { res.status(502).json({ error: `Verbindung fehlgeschlagen: ${err.message}` }); } }); /** Beispiel-Stundennachweis mit Platzhalterdaten - zeigt Kopf und Fuss. */ router.get('/export-preview', requireRole('admin'), async (req, res, next) => { try { const settings = await getSettings(); const format = ['pdf', 'xlsx', 'csv'].includes(req.query.format) ? req.query.format : 'pdf'; const rows = [ ['2026-09-01', 'Di', 'Projektleitung', 'Jour fixe und Statusbericht', 150], ['2026-09-02', 'Mi', 'Konzeption', 'Workshop Anforderungen', 420], ['2026-09-03', 'Do', 'Konzeption', 'Dokumentation Prozessmodell', 390], ['2026-09-04', 'Fr', 'Projektleitung', '', 240], ].map(([entry_date, weekday, task_name, note, minutes]) => ({ entry_date, weekday, project_display: 'Musterkunde - Beispielprojekt', task_name, note, minutes, duration: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`, })); const total = rows.reduce((s, r) => s + r.minutes, 0); await sendDocument( res, { title: settings.export.title || 'Stundennachweis', subtitle: 'Vorschau', filename: 'Vorschau Stundennachweis', info: [ ['Berater', 'Max Mustermann'], ['Kunde', 'Musterkunde GmbH'], ['Zeitraum', '01.09.2026 – 30.09.2026'], ['Projekt', 'Musterkunde - Beispielprojekt'], ], kpis: [ ['Gesamtstunden', (total / 60).toLocaleString('de-DE', { minimumFractionDigits: 2 })], ['Tage mit Leistung', '4'], ], tables: [ { columns: [ { key: 'entry_date', label: 'Datum', type: 'date', width: 58 }, { key: 'weekday', label: 'Tag', width: 26 }, { key: 'project_display', label: 'Projekt', width: 120 }, { key: 'task_name', label: 'Task', width: 80 }, { key: 'note', label: 'Tätigkeit', type: 'text' }, { key: 'duration', label: 'Dauer', align: 'right', width: 38 }, { key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46 }, ], rows, totals: { weekday: 'Summe', minutes: total }, }, ], signature: true, }, format, settings ); } catch (err) { next(err); } }); export default router;