feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db.js';
|
||||
import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js';
|
||||
import { getSettings } from '../lib/settings.js';
|
||||
import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/*
|
||||
* Bremse gegen Passwortraten: 10 Fehlversuche je IP und Kennung in 15 Minuten.
|
||||
* Im Speicher reicht - es gibt genau eine Backend-Instanz.
|
||||
*/
|
||||
const attempts = new Map();
|
||||
const WINDOW = 15 * 60000;
|
||||
function isLimited(key) {
|
||||
const now = Date.now();
|
||||
const recent = (attempts.get(key) || []).filter((t) => now - t < WINDOW);
|
||||
if (recent.length) attempts.set(key, recent);
|
||||
else attempts.delete(key);
|
||||
return recent.length >= 10;
|
||||
}
|
||||
function recordFailure(key) {
|
||||
attempts.set(key, [...(attempts.get(key) || []), Date.now()]);
|
||||
}
|
||||
|
||||
// Oeffentlich: was zeigt der Anmeldeschirm?
|
||||
router.get('/config', async (req, res, next) => {
|
||||
try {
|
||||
const { sso } = await getSettings();
|
||||
const ssoOn = Boolean(sso.enabled && ssoConfigured(sso));
|
||||
res.json({ sso_enabled: ssoOn, sso_label: sso.button_label, local_login: ssoOn ? sso.local_login !== false : true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
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.' });
|
||||
}
|
||||
const key = `${req.ip}|${identifier.toLowerCase()}`;
|
||||
if (isLimited(key)) {
|
||||
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM pulse_users WHERE username = ? OR email = ? LIMIT 1', [
|
||||
identifier,
|
||||
identifier.toLowerCase(),
|
||||
]);
|
||||
const user = rows[0];
|
||||
// Identische Meldung fuer "gibt es nicht" und "falsches Passwort".
|
||||
const ok = user?.password_hash ? await bcrypt.compare(password, user.password_hash) : false;
|
||||
if (!ok) {
|
||||
recordFailure(key);
|
||||
return res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' });
|
||||
}
|
||||
if (!user.is_active) return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' });
|
||||
|
||||
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
|
||||
res.json({ token: signToken(user), user: publicUser(user) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) }));
|
||||
|
||||
// Eigene Darstellung: Design, Modus, Farbe.
|
||||
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
const updates = {};
|
||||
if (req.body?.theme !== undefined) {
|
||||
const theme = req.body.theme;
|
||||
if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].includes(theme)) {
|
||||
return res.status(400).json({ error: 'Unbekanntes Design.' });
|
||||
}
|
||||
updates.theme = theme === 'perinco' ? null : theme;
|
||||
}
|
||||
if (req.body?.theme_mode !== undefined) {
|
||||
const mode = req.body.theme_mode;
|
||||
if (mode !== null && mode !== 'light' && mode !== 'dark') return res.status(400).json({ error: 'Unbekannter Modus.' });
|
||||
updates.theme_mode = mode;
|
||||
}
|
||||
if (req.body?.color !== undefined) {
|
||||
const color = req.body.color;
|
||||
if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' });
|
||||
updates.color = color;
|
||||
}
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
try {
|
||||
await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
|
||||
...keys.map((k) => updates[k]),
|
||||
req.user.id,
|
||||
]);
|
||||
res.json({ user: publicUser({ ...req.user, ...updates }) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/change-password', requireAuth, async (req, res, next) => {
|
||||
const current = String(req.body?.current_password || '');
|
||||
const wanted = String(req.body?.new_password || '');
|
||||
if (wanted.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 pulse_users WHERE id = ?', [req.user.id]);
|
||||
const hash = rows[0]?.password_hash;
|
||||
if (!hash) return res.status(400).json({ error: 'Dieses Konto meldet sich über SSO an und hat kein Passwort.' });
|
||||
if (!(await bcrypt.compare(current, hash))) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' });
|
||||
await pool.query('UPDATE pulse_users SET password_hash = ? WHERE id = ?', [await bcrypt.hash(wanted, 12), req.user.id]);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- SSO -- */
|
||||
|
||||
router.get('/sso/start', async (req, res, next) => {
|
||||
try {
|
||||
const { sso } = await getSettings();
|
||||
if (!sso.enabled || !ssoConfigured(sso)) {
|
||||
return res.redirect('/?sso_error=' + encodeURIComponent('SSO ist nicht eingerichtet.'));
|
||||
}
|
||||
const state = signShort({ n: crypto.randomBytes(12).toString('hex'), t: 'sso' });
|
||||
res.redirect(authorizeUrl(sso, state));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sso/callback', async (req, res) => {
|
||||
const back = (message) => res.redirect('/?sso_error=' + encodeURIComponent(message));
|
||||
try {
|
||||
if (req.query.error) return back(String(req.query.error_description || req.query.error).split('\n')[0]);
|
||||
try {
|
||||
const state = verifyShort(String(req.query.state || ''));
|
||||
if (state.t !== 'sso') throw new Error('state');
|
||||
} catch {
|
||||
return back('Anmeldung abgelaufen. Bitte erneut versuchen.');
|
||||
}
|
||||
|
||||
const { sso } = await getSettings();
|
||||
if (!sso.enabled || !ssoConfigured(sso)) return back('SSO ist nicht eingerichtet.');
|
||||
const identity = await exchangeCode(sso, String(req.query.code || ''));
|
||||
|
||||
// Nur wer in der SSO-Verwaltung uebernommen wurde, kommt herein - verknuepft
|
||||
// wird ueber die Objekt-ID, nicht ueber die Mailadresse.
|
||||
const [rows] = await pool.query("SELECT * FROM pulse_users WHERE external_id = ? AND source = 'sso' LIMIT 1", [identity.oid]);
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
return back(`Für ${identity.email || 'dieses Konto'} ist Pulse noch nicht freigeschaltet. Bitte an einen Manager oder Administrator wenden.`);
|
||||
}
|
||||
if (!user.is_active) return back('Dieses Benutzerkonto ist deaktiviert.');
|
||||
|
||||
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
|
||||
// Token im Fragment: landet weder im Server-Log noch im Referer.
|
||||
res.redirect(`/#sso_token=${encodeURIComponent(signToken(user))}`);
|
||||
} catch (err) {
|
||||
console.error('[Pulse] SSO-Anmeldung fehlgeschlagen:', err.message);
|
||||
back('Anmeldung über Microsoft fehlgeschlagen: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user