This commit is contained in:
2026-02-02 19:12:40 +01:00
parent c6421049c8
commit 952c353118
17 changed files with 982 additions and 513 deletions

View File

@@ -0,0 +1,177 @@
---
name: MySQL-Integration mit Admin-Konfiguration
overview: Erweitere das System um MySQL-Unterstützung mit einer Admin-Oberfläche zur Konfiguration. Die Datenbank wird in einer Konfigurationsdatei gespeichert und kann im Admin-Bereich geändert werden (erfordert Server-Neustart).
todos: []
---
# MySQL/MariaDB-Integration mit Admin-Konfiguration
## Übersicht
Erweitere das System um MySQL/MariaDB-Unterstützung neben SQLite3. Die Datenbank-Konfiguration wird in einer Konfigurationsdatei gespeichert und kann im Admin-Bereich verwaltet werden. Ein Wechsel erfordert einen Server-Neustart.
**Hinweis:** MariaDB ist vollständig MySQL-kompatibel und funktioniert mit derselben Schnittstelle (`mysql2` Package).
## Architektur
### Datenbank-Abstraktionsschicht
Erstelle eine Abstraktionsschicht, die sowohl SQLite als auch MySQL unterstützt:
1. **Neue Datei:** `database/db-adapter.js`
- Abstrahiert `db.get()`, `db.run()`, `db.all()` für beide Datenbanken
- Promise-basierte API (für bessere Kompatibilität)
- Automatische Fehlerbehandlung
- SQL-Dialekt-Anpassung (z.B. `AUTOINCREMENT` vs `AUTO_INCREMENT`)
2. **Neue Datei:** `database/sqlite-adapter.js`
- SQLite-spezifische Implementierung
- Wrapper um sqlite3
3. **Neue Datei:** `database/mysql-adapter.js`
- MySQL/MariaDB-spezifische Implementierung
- Verwendet `mysql2` Package (funktioniert mit MySQL und MariaDB)
4. **Anpassung:** `database.js`
- Lädt Konfiguration aus `config/database.json`
- Initialisiert entsprechenden Adapter
- Behält `initDatabase()` Funktion bei
### Konfigurationsdatei
**Neue Datei:** `config/database.json`
```json
{
"type": "sqlite",
"sqlite": {
"path": "./stundenerfassung.db"
},
"mysql": {
"host": "localhost",
"port": 3306,
"user": "root",
"password": "",
"database": "stundenerfassung",
"charset": "utf8mb4"
}
}
```
### Admin-Interface
**Anpassung:** `routes/admin.js`
- Neue Route `/admin/database/config` (GET/PUT)
- Zeigt aktuelle Datenbank-Konfiguration
- Formular zum Ändern der Datenbank-Einstellungen
- Warnung bei Wechsel (Server-Neustart erforderlich)
**Anpassung:** `views/admin.ejs`
- Neuer Abschnitt "Datenbank-Konfiguration"
- Formular für SQLite/MySQL/MariaDB-Auswahl
- Eingabefelder für MySQL/MariaDB-Verbindungsdaten
- Test-Verbindung Button
- Warnung bei Änderungen
- Hinweis: MariaDB funktioniert mit MySQL-Konfiguration
### SQL-Anpassungen
**Unterschiede zwischen SQLite und MySQL:**
- `INTEGER PRIMARY KEY AUTOINCREMENT``INT AUTO_INCREMENT PRIMARY KEY`
- `TEXT``VARCHAR(255)` oder `TEXT`
- `REAL``DOUBLE` oder `DECIMAL`
- `TEXT` (unbegrenzt) → `TEXT` oder `LONGTEXT`
- `DATETIME DEFAULT CURRENT_TIMESTAMP``DATETIME DEFAULT CURRENT_TIMESTAMP` (gleich)
- `INSERT OR IGNORE``INSERT IGNORE`
- `COLLATE NOCASE``COLLATE utf8mb4_general_ci` (für case-insensitive)
- Foreign Keys müssen in MySQL explizit aktiviert werden
**Anpassung:** `database.js` - `initDatabase()`
- SQL-Generierung basierend auf Datenbanktyp
- Separate CREATE TABLE Statements für SQLite und MySQL
- Migrationen anpassen (ALTER TABLE Syntax)
### Package-Abhängigkeiten
**Anpassung:** `package.json`
- `mysql2` hinzufügen (bessere Promise-Unterstützung als `mysql`, funktioniert mit MySQL und MariaDB)
### Services und Routes
Alle Dateien, die `db` verwenden, müssen angepasst werden:
- `routes/*.js` (7 Dateien)
- `services/*.js` (5 Dateien)
- `checkin-server.js`
**Änderungen:**
- Promise-basierte Queries verwenden (statt Callbacks)
- Oder: Callback-Wrapper in Adapter
### Initialisierung
**Anpassung:** `server.js`
- Prüft ob `config/database.json` existiert
- Erstellt Standard-Konfiguration falls nicht vorhanden
- Initialisiert Datenbank entsprechend Konfiguration
## Implementierungsschritte
1. **Datenbank-Abstraktionsschicht erstellen**
- `database/db-adapter.js` - Interface
- `database/sqlite-adapter.js` - SQLite-Implementierung
- `database/mysql-adapter.js` - MySQL-Implementierung
2. **Konfigurationssystem**
- `config/database.json` Template
- Konfigurations-Loader in `database.js`
3. **SQL-Anpassungen**
- `initDatabase()` für beide Datenbanken anpassen
- SQL-Dialekt-Unterschiede behandeln
4. **Admin-Interface**
- Route für Datenbank-Konfiguration
- View mit Konfigurationsformular
- Test-Verbindung Funktion
5. **Alle Queries anpassen**
- Callbacks zu Promises oder Callback-Wrapper
- SQLite-spezifische Syntax entfernen (z.B. `COLLATE NOCASE`)
6. **Dokumentation**
- README.md aktualisieren
- Konfigurationsanleitung
## Technische Details
- **MySQL/MariaDB-Package:** `mysql2` (bessere Promise-Unterstützung, funktioniert mit beiden)
- **Kompatibilität:** MariaDB ist vollständig MySQL-kompatibel, verwendet dieselbe Konfiguration
- **Fallback:** Bei Fehler in MySQL/MariaDB-Verbindung auf SQLite zurückfallen
- **Validierung:** Test-Verbindung vor Speichern der Konfiguration
- **Sicherheit:** MySQL/MariaDB-Passwörter verschlüsselt in Config speichern (optional)
## Dateien die angepasst werden müssen
- `database.js``database/` Ordner mit mehreren Dateien
- `routes/admin.js` - Datenbank-Konfiguration
- `views/admin.ejs` - Konfigurations-UI
- `package.json` - mysql2 Dependency
- `server.js` - Konfigurationspr

View File

@@ -18,3 +18,4 @@ Thumbs.db
logs
*.log
dev
backup/

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
#Backups
backup/
# Dependencies
node_modules/
npm-debug.log*

153
README.md
View File

