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:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
import jwt from 'jsonwebtoken';
import { pool } from '../db.js';
export const ROLES = ['user', 'lead', 'management', 'admin'];
const RANK = { user: 0, lead: 1, management: 2, admin: 3 };
function secret() {
const value = process.env.JWT_SECRET;
if (!value || value.length < 16) throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).');
return value;
}
export function signToken(user) {
return jwt.sign({ sub: user.id, role: user.role }, secret(), {
expiresIn: process.env.JWT_EXPIRES_IN || '12h',
});
}
export function signShort(payload, expiresIn = '10m') {
return jwt.sign(payload, secret(), { expiresIn });
}
export function verifyShort(token) {
return jwt.verify(token, secret());
}
export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company,
source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
/**
* Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus
* der Datenbank - ein deaktiviertes Konto ist sofort draussen und eine
* geaenderte Rolle gilt sofort.
*/
export async function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' });
let payload;
try {
payload = jwt.verify(token, secret());
} catch {
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' });
}
try {
const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [payload.sub]);
const user = rows[0];
if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' });
if (!user.is_active) return res.status(401).json({ error: 'Benutzerkonto ist deaktiviert.' });
req.user = user;
next();
} catch (err) {
next(err);
}
}
export function hasRole(user, minimum) {
return RANK[user?.role] >= RANK[minimum];
}
/** Mindestrolle: lead < management < admin. */
export function requireRole(minimum) {
return (req, res, next) => {
if (!hasRole(req.user, minimum)) {
return res.status(403).json({ error: 'Für diese Aktion fehlen die Berechtigungen.' });
}
next();
};
}
export function publicUser(row) {
return {
id: row.id,
username: row.username,
email: row.email,
name: row.name,
role: row.role,
manager_id: row.manager_id ?? null,
job_title: row.job_title ?? null,
is_external: Boolean(row.is_external),
company: row.company ?? null,
source: row.source,
weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours),
color: row.color ?? null,
theme: row.theme ?? null,
theme_mode: row.theme_mode ?? null,
is_active: Boolean(row.is_active),
last_login_at: row.last_login_at ?? null,
created_at: row.created_at,
};
}