diff --git a/.cursor/plans/mysql-integration_mit_admin-konfiguration_20cdec3c.plan.md b/.cursor/plans/mysql-integration_mit_admin-konfiguration_20cdec3c.plan.md new file mode 100644 index 0000000..b6304fd --- /dev/null +++ b/.cursor/plans/mysql-integration_mit_admin-konfiguration_20cdec3c.plan.md @@ -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 \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index effa617..c8f6d35 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,3 +18,4 @@ Thumbs.db logs *.log dev +backup/ diff --git a/.gitignore b/.gitignore index 9b58196..71f4eeb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +#Backups +backup/ + # Dependencies node_modules/ npm-debug.log* diff --git a/README.md b/README.md index a3a1869..eef8a9e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/database.js b/database.js index 1812054..3b6524a 100644 --- a/database.js +++ b/database.js @@ -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) => { diff --git a/dev/ldapserver/docker-compose.yml b/dev/ldapserver/docker-compose.yml deleted file mode 100644 index e773fa1..0000000 --- a/dev/ldapserver/docker-compose.yml +++ /dev/null @@ -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 # 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 # Same for reply-to, optional. \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js index 734da52..32abd31 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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'; diff --git a/reset-db.js b/reset-db.js index 61a6ccc..c453586 100644 --- a/reset-db.js +++ b/reset-db.js @@ -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, diff --git a/routes/admin-ldap.js b/routes/admin-ldap.js index 4e84c2e..ec78f62 100644 --- a/routes/admin-ldap.js +++ b/routes/admin-ldap.js @@ -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, diff --git a/routes/admin.js b/routes/admin.js index 02f0bb5..49ffe49 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -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' }); diff --git a/routes/auth.js b/routes/auth.js index 3c530ae..5a13bd9 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -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.' }); } diff --git a/routes/dashboard.js b/routes/dashboard.js index 8b8beaf..5a65961 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -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 diff --git a/services/ldap-scheduler.js b/services/ldap-scheduler.js index b6e74c4..24237d6 100644 --- a/services/ldap-scheduler.js +++ b/services/ldap-scheduler.js @@ -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() { diff --git a/ldap-service.js b/services/ldap-service.js similarity index 87% rename from ldap-service.js rename to services/ldap-service.js index 0993fd8..243efb5 100644 --- a/ldap-service.js +++ b/services/ldap-service.js @@ -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; diff --git a/services/pdf-service.js b/services/pdf-service.js index 73700a9..dcf592d 100644 --- a/services/pdf-service.js +++ b/services/pdf-service.js @@ -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; diff --git a/views/admin.ejs b/views/admin.ejs index 2f3d97f..4a86d7c 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -234,6 +234,14 @@ +
+

Check-in URL-Konfiguration

+

Definieren Sie die Basis-URL für alle Check-in und Check-out URLs. Diese URL wird vor /api verwendet. Beispiel: https://example.com:3334

+ + + Lassen Sie dieses Feld leer, um die URL automatisch aus der aktuellen Seite zu generieren. +
+ @@ -289,7 +297,7 @@
- +
diff --git a/views/dashboard.ejs b/views/dashboard.ejs index af80a2f..869c77f 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -99,28 +99,59 @@ Automatische Zeiterfassung