@@ -1,26 +1,47 @@
# Stundenerfassungs-System
Eine webbasierte Anwendung zur Erfassung von Arbeitszeiten mit Admin-Bereich und PDF-Export.
Eine webbasierte Anwendung zur Erfassung von Arbeitszeiten mit Admin-Bereich, PDF-Export, Überstunden-Verwaltung und LDAP-Integration.
## Features
### Für Mitarbeiter
- ✅ Login mit Benutzername und Passwort
- ✅ Login mit Benutzername und Passwort oder LDAP
- ✅ Wöchentliche Stundenerfassung (Montag - Sonntag)
- ✅ Automatisches Speichern der Einträge
- ✅ Eingabe von Start-/Endzeit, Pausen und Notizen
- ✅ Mehrere Tätigkeiten pro Tag mit Projektnummern (bis zu 5)
- ✅ Automatische Berechnung der Gesamtstunden
- ✅ Überstunden-Verwaltung (Anzeige und Verbrauch)
- ✅ Urlaubsverwaltung (ganzer/halber Tag)
- ✅ Krankheitstage erfassen
- ✅ Feiertage werden automatisch erkannt
- ✅ Wochenend-Prozentsätze (konfigurierbar)
- ✅ Wöchentliches Abschicken des Stundenzettels
- ✅ Überstunden-Auswertung pro Woche mit detaillierter Aufschlüsselung
- ✅ Anzeige aktueller Überstunden und verbleibender Urlaubstage
- ✅ Automatische Zeiterfassung via IP-Ping (optional)
- ✅ QR-Code für Check-in/Check-out
### Für Administratoren
- ✅ Benutzerverwaltung (Anlegen, Löschen)
- ✅ Benutzerverwaltung (Anlegen, Löschen, Bearbeiten)
- ✅ Rollenvergabe (Mitarbeiter, Verwaltung, Admin)
-Übersicht aller Benutzer
-Mehrere Rollen pro Benutzer möglich
- ✅ Übersicht aller Benutzer mit Statistiken
- ✅ Personalnummern verwalten
- ✅ Wochenstunden und Urlaubstage pro Mitarbeiter konfigurieren
- ✅ LDAP-Integration für Benutzer-Synchronisation
- ✅ LDAP-Konfiguration und manuelle/automatische Synchronisation
- ✅ System-Optionen (Wochenend-Prozentsätze)
### Für Verwaltung
- ✅ Postfach mit eingereichten Stundenzetteln
- ✅ PDF-Generierung und Download
- ✅ Übersichtliche Darstellung aller Einreichungen
- ✅ Wochenansicht mit Statistiken pro Mitarbeiter
- ✅ Überstunden-Offset (manuelle Korrekturen)
- ✅ Admin-Kommentare zu Stundenzetteln
- ✅ Versionsverwaltung bei Änderungen
- ✅ Übersicht über Überstunden und Urlaubstage aller Mitarbeiter
## Installation
@@ -47,6 +68,14 @@ Für Entwicklung mit automatischem Neustart:
npm run dev
```
### Docker (optional)
Das System kann auch mit Docker betrieben werden:
```bash
docker-compose up -d
```
## Standard-Zugangsdaten
Nach der Installation sind folgende Benutzer verfügbar:
@@ -54,12 +83,12 @@ Nach der Installation sind folgende Benutzer verfügbar:
### Administrator
- **Benutzername:** admin
- **Passwort:** admin123
- **Funktion:** Kann Benutzer anlegen und verwalten
- **Funktion:** Kann Benutzer anlegen und verwalten, LDAP konfigurieren
### Verwaltung
- **Benutzername:** verwaltung
- **Passwort:** verwaltung123
- **Funktion:** Kann eingereichte Stundenzettel einsehen und PDFs erstellen
- **Funktion:** Kann eingereichte Stundenzettel einsehen, PDFs erstellen und Überstunden korrigieren
**WICHTIG:** Bitte ändern Sie diese Passwörter nach der ersten Anmeldung!
@@ -73,10 +102,15 @@ Nach der Installation sind folgende Benutzer verfügbar:
- **Start:** Arbeitsbeginn
- **Ende:** Arbeitsende
- **Pause:** Pausenzeit in Minuten
- **Tätigkeiten:** Bis zu 5 Tätigkeiten mit Beschreibung, Stunden und Projektnummer
- **Notizen:** Optional, z.B. Projekt oder Tätigkeit
- **Überstunden:** Optional, wenn Überstunden verbraucht werden sollen
- **Urlaub:** Ganzer oder halber Tag Urlaub
- **Krank:** Krankheitstag markieren
4. Die Einträge werden automatisch gespeichert
5. Am Ende der Woche: Klicken Sie auf **"Woche abschicken"**
6. Nach dem Abschicken können keine Änderungen mehr vorgenommen werden
7. **Überstunden-Auswertung:** Klicken Sie auf "Details anzeigen" im Bereich "Aktuelle Überstunden" für eine wöchentliche Aufschlüsselung
### Für Administratoren
@@ -84,11 +118,16 @@ Nach der Installation sind folgende Benutzer verfügbar:
2. Sie gelangen automatisch zur Benutzerverwaltung
3. **Neuen Benutzer anlegen:**
- Füllen Sie das Formular aus
- Wählen Sie die passende Rolle
- Wählen Sie die passende Rolle (mehrere möglich)
- Personalnummer, Wochenstunden und Urlaubstage konfigurieren
- Klicken Sie auf "Benutzer anlegen"
4. **Benutzer löschen:**
- Klicken Sie auf "Löschen" neben dem gewünschten Benutzer
- System-Benutzer (Admin, Verwaltung) können nicht gelöscht werden
5. **LDAP konfigurieren:**
- Navigieren Sie zum LDAP-Bereich
- Konfigurieren Sie LDAP-Verbindung und Synchronisation
- Führen Sie manuelle Synchronisationen durch oder aktivieren Sie automatische Syncs
### Für Verwaltung
@@ -97,10 +136,16 @@ Nach der Installation sind folgende Benutzer verfügbar:
3. **PDF erstellen:**
- Klicken Sie auf "PDF herunterladen" neben dem gewünschten Stundenzettel
- Die PDF wird automatisch generiert und heruntergeladen
4. Die PDF enthält:
- Mitarbeitername
4. **Überstunden korrigieren:**
- In der Wochenansicht können Sie manuelle Korrekturen (Offset) für jeden Mitarbeiter vornehmen
5. **Kommentare hinzufügen:**
- Fügen Sie Admin-Kommentare zu Stundenzetteln hinzu
6. Die PDF enthält:
- Mitarbeitername und Personalnummer
- Zeitraum
- Alle Tageseinträge mit Start, Ende, Pause, Stunden und Notizen
- Alle Tageseinträge mit Start, Ende, Pause, Stunden, Tätigkeiten und Notizen
- Überstunden-Verbrauch
- Urlaubstage
- Gesamtstundensumme
## Technologie-Stack
@@ -110,20 +155,101 @@ Nach der Installation sind folgende Benutzer verfügbar:
- **Template Engine:** EJS
- **PDF-Generierung:** PDFKit
- **Authentifizierung:** bcryptjs + express-session
- **LDAP:** ldapjs
- **QR-Codes:** qrcode
## Projektstruktur
```
stundenerfassung/
├── routes/ # API-Routen
│ ├── admin.js # Admin-Funktionen
│ ├── admin-ldap.js # LDAP-Verwaltung
│ ├── auth.js # Authentifizierung
│ ├── dashboard.js # Dashboard-Routen
│ ├── timesheet.js # Stundenerfassung
│ ├── user.js # Benutzer-APIs
│ └── verwaltung.js # Verwaltungs-Funktionen
├── services/ # Services
│ ├── feiertage-service.js # Feiertags-API
│ ├── ldap-service.js # LDAP-Service
│ ├── ldap-scheduler.js # LDAP-Synchronisation
│ ├── pdf-service.js # PDF-Generierung
│ └── ping-service.js # IP-basierte Zeiterfassung
├── views/ # EJS-Templates
├── public/ # Statische Dateien
│ ├── css/
│ ├── js/
│ └── images/
└── helpers/ # Helper-Funktionen
```
## Datenbankstruktur
### Tabelle: users
- Speichert Benutzerinformationen und Zugangsdaten
- Passwörter werden verschlüsselt gespeichert
- Unterstützt mehrere Rollen pro Benutzer
- Personalnummer, Wochenstunden, Urlaubstage, Überstunden-Offset
### Tabelle: timesheet_entries
- Speichert einzelne Tageseinträge
- Automatische Berechnung der Gesamtstunden
- Unterstützt mehrere Tätigkeiten pro Tag
- Überstunden-Verbrauch, Urlaub, Krankheit
### Tabelle: weekly_timesheets
- Speichert eingereichte Wochenstundenzettel
- Verknüpfung mit Benutzer und Status
- Versionsverwaltung und Admin-Kommentare
### Tabelle: system_options
- Systemweite Einstellungen
- Wochenend-Prozentsätze (Samstag/Sonntag)
### Tabelle: public_holidays
- Öffentliche Feiertage (Baden-Württemberg)
- Automatische Aktualisierung
### Tabelle: ldap_config
- LDAP-Konfiguration
- Synchronisationseinstellungen
### Tabelle: ping_status
- IP-basierte Zeiterfassung
- Ping-Status pro Benutzer und Tag
## Features im Detail
### Überstunden-Verwaltung
- Automatische Berechnung basierend auf Sollstunden
- Berücksichtigung von Wochenend-Prozentsätzen
- Feiertagsstunden werden automatisch hinzugefügt
- Urlaubsstunden werden in die Berechnung einbezogen
- Manuelle Korrekturen durch Verwaltung möglich
- Detaillierte wöchentliche Auswertung für Mitarbeiter
### Urlaubsverwaltung
- Ganzer oder halber Tag Urlaub
- Verbleibende Urlaubstage werden angezeigt
- Verplante Urlaubstage werden erfasst
- Urlaubstage werden in Überstunden-Berechnung berücksichtigt
### Wochenend-Prozentsätze
- Konfigurierbare Prozentsätze für Samstag und Sonntag
- Werden automatisch auf gearbeitete Stunden angewendet
- Standard: 100% (keine Zuschläge)
### Feiertage
- Automatische Erkennung öffentlicher Feiertage (Baden-Württemberg)
- Feiertagsstunden werden automatisch berechnet
- Arbeit an Feiertagen zählt als Überstunden
### LDAP-Integration
- Automatische Benutzer-Synchronisation
- Konfigurierbare Synchronisationsintervalle
- Manuelle Synchronisation möglich
- LDAP-Authentifizierung als Alternative zu lokalen Passwörtern
## Sicherheit
@@ -131,6 +257,8 @@ Nach der Installation sind folgende Benutzer verfügbar:
- ✅ Session-basierte Authentifizierung
- ✅ Rollenbasierte Zugriffskontrolle
- ✅ CSRF-Schutz durch Sessions
- ✅ LDAP-Integration für zentrale Authentifizierung
- ✅ SQL-Injection-Schutz durch parametrisierte Queries
## Anpassungen
@@ -142,6 +270,11 @@ const PORT = 3333; // Ändern Sie hier den Port
### Datenbank-Speicherort
Die Datenbank wird standardmäßig als `stundenerfassung.db` im Projektverzeichnis gespeichert.
Über die Umgebungsvariable `DB_PATH` kann ein anderer Pfad angegeben werden.
### Wochenend-Prozentsätze
Im Admin-Bereich können die Prozentsätze für Samstag und Sonntag konfiguriert werden.
Standard: 100% (keine Zuschläge).
## Lizenz

View File

@@ -223,7 +223,7 @@ function initDatabase() {
bind_password TEXT,
base_dn TEXT,
user_search_filter TEXT,
username_attribute TEXT DEFAULT 'cn',
username_attribute TEXT DEFAULT 'sAMAccountName',
firstname_attribute TEXT DEFAULT 'givenName',
lastname_attribute TEXT DEFAULT 'sn',
sync_interval INTEGER DEFAULT 0,
@@ -265,6 +265,14 @@ function initDatabase() {
}
});
// Migration: checkin_root_url Spalte hinzufügen
db.run(`ALTER TABLE system_options ADD COLUMN checkin_root_url TEXT`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert
if (err && !err.message.includes('duplicate column')) {
console.warn('Warnung beim Hinzufügen der Spalte checkin_root_url:', err.message);
}
});
// Migration: Bestehende Rollen zu JSON-Arrays konvertieren
// Prüfe ob Rollen noch als einfache Strings gespeichert sind (nicht als JSON-Array)
db.all('SELECT id, role FROM users', (err, users) => {

View File

@@ -1,44 +0,0 @@
version: "3"
volumes:
lldap_data:
driver: local
services:
lldap:
image: lldap/lldap:stable
ports:
# For LDAP, not recommended to expose, see Usage section.
- "3890:3890"
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
#- "6360:6360"
# For the web front-end
- "17170:17170"
volumes:
- "lldap_data:/data"
# Alternatively, you can mount a local folder
# - "./lldap_data:/data"
environment:
- UID=1000
- GID=1000
- TZ=Europe/Berlin
- LLDAP_JWT_SECRET=1omV4UDprT1PAJFYXGisVlei/V5b5Uaiqssl9qburwV+T1S0ox8iurI6FJkWPnX5xRUMbHswJwZLG7QzUnzdZw==
- LLDAP_KEY_SEED=ffcomviFeT8RByJf7jT3AuzDcFbrgWb+oSuMDp72pql96J4Rdq5gno2Dk1xfWrYPLH5OoS/bpDuOp/oE9T5+sA==
- LLDAP_LDAP_BASE_DN=dc=gmbh,dc=de
- LLDAP_LDAP_USER_PASS=Delfine1!!! # If the password contains '$', escape it (e.g. Pas$$word sets Pas$word)
# If using LDAPS, set enabled true and configure cert and key path
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
# You can also set a different database:
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
# If using SMTP, set the following variables
# - LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
# - LLDAP_SMTP_OPTIONS__SERVER=smtp.example.com
# - LLDAP_SMTP_OPTIONS__PORT=465 # Check your smtp provider's documentation for this setting
# - LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=TLS # How the connection is encrypted, either "NONE" (no encryption, port 25), "TLS" (sometimes called SSL, port 465) or "STARTTLS" (sometimes called TLS, port 587).
# - LLDAP_SMTP_OPTIONS__USER=no-reply@example.com # The SMTP user, usually your email address
# - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password
# - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>.
# - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional.

View File

@@ -65,7 +65,8 @@ document.addEventListener('DOMContentLoaded', function() {
const formData = {
saturday_percentage: document.getElementById('saturdayPercentage').value,
sunday_percentage: document.getElementById('sundayPercentage').value
sunday_percentage: document.getElementById('sundayPercentage').value,
checkin_root_url: document.getElementById('checkinRootUrl') ? document.getElementById('checkinRootUrl').value : null
};
try {
@@ -213,6 +214,9 @@ async function loadOptions() {
if (document.getElementById('sundayPercentage')) {
document.getElementById('sundayPercentage').value = config.sunday_percentage || 0;
}
if (document.getElementById('checkinRootUrl')) {
document.getElementById('checkinRootUrl').value = config.checkin_root_url || '';
}
}
} catch (error) {
console.error('Fehler beim Laden der Optionen:', error);
@@ -244,7 +248,7 @@ async function loadLDAPConfig() {
document.getElementById('ldapSearchFilter').value = config.user_search_filter || '(objectClass=person)';
}
if (document.getElementById('ldapUsernameAttr')) {
document.getElementById('ldapUsernameAttr').value = config.username_attribute || 'cn';
document.getElementById('ldapUsernameAttr').value = config.username_attribute || 'sAMAccountName';
}
if (document.getElementById('ldapFirstnameAttr')) {
document.getElementById('ldapFirstnameAttr').value = config.firstname_attribute || 'givenName';

View File

@@ -307,7 +307,7 @@ resetDatabase().catch((error) => {
bind_password TEXT,
base_dn TEXT,
user_search_filter TEXT,
username_attribute TEXT DEFAULT 'cn',
username_attribute TEXT DEFAULT 'sAMAccountName',
firstname_attribute TEXT DEFAULT 'givenName',
lastname_attribute TEXT DEFAULT 'sn',
sync_interval INTEGER DEFAULT 0,

View File

@@ -1,7 +1,7 @@
// LDAP Admin Routes
const { db } = require('../database');
const LDAPService = require('../ldap-service');
const LDAPService = require('../services/ldap-service');
const { requireAdmin } = require('../middleware/auth');
// Routes registrieren
@@ -55,7 +55,7 @@ function registerAdminLDAPRoutes(app) {
bind_password: bind_password ? bind_password.trim() : null,
base_dn: base_dn.trim(),
user_search_filter: user_search_filter ? user_search_filter.trim() : '(objectClass=person)',
username_attribute: username_attribute ? username_attribute.trim() : 'cn',
username_attribute: username_attribute ? username_attribute.trim() : 'sAMAccountName',
firstname_attribute: firstname_attribute ? firstname_attribute.trim() : 'givenName',
lastname_attribute: lastname_attribute ? lastname_attribute.trim() : 'sn',
sync_interval: parseInt(sync_interval) || 0,

View File

@@ -164,7 +164,8 @@ function registerAdminRoutes(app) {
return res.json({
config: {
saturday_percentage: 100,
sunday_percentage: 100
sunday_percentage: 100,
checkin_root_url: null
}
});
}
@@ -174,7 +175,7 @@ function registerAdminRoutes(app) {
// Optionen speichern
app.post('/admin/options', requireAdmin, (req, res) => {
const { saturday_percentage, sunday_percentage } = req.body;
const { saturday_percentage, sunday_percentage, checkin_root_url } = req.body;
// Validierung
const satPercent = parseFloat(saturday_percentage);
@@ -188,6 +189,12 @@ function registerAdminRoutes(app) {
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) {
@@ -196,8 +203,8 @@ function registerAdminRoutes(app) {
if (existing) {
// Update
db.run('UPDATE system_options SET saturday_percentage = ?, sunday_percentage = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1',
[satPercent, sunPercent],
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' });
@@ -206,8 +213,8 @@ function registerAdminRoutes(app) {
});
} else {
// Insert
db.run('INSERT INTO system_options (id, saturday_percentage, sunday_percentage) VALUES (1, ?, ?)',
[satPercent, sunPercent],
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' });

View File

@@ -2,7 +2,7 @@
const bcrypt = require('bcryptjs');
const { db } = require('../database');
const LDAPService = require('../ldap-service');
const LDAPService = require('../services/ldap-service');
const { getDefaultRole } = require('../helpers/utils');
// Helper-Funktion für erfolgreiche Anmeldung
@@ -76,16 +76,14 @@ function registerAuthRoutes(app) {
// Wenn LDAP aktiviert ist, authentifiziere gegen LDAP
if (isLDAPEnabled) {
LDAPService.authenticate(username, password, (authErr, authSuccess) => {
LDAPService.authenticate(username, password, (authErr, authSuccess, ldapUserInfo) => {
if (authErr || !authSuccess) {
// LDAP-Authentifizierung fehlgeschlagen - prüfe lokale Datenbank als Fallback
// Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive
db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => {
if (err || !user) {
return res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
}
// Versuche lokale Authentifizierung
if (bcrypt.compareSync(password, user.password)) {
handleSuccessfulLogin(req, res, user, rememberMe);
} else {
@@ -93,9 +91,10 @@ function registerAuthRoutes(app) {
}
});
} else {
// LDAP-Authentifizierung erfolgreich - hole Benutzer aus Datenbank
// Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive
db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => {
// LDAP-Authentifizierung erfolgreich - Benutzer anhand des kanonischen LDAP-Benutzernamens aus der DB holen
// (Sync speichert den exakten LDAP-Wert, z. B. "geißlerj" oder "GeisslerJ")
const dbLookupUsername = (ldapUserInfo && ldapUserInfo.username) ? ldapUserInfo.username : username;
db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [dbLookupUsername], (err, user) => {
if (err || !user) {
return res.render('login', { error: 'Benutzer nicht in der Datenbank gefunden. Bitte führen Sie eine LDAP-Synchronisation durch.' });
}

View File

@@ -3,15 +3,36 @@
const { hasRole } = require('../helpers/utils');
const { requireAuth } = require('../middleware/auth');
const { generateCheckinCheckoutQRPDF } = require('../services/pdf-service');
const { db } = require('../database');
// Routes registrieren
function registerDashboardRoutes(app) {
// Check-in Root URL abrufen (öffentlich zugänglich für Konfiguration)
app.get('/api/checkin-root-url', (req, res) => {
db.get('SELECT checkin_root_url FROM system_options WHERE id = 1', (err, options) => {
if (err) {
return res.status(500).json({ error: 'Fehler beim Laden der Root URL' });
}
res.json({
root_url: options && options.checkin_root_url ? options.checkin_root_url : null
});
});
});
// QR-Code-PDF (Check-in/Check-out) nur für eingeloggte Nutzer mit Mitarbeiter-Rolle
app.get('/api/dashboard/qr-pdf', requireAuth, (req, res) => {
// Interne URLs
app.get('/api/dashboard/qr-pdf/internal', requireAuth, (req, res) => {
if (!hasRole(req, 'mitarbeiter')) {
return res.status(403).send('Zugriff verweigert');
}
generateCheckinCheckoutQRPDF(req, res);
generateCheckinCheckoutQRPDF(req, res, 'internal');
});
// Externe URLs
app.get('/api/dashboard/qr-pdf/external', requireAuth, (req, res) => {
if (!hasRole(req, 'mitarbeiter')) {
return res.status(403).send('Zugriff verweigert');
}
generateCheckinCheckoutQRPDF(req, res, 'external');
});
// Dashboard für Mitarbeiter

View File

@@ -1,7 +1,7 @@
// LDAP-Scheduler Service
const { db } = require('../database');
const LDAPService = require('../ldap-service');
const LDAPService = require('./ldap-service');
// Automatische LDAP-Synchronisation einrichten
function setupLDAPScheduler() {

View File

@@ -1,402 +1,417 @@
const ldap = require('ldapjs');
const { db } = require('./database');
const bcrypt = require('bcryptjs');
/**
* LDAP-Service für Benutzer-Synchronisation
*/
class LDAPService {
/**
* LDAP-Konfiguration aus der Datenbank abrufen
*/
static getConfig(callback) {
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, config) => {
if (err) {
return callback(err, null);
}
callback(null, config);
});
}
/**
* LDAP-Verbindung herstellen
*/
static connect(config, callback) {
if (!config || !config.enabled || !config.url) {
return callback(new Error('LDAP ist nicht konfiguriert oder deaktiviert'));
}
const client = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
// Fehlerbehandlung
client.on('error', (err) => {
callback(err, null);
});
// Bind mit Credentials
const bindDN = config.bind_dn || '';
const bindPassword = config.bind_password || '';
// Hinweis: Passwort wird im Klartext gespeichert
// In einer produktiven Umgebung sollte man eine Verschlüsselung mit einem Master-Key verwenden
client.bind(bindDN, bindPassword, (err) => {
if (err) {
client.unbind();
return callback(err, null);
}
callback(null, client);
});
}
/**
* Benutzer aus LDAP abrufen
*/
static searchUsers(client, config, callback) {
const baseDN = config.base_dn || '';
const searchFilter = config.user_search_filter || '(objectClass=person)';
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: [
config.username_attribute || 'cn',
config.firstname_attribute || 'givenName',
config.lastname_attribute || 'sn'
]
};
const users = [];
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
return callback(err, null);
}
res.on('searchEntry', (entry) => {
const user = {
username: this.getAttributeValue(entry, config.username_attribute || 'cn'),
firstname: this.getAttributeValue(entry, config.firstname_attribute || 'givenName'),
lastname: this.getAttributeValue(entry, config.lastname_attribute || 'sn')
};
// Nur Benutzer mit allen erforderlichen Feldern hinzufügen
if (user.username && user.firstname && user.lastname) {
users.push(user);
}
});
res.on('error', (err) => {
callback(err, null);
});
res.on('end', (result) => {
if (result && result.status !== 0) {
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`), null);
}
callback(null, users);
});
});
}
/**
* Wert eines LDAP-Attributs extrahieren
*
* Die ldapjs-Bibliothek behandelt UTF-8-Zeichen automatisch korrekt.
* Diese Funktion stellt sicher, dass UTF-8-Zeichen wie ß, ä, ö, ü korrekt zurückgegeben werden.
*/
static getAttributeValue(entry, attributeName) {
const attr = entry.attributes.find(a => a.type === attributeName);
if (!attr) {
return null;
}
const value = Array.isArray(attr.values) ? attr.values[0] : attr.values;
// Stelle sicher, dass der Wert als String zurückgegeben wird (UTF-8 wird automatisch korrekt behandelt)
return value != null ? String(value) : null;
}
/**
* Escaped einen Wert für LDAP-Filter (verhindert LDAP-Injection)
*
* WICHTIG: UTF-8-Zeichen wie ß, ä, ö, ü müssen NICHT escaped werden.
* LDAP-Filter unterstützen UTF-8 direkt nach RFC 4515.
* Nur die speziellen LDAP-Filter-Zeichen werden escaped.
*/
static escapeLDAPFilter(value) {
if (!value) return '';
// Stelle sicher, dass der Wert als String behandelt wird
const str = String(value);
// Escape nur die speziellen LDAP-Filter-Zeichen
// UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet
return str
.replace(/\\/g, '\\5c') // Backslash
.replace(/\*/g, '\\2a') // Stern
.replace(/\(/g, '\\28') // Öffnende Klammer
.replace(/\)/g, '\\29') // Schließende Klammer
.replace(/\0/g, '\\00'); // Null-Byte
}
/**
* Benutzer in SQLite synchronisieren
*/
static syncUsers(ldapUsers, callback) {
let syncedCount = 0;
let errorCount = 0;
const errors = [];
if (!ldapUsers || ldapUsers.length === 0) {
return callback(null, { synced: 0, errors: [] });
}
// Verarbeite jeden Benutzer
const processUser = (index) => {
if (index >= ldapUsers.length) {
return callback(null, { synced: syncedCount, errors: errors });
}
const ldapUser = ldapUsers[index];
// .trim() behält UTF-8-Zeichen wie ß, ä, ö, ü korrekt bei
// Stelle sicher, dass Werte als String behandelt werden
const username = String(ldapUser.username || '').trim();
const firstname = String(ldapUser.firstname || '').trim();
const lastname = String(ldapUser.lastname || '').trim();
// Prüfe ob Benutzer bereits existiert (case-insensitive)
db.get('SELECT id, role FROM users WHERE username = ? COLLATE NOCASE', [username], (err, existingUser) => {
if (err) {
errors.push(`Fehler beim Prüfen von ${username}: ${err.message}`);
errorCount++;
return processUser(index + 1);
}
if (existingUser) {
// Benutzer existiert - aktualisiere nur Name, behalte Rolle (case-insensitive)
db.run(
'UPDATE users SET firstname = ?, lastname = ? WHERE username = ? COLLATE NOCASE',
[firstname, lastname, username],
(err) => {
if (err) {
errors.push(`Fehler beim Aktualisieren von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
} else {
// Neuer Benutzer - erstelle mit Standard-Rolle
// Generiere ein zufälliges Passwort (Benutzer muss es beim ersten Login ändern)
const defaultPassword = bcrypt.hashSync('changeme123', 10);
db.run(
'INSERT INTO users (username, password, firstname, lastname, role) VALUES (?, ?, ?, ?, ?)',
[username, defaultPassword, firstname, lastname, 'mitarbeiter'],
(err) => {
if (err) {
errors.push(`Fehler beim Erstellen von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
}
});
};
processUser(0);
}
/**
* Sync-Log-Eintrag erstellen
*/
static createSyncLog(syncType, status, usersSynced, errorMessage, callback) {
const startedAt = new Date().toISOString();
const completedAt = new Date().toISOString();
db.run(
`INSERT INTO ldap_sync_log (sync_type, status, users_synced, error_message, sync_started_at, sync_completed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[syncType, status, usersSynced, errorMessage || null, startedAt, completedAt],
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Letzte Synchronisation aktualisieren
*/
static updateLastSync(callback) {
db.run(
'UPDATE ldap_config SET last_sync = CURRENT_TIMESTAMP WHERE id = 1',
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Benutzer gegen LDAP authentifizieren
*
* Unterstützt UTF-8-Zeichen wie ß, ä, ö, ü in Usernamen.
* Die ldapjs-Bibliothek behandelt UTF-8 automatisch korrekt.
*/
static authenticate(username, password, callback) {
// Stelle sicher, dass Username als String behandelt wird (UTF-8 wird korrekt unterstützt)
const usernameStr = String(username || '').trim();
if (!usernameStr) {
return callback(new Error('Benutzername darf nicht leer sein'), false);
}
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err || !config || !config.enabled) {
return callback(new Error('LDAP ist nicht aktiviert'), false);
}
// LDAP-Verbindung herstellen (mit Service-Account)
this.connect(config, (err, client) => {
if (err) {
return callback(err, false);
}
// Suche nach dem Benutzer in LDAP
const baseDN = config.base_dn || '';
const usernameAttr = config.username_attribute || 'cn';
// escapeLDAPFilter behandelt UTF-8-Zeichen korrekt (escaped sie nicht)
const escapedUsername = this.escapeLDAPFilter(usernameStr);
const searchFilter = `(${usernameAttr}=${escapedUsername})`;
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: ['dn', usernameAttr]
};
let userDN = null;
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
client.unbind();
// Verbesserte Fehlermeldung für mögliche Encoding-Probleme
const errorMsg = err.message || String(err);
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${errorMsg}. Hinweis: Prüfen Sie, ob der Benutzername UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt enthält.`), false);
}
res.on('searchEntry', (entry) => {
userDN = entry.dn.toString();
});
res.on('error', (err) => {
client.unbind();
const errorMsg = err.message || String(err);
callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false);
});
res.on('end', (result) => {
// Service-Account-Verbindung schließen
client.unbind();
if (!userDN) {
// Verbesserte Fehlermeldung: Hinweis auf mögliche Encoding-Probleme
return callback(new Error(`Benutzer "${usernameStr}" nicht gefunden. Hinweis: Prüfen Sie, ob der Benutzername korrekt ist und UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt geschrieben sind.`), false);
}
// Versuche, sich mit den Benutzer-Credentials zu binden
const authClient = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
authClient.on('error', (err) => {
authClient.unbind();
callback(err, false);
});
authClient.bind(userDN, password, (err) => {
authClient.unbind();
if (err) {
const errorMsg = err.message || String(err);
return callback(new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), false);
}
callback(null, true);
});
});
});
});
});
}
/**
* Vollständige Synchronisation durchführen
*/
static performSync(syncType, callback) {
const startedAt = new Date();
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `Fehler beim Abrufen der Konfiguration: ${err.message}`, () => {});
return callback(err);
}
if (!config || !config.enabled) {
const errorMsg = 'LDAP-Synchronisation ist nicht aktiviert';
this.createSyncLog(syncType, 'error', 0, errorMsg, () => {});
return callback(new Error(errorMsg));
}
// LDAP-Verbindung herstellen
this.connect(config, (err, client) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Verbindungsfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer aus LDAP abrufen
this.searchUsers(client, config, (err, ldapUsers) => {
// Verbindung schließen
client.unbind();
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Suchfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer synchronisieren
this.syncUsers(ldapUsers, (err, result) => {
if (err) {
this.createSyncLog(syncType, 'error', result.synced, `Sync-Fehler: ${err.message}`, () => {});
return callback(err);
}
// Letzte Synchronisation aktualisieren
this.updateLastSync(() => {
const status = result.errors.length > 0 ? 'error' : 'success';
const errorMsg = result.errors.length > 0 ? result.errors.join('; ') : null;
this.createSyncLog(syncType, status, result.synced, errorMsg, () => {
callback(null, result);
});
});
});
});
});
});
}
}
module.exports = LDAPService;
const ldap = require('ldapjs');
const { db } = require('../database');
const bcrypt = require('bcryptjs');
/**
* LDAP-Service für Benutzer-Synchronisation
*/
class LDAPService {
/**
* LDAP-Konfiguration aus der Datenbank abrufen
*/
static getConfig(callback) {
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, config) => {
if (err) {
return callback(err, null);
}
callback(null, config);
});
}
/**
* LDAP-Verbindung herstellen
*/
static connect(config, callback) {
if (!config || !config.enabled || !config.url) {
return callback(new Error('LDAP ist nicht konfiguriert oder deaktiviert'));
}
const client = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
// Fehlerbehandlung
client.on('error', (err) => {
callback(err, null);
});
// Bind mit Credentials
const bindDN = config.bind_dn || '';
const bindPassword = config.bind_password || '';
// Hinweis: Passwort wird im Klartext gespeichert
// In einer produktiven Umgebung sollte man eine Verschlüsselung mit einem Master-Key verwenden
client.bind(bindDN, bindPassword, (err) => {
if (err) {
client.unbind();
return callback(err, null);
}
callback(null, client);
});
}
/**
* Benutzer aus LDAP abrufen
*/
static searchUsers(client, config, callback) {
const baseDN = config.base_dn || '';
const searchFilter = config.user_search_filter || '(objectClass=person)';
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: [
config.username_attribute || 'sAMAccountName',
config.firstname_attribute || 'givenName',
config.lastname_attribute || 'sn'
]
};
const users = [];
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
return callback(err, null);
}
res.on('searchEntry', (entry) => {
const user = {
username: this.getAttributeValue(entry, config.username_attribute || 'sAMAccountName'),
firstname: this.getAttributeValue(entry, config.firstname_attribute || 'givenName'),
lastname: this.getAttributeValue(entry, config.lastname_attribute || 'sn')
};
// Nur Benutzer mit allen erforderlichen Feldern hinzufügen
if (user.username && user.firstname && user.lastname) {
users.push(user);
}
});
res.on('error', (err) => {
callback(err, null);
});
res.on('end', (result) => {
if (result && result.status !== 0) {
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`), null);
}
callback(null, users);
});
});
}
/**
* Wert eines LDAP-Attributs extrahieren
*
* Die ldapjs-Bibliothek behandelt UTF-8-Zeichen automatisch korrekt.
* Diese Funktion stellt sicher, dass UTF-8-Zeichen wie ß, ä, ö, ü korrekt zurückgegeben werden.
*/
static getAttributeValue(entry, attributeName) {
const attr = entry.attributes.find(a => a.type === attributeName);
if (!attr) {
return null;
}
const value = Array.isArray(attr.values) ? attr.values[0] : attr.values;
// Stelle sicher, dass der Wert als String zurückgegeben wird (UTF-8 wird automatisch korrekt behandelt)
return value != null ? String(value) : null;
}
/**
* Escaped einen Wert für LDAP-Filter (verhindert LDAP-Injection)
*
* WICHTIG: UTF-8-Zeichen wie ß, ä, ö, ü müssen NICHT escaped werden.
* LDAP-Filter unterstützen UTF-8 direkt nach RFC 4515.
* Nur die speziellen LDAP-Filter-Zeichen werden escaped.
*/
static escapeLDAPFilter(value) {
if (!value) return '';
// Stelle sicher, dass der Wert als String behandelt wird
const str = String(value);
// Escape nur die speziellen LDAP-Filter-Zeichen
// UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet
return str
.replace(/\\/g, '\\5c') // Backslash
.replace(/\*/g, '\\2a') // Stern
.replace(/\(/g, '\\28') // Öffnende Klammer
.replace(/\)/g, '\\29') // Schließende Klammer
.replace(/\0/g, '\\00'); // Null-Byte
}
/**
* Benutzer in SQLite synchronisieren
*/
static syncUsers(ldapUsers, callback) {
let syncedCount = 0;
let errorCount = 0;
const errors = [];
if (!ldapUsers || ldapUsers.length === 0) {
return callback(null, { synced: 0, errors: [] });
}
// Verarbeite jeden Benutzer
const processUser = (index) => {
if (index >= ldapUsers.length) {
return callback(null, { synced: syncedCount, errors: errors });
}
const ldapUser = ldapUsers[index];
// .trim() behält UTF-8-Zeichen wie ß, ä, ö, ü korrekt bei
// Stelle sicher, dass Werte als String behandelt werden
const username = String(ldapUser.username || '').trim();
const firstname = String(ldapUser.firstname || '').trim();
const lastname = String(ldapUser.lastname || '').trim();
// Prüfe ob Benutzer bereits existiert (case-insensitive)
db.get('SELECT id, role FROM users WHERE username = ? COLLATE NOCASE', [username], (err, existingUser) => {
if (err) {
errors.push(`Fehler beim Prüfen von ${username}: ${err.message}`);
errorCount++;
return processUser(index + 1);
}
if (existingUser) {
// Benutzer existiert - aktualisiere nur Name, behalte Rolle (case-insensitive)
db.run(
'UPDATE users SET firstname = ?, lastname = ? WHERE username = ? COLLATE NOCASE',
[firstname, lastname, username],
(err) => {
if (err) {
errors.push(`Fehler beim Aktualisieren von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
} else {
// Neuer Benutzer - erstelle mit Standard-Rolle
// Generiere ein zufälliges Passwort (Benutzer muss es beim ersten Login ändern)
const defaultPassword = bcrypt.hashSync('changeme123', 10);
db.run(
'INSERT INTO users (username, password, firstname, lastname, role) VALUES (?, ?, ?, ?, ?)',
[username, defaultPassword, firstname, lastname, 'mitarbeiter'],
(err) => {
if (err) {
errors.push(`Fehler beim Erstellen von ${username}: ${err.message}`);
errorCount++;
} else {
syncedCount++;
}
processUser(index + 1);
}
);
}
});
};
processUser(0);
}
/**
* Sync-Log-Eintrag erstellen
*/
static createSyncLog(syncType, status, usersSynced, errorMessage, callback) {
const startedAt = new Date().toISOString();
const completedAt = new Date().toISOString();
db.run(
`INSERT INTO ldap_sync_log (sync_type, status, users_synced, error_message, sync_started_at, sync_completed_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[syncType, status, usersSynced, errorMessage || null, startedAt, completedAt],
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Letzte Synchronisation aktualisieren
*/
static updateLastSync(callback) {
db.run(
'UPDATE ldap_config SET last_sync = CURRENT_TIMESTAMP WHERE id = 1',
(err) => {
if (callback) {
callback(err);
}
}
);
}
/**
* Suchvarianten für Benutzername (case-insensitive, ß/ss) für LDAP-Filter.
* So findet der Login auch "GeißlerJ", wenn LDAP "geißlerj" oder "GeisslerJ" speichert.
*/
static getUsernameSearchVariants(usernameStr) {
const variants = new Set();
variants.add(usernameStr);
variants.add(usernameStr.toLowerCase());
variants.add(usernameStr.replace(/\u00df/g, 'ss')); // ß -> ss
variants.add(usernameStr.replace(/ss/g, '\u00df')); // ss -> ß (z. B. Geissler -> Geißler)
return [...variants];
}
/**
* Benutzer gegen LDAP authentifizieren
*
* Unterstützt UTF-8-Zeichen wie ß, ä, ö, ü in Usernamen.
* Sucht mit mehreren Varianten (Groß-/Kleinschreibung, ß/ss), damit z. B. "GeißlerJ"
* auch gefunden wird, wenn LDAP "geißlerj" oder "GeisslerJ" speichert.
* Gibt bei Erfolg den kanonischen LDAP-Benutzernamen zurück (für DB-Lookup).
*/
static authenticate(username, password, callback) {
// Stelle sicher, dass Username als String behandelt wird (UTF-8 wird korrekt unterstützt)
const usernameStr = String(username || '').trim();
if (!usernameStr) {
return callback(new Error('Benutzername darf nicht leer sein'), false);
}
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err || !config || !config.enabled) {
return callback(new Error('LDAP ist nicht aktiviert'), false);
}
// LDAP-Verbindung herstellen (mit Service-Account)
this.connect(config, (err, client) => {
if (err) {
return callback(err, false);
}
const baseDN = config.base_dn || '';
const usernameAttr = config.username_attribute || 'sAMAccountName';
// OR-Filter mit mehreren Varianten (exakt, lowercase, ß/ss), damit Login trotz unterschiedlicher Schreibweise funktioniert
const variants = this.getUsernameSearchVariants(usernameStr);
const filterParts = variants.map(v => `(${usernameAttr}=${this.escapeLDAPFilter(v)})`);
const searchFilter = filterParts.length === 1 ? filterParts[0] : `(|${filterParts.join('')})`;
const searchOptions = {
filter: searchFilter,
scope: 'sub',
attributes: ['dn', usernameAttr]
};
let userDN = null;
let canonicalUsername = null;
client.search(baseDN, searchOptions, (err, res) => {
if (err) {
client.unbind();
const errorMsg = err.message || String(err);
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${errorMsg}. Hinweis: Prüfen Sie, ob der Benutzername UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt enthält.`), false);
}
res.on('searchEntry', (entry) => {
userDN = entry.dn.toString();
// Kanonischen Benutzernamen aus LDAP verwenden (für DB-Lookup nach Sync)
canonicalUsername = this.getAttributeValue(entry, usernameAttr) || usernameStr;
});
res.on('error', (err) => {
client.unbind();
const errorMsg = err.message || String(err);
callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false);
});
res.on('end', (result) => {
client.unbind();
if (!userDN) {
return callback(new Error(`Benutzer "${usernameStr}" nicht gefunden. Hinweis: Prüfen Sie, ob der Benutzername korrekt ist und UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt geschrieben sind.`), false);
}
const authClient = ldap.createClient({
url: config.url,
timeout: 10000,
connectTimeout: 10000
});
authClient.on('error', (err) => {
authClient.unbind();
callback(err, false);
});
authClient.bind(userDN, password, (err) => {
authClient.unbind();
if (err) {
const errorMsg = err.message || String(err);
return callback(new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), false);
}
// Erfolg: kanonischen Benutzernamen mitgeben, damit die DB-Lookup mit dem Sync-Benutzernamen funktioniert
callback(null, true, { username: canonicalUsername });
});
});
});
});
});
}
/**
* Vollständige Synchronisation durchführen
*/
static performSync(syncType, callback) {
const startedAt = new Date();
// Konfiguration abrufen
this.getConfig((err, config) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `Fehler beim Abrufen der Konfiguration: ${err.message}`, () => {});
return callback(err);
}
if (!config || !config.enabled) {
const errorMsg = 'LDAP-Synchronisation ist nicht aktiviert';
this.createSyncLog(syncType, 'error', 0, errorMsg, () => {});
return callback(new Error(errorMsg));
}
// LDAP-Verbindung herstellen
this.connect(config, (err, client) => {
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Verbindungsfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer aus LDAP abrufen
this.searchUsers(client, config, (err, ldapUsers) => {
// Verbindung schließen
client.unbind();
if (err) {
this.createSyncLog(syncType, 'error', 0, `LDAP-Suchfehler: ${err.message}`, () => {});
return callback(err);
}
// Benutzer synchronisieren
this.syncUsers(ldapUsers, (err, result) => {
if (err) {
this.createSyncLog(syncType, 'error', result.synced, `Sync-Fehler: ${err.message}`, () => {});
return callback(err);
}
// Letzte Synchronisation aktualisieren
this.updateLastSync(() => {
const status = result.errors.length > 0 ? 'error' : 'success';
const errorMsg = result.errors.length > 0 ? result.errors.join('; ') : null;
this.createSyncLog(syncType, status, result.synced, errorMsg, () => {
callback(null, result);
});
});
});
});
});
});
}
}
module.exports = LDAPService;

View File

@@ -530,18 +530,56 @@ function generatePDFToBuffer(timesheetId, req) {
}
// Check-in/Check-out URL-Basis (wie im Dashboard-Frontend)
function getCheckinBaseUrl(req) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
return baseUrl.replace(/:\d+$/, ':3334');
function getCheckinBaseUrl(req, callback) {
// Versuche Root URL aus Datenbank zu laden
db.get('SELECT checkin_root_url FROM system_options WHERE id = 1', (err, options) => {
if (err) {
console.warn('Fehler beim Laden der Root URL, verwende Fallback:', err);
}
let checkinBaseUrl = null;
if (options && options.checkin_root_url && options.checkin_root_url.trim() !== '') {
checkinBaseUrl = options.checkin_root_url.trim();
// Stelle sicher, dass kein trailing slash vorhanden ist
checkinBaseUrl = checkinBaseUrl.replace(/\/+$/, '');
}
// Fallback: Konstruiere URL aus Request (Port 3334 für Check-in)
if (!checkinBaseUrl) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
}
callback(checkinBaseUrl);
});
}
// PDF mit Check-in- und Check-out-QR-Codes (A4)
async function generateCheckinCheckoutQRPDF(req, res) {
// urlType: 'internal' oder 'external'
async function generateCheckinCheckoutQRPDF(req, res, urlType = 'internal') {
const userId = req.session.userId;
if (!userId) {
return res.status(401).send('Nicht angemeldet');
}
const checkinBaseUrl = getCheckinBaseUrl(req);
let checkinBaseUrl;
if (urlType === 'external') {
// Externe URL: Lade aus Datenbank
checkinBaseUrl = await new Promise((resolve) => {
getCheckinBaseUrl(req, resolve);
});
} else {
// Interne URL: Konstruiere aus Request (Port 3334)
const baseUrl = `${req.protocol}://${req.get('host')}`;
if (baseUrl.match(/:\d+$/)) {
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
} else {
const url = new URL(baseUrl);
checkinBaseUrl = `${url.protocol}//${url.hostname}:3334`;
}
}
const checkinUrl = `${checkinBaseUrl}/api/checkin/${userId}`;
const checkoutUrl = `${checkinBaseUrl}/api/checkout/${userId}`;
@@ -554,11 +592,12 @@ async function generateCheckinCheckoutQRPDF(req, res) {
const firstname = (req.session.firstname || '').replace(/\s+/g, '');
const lastname = (req.session.lastname || '').replace(/\s+/g, '');
const namePart = [firstname, lastname].filter(Boolean).join('_') || 'User';
const urlTypeLabel = urlType === 'external' ? 'Extern' : 'Intern';
const today = new Date();
const dateStr = today.getFullYear() + '-' +
String(today.getMonth() + 1).padStart(2, '0') + '-' +
String(today.getDate()).padStart(2, '0');
const filename = `Check-in_Check-out_QR_${namePart}_${dateStr}.pdf`;
const filename = `Check-in_Check-out_QR_${urlTypeLabel}_${namePart}_${dateStr}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('X-Content-Type-Options', 'nosniff');
@@ -573,7 +612,10 @@ async function generateCheckinCheckoutQRPDF(req, res) {
const left1 = 50 + (pageWidth / 2 - qrSize - gap / 2);
const left2 = 50 + (pageWidth / 2 + gap / 2);
doc.fontSize(18).text('Check-in / Check-out Zeiterfassung', { align: 'center' });
const title = urlType === 'external'
? 'Check-in / Check-out Zeiterfassung (Externe URLs)'
: 'Check-in / Check-out Zeiterfassung (Interne URLs)';
doc.fontSize(18).text(title, { align: 'center' });
doc.moveDown(1.5);
const topY = doc.y;

View File

@@ -234,6 +234,14 @@
</div>
</div>
<div class="form-group" style="margin-top: 20px;">
<h3 style="margin-bottom: 10px;">Check-in URL-Konfiguration</h3>
<p style="margin-bottom: 15px; color: #666;">Definieren Sie die Basis-URL für alle Check-in und Check-out URLs. Diese URL wird vor <code>/api</code> verwendet. Beispiel: https://example.com:3334</p>
<label for="checkinRootUrl">Check-in Root URL</label>
<input type="text" id="checkinRootUrl" name="checkin_root_url" class="form-control" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" placeholder="https://example.com:3334" value="<%= (typeof options !== 'undefined' && options && options.checkin_root_url) ? options.checkin_root_url : '' %>">
<small style="color: #666; display: block; margin-top: 5px;">Lassen Sie dieses Feld leer, um die URL automatisch aus der aktuellen Seite zu generieren.</small>
</div>
<button type="submit" class="btn btn-primary">Optionen speichern</button>
</form>
</div>
@@ -289,7 +297,7 @@
<div class="form-row">
<div class="form-group">
<label for="ldapUsernameAttr">Username-Attribut</label>
<input type="text" id="ldapUsernameAttr" name="username_attribute" placeholder="cn" value="cn">
<input type="text" id="ldapUsernameAttr" name="username_attribute" placeholder="sAMAccountName" value="sAMAccountName">
</div>
<div class="form-group">

View File

@@ -99,28 +99,59 @@
Automatische Zeiterfassung
</h3>
<div id="timeCaptureContent" style="display: none; margin-top: 15px;">
<!-- URL-Erfassung -->
<!-- Interne URLs -->
<div style="margin-bottom: 20px;">
<h4 style="font-size: 13px; margin-bottom: 10px; color: #555; display: flex; align-items: center; gap: 5px;">
Zeiterfassung per URL
<span class="help-icon" onclick="showHelpModal('url-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
<h4 style="font-size: 13px; margin-bottom: 10px; color: #555; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 8px;" onclick="toggleInternalUrls()">
<span class="toggle-icon-internal-urls" style="display: inline-block; transition: transform 0.3s; font-size: 10px;">▶</span>
Interne URLs
<span class="help-icon" onclick="event.stopPropagation(); showHelpModal('url-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1; margin-left: auto;">?</span>
</h4>
<div class="form-group" style="margin-bottom: 15px;">
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-in URL</label>
<div style="display: flex; gap: 5px;">
<input type="text" id="checkinUrl" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
<button onclick="copyToClipboard('checkinUrl')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
<div id="internalUrlsContent" style="display: none; margin-top: 10px;">
<div class="form-group" style="margin-bottom: 15px;">
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-in URL</label>
<div style="display: flex; gap: 5px;">
<input type="text" id="checkinUrlInternal" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
<button onclick="copyToClipboard('checkinUrlInternal')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-out URL</label>
<div style="display: flex; gap: 5px;">
<input type="text" id="checkoutUrlInternal" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
<button onclick="copyToClipboard('checkoutUrlInternal')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
</div>
</div>
<div style="margin-top: 12px;">
<a href="/api/dashboard/qr-pdf/internal" class="btn btn-sm btn-secondary" style="padding: 8px 12px; text-decoration: none; display: inline-block;" download>QR-Code-PDF herunterladen</a>
</div>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-out URL</label>
<div style="display: flex; gap: 5px;">
<input type="text" id="checkoutUrl" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
<button onclick="copyToClipboard('checkoutUrl')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
</div>
<!-- Externe URLs -->
<div id="externalUrlsSection" style="margin-bottom: 20px; display: none;">
<h4 style="font-size: 13px; margin-bottom: 10px; color: #555; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 8px;" onclick="toggleExternalUrls()">
<span class="toggle-icon-external-urls" style="display: inline-block; transition: transform 0.3s; font-size: 10px;">▶</span>
Externe URLs
<span class="help-icon" onclick="event.stopPropagation(); showHelpModal('url-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1; margin-left: auto;">?</span>
</h4>
<div id="externalUrlsContent" style="display: none; margin-top: 10px;">
<div class="form-group" style="margin-bottom: 15px;">
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-in URL</label>
<div style="display: flex; gap: 5px;">
<input type="text" id="checkinUrlExternal" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
<button onclick="copyToClipboard('checkinUrlExternal')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-out URL</label>
<div style="display: flex; gap: 5px;">
<input type="text" id="checkoutUrlExternal" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
<button onclick="copyToClipboard('checkoutUrlExternal')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
</div>
</div>
<div style="margin-top: 12px;">
<a href="/api/dashboard/qr-pdf/external" class="btn btn-sm btn-secondary" style="padding: 8px 12px; text-decoration: none; display: inline-block;" download>QR-Code-PDF herunterladen</a>
</div>
</div>
<div style="margin-top: 12px;">
<a href="/api/dashboard/qr-pdf" class="btn btn-sm btn-secondary" style="padding: 8px 12px; text-decoration: none; display: inline-block;" download>QR-Code-PDF herunterladen</a>
</div>
</div>
@@ -180,6 +211,38 @@
}
}
// Interne URLs ein-/ausklappen
function toggleInternalUrls() {
const content = document.getElementById('internalUrlsContent');
const icon = document.querySelector('.toggle-icon-internal-urls');
if (content && icon) {
if (content.style.display === 'none') {
content.style.display = 'block';
icon.style.transform = 'rotate(90deg)';
} else {
content.style.display = 'none';
icon.style.transform = 'rotate(0deg)';
}
}
}
// Externe URLs ein-/ausklappen
function toggleExternalUrls() {
const content = document.getElementById('externalUrlsContent');
const icon = document.querySelector('.toggle-icon-external-urls');
if (content && icon) {
if (content.style.display === 'none') {
content.style.display = 'block';
icon.style.transform = 'rotate(90deg)';
} else {
content.style.display = 'none';
icon.style.transform = 'rotate(0deg)';
}
}
}
// URL-Kopier-Funktion
function copyToClipboard(inputId) {
const input = document.getElementById(inputId);
@@ -201,24 +264,56 @@
}
// URLs mit aktueller Domain aktualisieren (Port 3334 für Check-in)
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', async function() {
const userId = '<%= user.id %>';
// Interne URLs: Konstruiere aus window.location.origin (Port 3334 für Check-in)
const baseUrl = window.location.origin;
// Check-in URLs verwenden Port 3334
// Ersetze Port in URL oder füge Port hinzu falls nicht vorhanden
let checkinBaseUrl;
let internalBaseUrl;
if (baseUrl.match(/:\d+$/)) {
// Port vorhanden - ersetze ihn
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
internalBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
} else {
// Kein Port - füge Port hinzu
const url = new URL(baseUrl);
checkinBaseUrl = `${url.protocol}//${url.hostname}:3334`;
internalBaseUrl = `${url.protocol}//${url.hostname}:3334`;
}
// Setze interne URLs
const checkinInputInternal = document.getElementById('checkinUrlInternal');
const checkoutInputInternal = document.getElementById('checkoutUrlInternal');
if (checkinInputInternal) checkinInputInternal.value = `${internalBaseUrl}/api/checkin/${userId}`;
if (checkoutInputInternal) checkoutInputInternal.value = `${internalBaseUrl}/api/checkout/${userId}`;
// Externe URLs: Versuche Root URL von API zu laden
let externalBaseUrl = null;
try {
const response = await fetch('/api/checkin-root-url');
const result = await response.json();
if (result.root_url && result.root_url.trim() !== '') {
externalBaseUrl = result.root_url.trim();
// Stelle sicher, dass kein trailing slash vorhanden ist
externalBaseUrl = externalBaseUrl.replace(/\/+$/, '');
}
} catch (error) {
console.warn('Fehler beim Laden der externen Root URL:', error);
}
// Zeige externe Sektion nur an, wenn Root URL konfiguriert ist
const externalSection = document.getElementById('externalUrlsSection');
if (externalBaseUrl) {
// Setze externe URLs
const checkinInputExternal = document.getElementById('checkinUrlExternal');
const checkoutInputExternal = document.getElementById('checkoutUrlExternal');
if (checkinInputExternal) checkinInputExternal.value = `${externalBaseUrl}/api/checkin/${userId}`;
if (checkoutInputExternal) checkoutInputExternal.value = `${externalBaseUrl}/api/checkout/${userId}`;
// Zeige externe Sektion an
if (externalSection) externalSection.style.display = 'block';
} else {
// Verstecke externe Sektion
if (externalSection) externalSection.style.display = 'none';
}
const checkinInput = document.getElementById('checkinUrl');
const checkoutInput = document.getElementById('checkoutUrl');
if (checkinInput) checkinInput.value = `${checkinBaseUrl}/api/checkin/${userId}`;
if (checkoutInput) checkoutInput.value = `${checkinBaseUrl}/api/checkout/${userId}`;
// Rollenwechsel-Handler
const roleSwitcher = document.getElementById('roleSwitcher');