Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-20 21:31:04 +02:00
Commit 6b9b6d4f20
1821 geänderte Dateien mit 348527 neuen und 0 gelöschten Zeilen

44
.claude/settings.local.json Normale Datei
Datei anzeigen

@ -0,0 +1,44 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(npm install:*)",
"Bash(cp:*)",
"Bash(chmod:*)",
"Bash(ls:*)",
"Bash(rm:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(npx vite build:*)",
"Bash(npm run build:*)",
"Bash(sed:*)",
"Bash(touch:*)",
"Bash(build-executables.bat)",
"Bash(cmd.exe:*)",
"Bash(\"C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe\" \"installer\\SkillMate-Setup.iss\")",
"Bash(where node)",
"Bash(node:*)",
"Bash(npm:*)",
"Bash(python:*)",
"WebFetch(domain:www.desk.ly)",
"Bash(cmd /c:*)",
"Bash(curl:*)",
"Bash(taskkill:*)",
"Bash(set PORT=3004)",
"Bash(npx tsc:*)",
"Bash(wmic process where:*)",
"Bash(sqlite3:*)",
"Bash(set PORT=3006)",
"Bash(dir:*)",
"Bash(set FIELD_ENCRYPTION_KEY=dev_field_key_change_in_production_32chars_min!)",
"Bash(timeout:*)",
"Bash(tasklist:*)",
"Bash(set PORT=5000)",
"Bash(set PORT=3005)",
"Bash(set FIELD_ENCRYPTION_KEY=dev_field_encryption_key_32chars_min!)",
"Bash(start:*)"
],
"deny": []
}
}

Datei anzeigen

@ -0,0 +1,172 @@
================================================================================
SKILLMATE PROJEKT STATUS
Stand: 15.07.2025
================================================================================
PROJEKT ÜBERSICHT:
==================
SkillMate - Mitarbeiter-Fähigkeiten-Management-System für deutsche Sicherheitsbehörden
HAUPTKOMPONENTEN:
=================
✓ Backend Server (Node.js/TypeScript) - Port 3000
✓ Frontend Anwendung (React/TypeScript) - Port 5173
✓ Admin Panel (React/TypeScript) - Port 3001
✓ Shared Library (gemeinsame Types/Constants)
ERFOLGREICH ABGESCHLOSSENE AUFGABEN:
====================================
1. GRUNDLEGENDE INSTALLATION:
- install.bat (funktionsfähig, vom Benutzer bestätigt)
- install.sh (Linux-Unterstützung)
- Vollständige NPM-Abhängigkeiten-Installation
- Node.js Verfügbarkeitsprüfung
2. PROFESSIONELLER WINDOWS INSTALLER:
✓ SkillMate-Setup.bat - Hauptinstaller
✓ Separate ausführbare Dateien erstellt:
- SkillMate.exe (Hauptanwendung)
- SkillMate-Backend.exe (Backend separat startbar)
- SkillMate-Admin.exe (Admin Panel)
✓ Vollautomatische Installation nach Program Files
✓ Startmenü-Verknüpfungen
✓ Admin-Rechte-Prüfung
✓ Node.js Abhängigkeitsprüfung
✓ Deutsche Benutzerführung
✓ Professionelle Installations-UI
3. INSTALLER-INFRASTRUKTUR:
✓ IExpress-basierte EXE-Erstellung
✓ Windows Batch-Wrapper für alle Komponenten
✓ Automatische NPM-Installation während Setup
✓ Robuste Fehlerbehandlung
AKTUELLE DATEISTRUKTUR:
======================
HAUPTVERZEICHNIS (/mnt/c/Users/hendr/Desktop/Arbeit/SkillMate/):
├── SkillMate-Setup.bat ← HAUPTINSTALLER (FERTIG)
├── install.bat ← Einfacher Installer (funktioniert)
├── backend/ ← Backend Server
│ ├── src/
│ ├── package.json
│ └── ...
├── frontend/ ← Frontend Anwendung
│ ├── src/
│ ├── package.json
│ └── ...
├── admin-panel/ ← Admin Panel
│ ├── src/
│ ├── package.json
│ └── ...
├── shared/ ← Gemeinsame Library
├── installer/ ← Installer-Dateien
│ ├── executables/
│ │ ├── SkillMate.exe ← Hauptanwendung EXE
│ │ ├── SkillMate-Backend.exe ← Backend EXE
│ │ └── SkillMate-Admin.exe ← Admin EXE
│ ├── setup-scripts/
│ └── ...
└── package.json ← Root Package
WICHTIGE ERKENNTNISSE:
=====================
1. BENUTZER-FEEDBACK:
- "Das hat geklappt mit der install.bat" ✓
- Wunsch nach professionellem MSI/EXE-Installer ✓ (erfüllt)
- Backend muss separat startbar sein ✓ (erfüllt)
- Bitte keine mehreren Dateien erstellen ✓ (beachtet)
2. TECHNISCHE LÖSUNGEN:
- Inno Setup war nicht installiert → Alternative mit Windows-Bordmitteln
- PowerShell-Parsing-Probleme → Wechsel zu reinem Batch
- Node_modules-Kopierprobleme → Frische NPM-Installation während Setup
- IExpress für EXE-Erstellung funktioniert perfekt
3. INSTALLER-DETAILS:
- Installation nach: %ProgramFiles%\SkillMate
- Startmenü-Integration: "SkillMate" Ordner mit 3 Verknüpfungen
- Admin-Rechte erforderlich (wird geprüft)
- Node.js Abhängigkeit wird validiert
- Deutsche Sprache durchgehend
VERWENDUNG DES INSTALLERS:
=========================
FÜR BENUTZER:
1. SkillMate-Setup.bat als Administrator ausführen
2. Anweisungen folgen (Node.js muss installiert sein)
3. Nach Installation über Startmenü starten:
- "SkillMate" (Hauptanwendung - Frontend + Backend)
- "SkillMate Backend" (Nur Backend Server)
- "SkillMate Admin Panel" (Administration)
STANDARD-LOGIN:
- Benutzername: admin
- Passwort: admin123
URLs NACH START:
- Frontend: http://localhost:5173
- Admin Panel: http://localhost:3001
- Backend API: http://localhost:3000
OFFENE PUNKTE / MÖGLICHE ERWEITERUNGEN:
======================================
1. OPTIONAL - WEITERE VERBESSERUNGEN:
- Icon-Dateien für professionelleres Aussehen
- Digitale Signierung der EXE-Dateien
- MSI-Paket mit Windows Installer XML (WiX)
- Automatische Updates-Funktionalität
- Deinstallations-Routine
2. OPTIONAL - ERWEITERTE FEATURES:
- Service-Installation für Backend (Windows Service)
- Desktop-Verknüpfungen (optional)
- Portable Version ohne Installation
- Silent-Installation-Modus
ERFOLG:
=======
✅ ALLE BENUTZERANFORDERUNGEN ERFÜLLT!
Der Benutzer wollte:
1. ✓ Ausführbare Installer-Datei (SkillMate-Setup.bat)
2. ✓ Windows-Installer mit Schritt-für-Schritt Führung
3. ✓ Separate ausführbare Datei für Backend
4. ✓ Professionelle Installation mit Erklärungen
5. ✓ Deutsche Benutzeroberfläche
NÄCHSTE SCHRITTE BEI FORTSETZUNG:
=================================
FALLS WEITERE ARBEITEN GEWÜNSCHT:
1. Testen des Installers auf verschiedenen Windows-Systemen
2. Eventuell MSI-Paket erstellen (wenn Inno Setup installiert wird)
3. Automatische Update-Funktionalität implementieren
4. Performance-Optimierungen
5. Zusätzliche Sicherheitsfeatures
WICHTIGE DATEIEN ZUM WEITERARBEITEN:
====================================
- SkillMate-Setup.bat (Hauptinstaller)
- installer/executables/*.exe (Fertige EXE-Dateien)
- install.bat (Einfache Alternative)
- installer/executables/build-executables.bat (EXE-Erstellung)
TECHNISCHER STACK:
==================
- Backend: Node.js, TypeScript, SQLite
- Frontend: React, TypeScript, Vite
- Admin: React, TypeScript, Vite
- Installer: Windows Batch, IExpress
- Sprache: Deutsch
PROJEKT STATUS: ✅ ERFOLGREICH ABGESCHLOSSEN
BENUTZER-ZUFRIEDENHEIT: ✅ ALLE ANFORDERUNGEN ERFÜLLT
================================================================================
ENDE STATUS
================================================================================

Datei anzeigen

@ -0,0 +1,61 @@
# SkillMate - Refactored Version
## 🚀 Schnellstart
Die Anwendung kann jetzt komplett über eine einzige Datei gestartet werden:
### Windows:
```cmd
start-skillmate.cmd
```
### Linux/Mac:
```bash
./start-skillmate.sh
```
### Alternativ mit Python direkt:
```bash
python main.py
```
## 📋 Voraussetzungen
- **Python 3.8+** (für main.py)
- **Node.js 18+** und **npm** (für Frontend/Backend)
## 🏗️ Neue Struktur
- `main.py` - Zentraler Einstiegspunkt, startet alle Komponenten
- `start-skillmate.cmd` - Windows Starter-Script
- `start-skillmate.sh` - Linux/Mac Starter-Script
- `requirements.txt` - Python-Abhängigkeiten (nur requests)
## ⚙️ Was macht main.py?
1. **Prüft Voraussetzungen**: Node.js und npm müssen installiert sein
2. **Installiert Abhängigkeiten**: Automatisch bei erstem Start
3. **Startet Backend**: Auf Port 3001
4. **Startet Frontend**: Auf Port 5173
5. **Startet Admin-Panel**: Auf Port 5174 (optional)
6. **Öffnet Browser**: Automatisch das Frontend
## 🛑 Beenden
Drücken Sie `Strg+C` um alle Komponenten sauber zu beenden.
## 🔄 Änderungen zum Original
- **Kein Installer mehr nötig** - Direkte Ausführung
- **Vereinfachter Start** - Ein Befehl startet alles
- **Automatische Abhängigkeiten** - Installation bei Bedarf
- **Prozess-Management** - Sauberes Beenden aller Komponenten
## 📁 Entfernte Dateien
Alle Installer-bezogenen Dateien wurden entfernt:
- Alle `.bat` Installer-Skripte
- Der komplette `installer/` Ordner
- Installation-Dokumentation
Die Kernfunktionalität bleibt vollständig erhalten!

Datei anzeigen

@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Debug-Version zum Testen des Backends"""
import subprocess
import os
from pathlib import Path
def test_backend():
base_dir = Path(__file__).parent.absolute()
backend_dir = base_dir / "backend"
print(f"Backend-Verzeichnis: {backend_dir}")
print(f"Existiert: {backend_dir.exists()}")
if not backend_dir.exists():
print("❌ Backend-Verzeichnis nicht gefunden!")
return
# Prüfe package.json
package_json = backend_dir / "package.json"
if not package_json.exists():
print("❌ package.json nicht gefunden!")
return
print("\n📁 Backend-Inhalt:")
for item in backend_dir.iterdir():
print(f" - {item.name}")
# Prüfe node_modules
node_modules = backend_dir / "node_modules"
if not node_modules.exists():
print("\n⚠️ node_modules nicht gefunden! Installiere Dependencies...")
subprocess.run(["npm", "install"], cwd=backend_dir, shell=True)
print("\n🚀 Starte Backend...")
# Starte Backend mit sichtbarer Ausgabe
process = subprocess.Popen(
"npm run dev",
cwd=backend_dir,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
bufsize=1
)
# Zeige Live-Ausgabe
try:
for line in process.stdout:
print(f" {line}", end='')
except KeyboardInterrupt:
process.terminate()
print("\n\n🛑 Backend gestoppt.")
if __name__ == "__main__":
print("🔍 SkillMate Backend Debug\n")
test_backend()

Datei anzeigen

@ -0,0 +1,62 @@
const Database = require('better-sqlite3');
const bcryptjs = require('bcryptjs'); // Use bcryptjs instead of bcrypt
const crypto = require('crypto');
const path = require('path');
// Hash function for email
function hashEmail(email) {
if (!email) return null;
return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex');
}
async function fixAdminPassword() {
const dbPath = path.join(__dirname, 'skillmate.dev.db');
console.log(`Opening database at: ${dbPath}`);
const db = new Database(dbPath);
try {
console.log('\n=== Fixing Admin User ===\n');
const password = 'admin123';
const hashedPassword = await bcryptjs.hash(password, 10);
const emailHash = hashEmail('admin@skillmate.local');
console.log('Setting admin password with bcryptjs...');
console.log('Email hash:', emailHash);
// Update admin user
const result = db.prepare(`
UPDATE users
SET password = ?, email_hash = ?
WHERE username = 'admin'
`).run(hashedPassword, emailHash);
console.log('Rows updated:', result.changes);
// Verify the update
const adminUser = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
if (adminUser) {
console.log('\nAdmin user updated:');
console.log('- Username:', adminUser.username);
console.log('- Email hash:', adminUser.email_hash);
console.log('- Password hash:', adminUser.password.substring(0, 20) + '...');
// Test password
const isValid = await bcryptjs.compare('admin123', adminUser.password);
console.log('- Password verification:', isValid ? 'PASS' : 'FAIL');
}
console.log('\n✓ Admin user fixed successfully!');
console.log('Username: admin');
console.log('Password: admin123');
} catch (error) {
console.error('Error fixing admin:', error);
process.exit(1);
} finally {
db.close();
}
}
// Run the fix
fixAdminPassword();

Datei anzeigen

@ -0,0 +1,71 @@
const Database = require('better-sqlite3');
const CryptoJS = require('crypto-js');
const path = require('path');
// Get encryption key from environment
const FIELD_ENCRYPTION_KEY = process.env.FIELD_ENCRYPTION_KEY || 'dev_field_key_change_in_production_32chars_min!';
// Encryption functions
function encrypt(text) {
if (!text) return null;
try {
return CryptoJS.AES.encrypt(text, FIELD_ENCRYPTION_KEY).toString();
} catch (error) {
console.error('Encryption error:', error);
return text;
}
}
function isEncrypted(text) {
// Check if text looks encrypted (AES encrypted strings start with 'U2FsdGVkX1')
return text && text.includes('U2FsdGVkX1');
}
// Open database
const dbPath = path.join(__dirname, 'skillmate.dev.db');
console.log(`Opening database at: ${dbPath}`);
const db = new Database(dbPath);
try {
console.log('\n=== Fixing User Email Encryption ===\n');
// Get all users
const users = db.prepare('SELECT id, username, email FROM users').all();
console.log(`Found ${users.length} users`);
let updatedCount = 0;
for (const user of users) {
// Skip if email is already encrypted
if (isEncrypted(user.email)) {
console.log(`✓ User ${user.username}: Email already encrypted`);
continue;
}
// Skip if email is null
if (!user.email) {
console.log(`- User ${user.username}: No email to encrypt`);
continue;
}
// Encrypt the email
const encryptedEmail = encrypt(user.email);
if (encryptedEmail && encryptedEmail !== user.email) {
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(encryptedEmail, user.id);
console.log(`✓ User ${user.username}: Email encrypted successfully`);
updatedCount++;
} else {
console.log(`✗ User ${user.username}: Failed to encrypt email`);
}
}
console.log(`\n=== Summary ===`);
console.log(`Total users: ${users.length}`);
console.log(`Emails encrypted: ${updatedCount}`);
console.log(`\n✓ Database encryption fix completed successfully!`);
} catch (error) {
console.error('Error during encryption fix:', error);
process.exit(1);
} finally {
db.close();
}

Datei anzeigen

@ -0,0 +1,13 @@
@echo off
echo Fixing better-sqlite3 for Windows...
cd backend
echo Removing old better-sqlite3...
rmdir /s /q node_modules\better-sqlite3 2>nul
echo Reinstalling better-sqlite3...
npm install better-sqlite3 --build-from-source
echo Done!
pause

Datei anzeigen

@ -0,0 +1,62 @@
const Database = require('better-sqlite3');
const crypto = require('crypto');
const path = require('path');
// Hash function for email
function hashEmail(email) {
if (!email) return null;
return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex');
}
async function fixUserTable() {
const dbPath = path.join(__dirname, 'skillmate.dev.db');
console.log(`Opening database at: ${dbPath}`);
const db = new Database(dbPath);
try {
console.log('\n=== Adding email_hash column to users table ===\n');
// Check if column already exists
const columns = db.prepare('PRAGMA table_info(users)').all();
const hasEmailHash = columns.some(col => col.name === 'email_hash');
if (!hasEmailHash) {
console.log('Adding email_hash column...');
db.prepare('ALTER TABLE users ADD COLUMN email_hash TEXT').run();
// Update existing users with email hash
const users = db.prepare('SELECT id, username, email FROM users').all();
console.log(`Updating ${users.length} users with email hash...`);
const updateStmt = db.prepare('UPDATE users SET email_hash = ? WHERE id = ?');
for (const user of users) {
// For admin user, we know the email
if (user.username === 'admin') {
const emailHash = hashEmail('admin@skillmate.local');
updateStmt.run(emailHash, user.id);
console.log(`Updated admin user with email hash`);
}
}
} else {
console.log('email_hash column already exists');
// Make sure admin has the correct email_hash
const adminEmail = 'admin@skillmate.local';
const emailHash = hashEmail(adminEmail);
console.log('Updating admin email_hash...');
db.prepare('UPDATE users SET email_hash = ? WHERE username = ?').run(emailHash, 'admin');
}
console.log('\n✓ User table fixed successfully!');
} catch (error) {
console.error('Error fixing user table:', error);
process.exit(1);
} finally {
db.close();
}
}
// Run the fix
fixUserTable();

Datei anzeigen

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
SkillMate - Windows-optimierter Haupteinstiegspunkt
"""
import os
import sys
import subprocess
import time
import webbrowser
import signal
import atexit
from pathlib import Path
class SkillMateStarter:
def __init__(self):
self.processes = []
self.base_dir = Path(__file__).parent.absolute()
# Registriere Cleanup-Handler
atexit.register(self.cleanup)
if sys.platform != 'win32':
signal.signal(signal.SIGINT, self.signal_handler)
signal.signal(signal.SIGTERM, self.signal_handler)
def signal_handler(self, signum, frame):
"""Handle Strg+C und andere Signale"""
print("\n\n🛑 SkillMate wird beendet...")
self.cleanup()
sys.exit(0)
def cleanup(self):
"""Beende alle gestarteten Prozesse"""
for process in self.processes:
try:
if process.poll() is None: # Prozess läuft noch
if sys.platform == 'win32':
subprocess.call(['taskkill', '/F', '/T', '/PID', str(process.pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
else:
process.terminate()
process.wait(timeout=5)
except:
pass
def check_node_npm(self):
"""Prüfe ob Node.js und npm installiert sind"""
try:
# Windows-spezifische Prüfung
if sys.platform == 'win32':
subprocess.run(["node", "--version"],
capture_output=True,
check=True,
shell=True)
subprocess.run(["npm", "--version"],
capture_output=True,
check=True,
shell=True)
else:
subprocess.run(["node", "--version"], capture_output=True, check=True)
subprocess.run(["npm", "--version"], capture_output=True, check=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
print("❌ Node.js und npm müssen installiert sein!")
print(" Bitte installieren Sie Node.js von: https://nodejs.org/")
return False
def start_services_windows(self):
"""Starte alle Services in Windows mit separaten CMD-Fenstern"""
print("🚀 Starte SkillMate Services...")
# Backend
backend_cmd = f'cd /d "{self.base_dir}\\backend" && npm run dev'
subprocess.Popen(['cmd', '/c', f'start "SkillMate Backend" cmd /k {backend_cmd}'])
# Warte bis Backend bereit ist
print(" Warte auf Backend...")
time.sleep(5)
# Frontend
frontend_cmd = f'cd /d "{self.base_dir}\\frontend" && npm run dev'
subprocess.Popen(['cmd', '/c', f'start "SkillMate Frontend" cmd /k {frontend_cmd}'])
# Admin Panel (optional)
admin_dir = self.base_dir / "admin-panel"
if admin_dir.exists():
admin_cmd = f'cd /d "{admin_dir}" && npm run dev -- --port 5174'
subprocess.Popen(['cmd', '/c', f'start "SkillMate Admin" cmd /k {admin_cmd}'])
print("\n✨ SkillMate läuft!")
print("\n📍 Zugriff:")
print(" - Frontend: http://localhost:5173")
print(" - Backend: http://localhost:3001")
print(" - Admin: http://localhost:5174")
# Öffne Frontend im Browser
time.sleep(3)
webbrowser.open("http://localhost:5173")
print("\n⚡ Schließen Sie dieses Fenster, um SkillMate zu beenden")
def run(self):
"""Hauptausführungsmethode"""
print("🎯 SkillMate wird gestartet...\n")
# Prüfe Voraussetzungen
if not self.check_node_npm():
return False
# Windows-spezifischer Start
if sys.platform == 'win32':
self.start_services_windows()
# Halte das Hauptfenster offen
try:
input("\nDrücken Sie Enter zum Beenden...")
except KeyboardInterrupt:
pass
else:
print("❌ Dieses Skript ist für Windows optimiert.")
print(" Nutzen Sie 'python main.py' für andere Systeme.")
return False
return True
def main():
"""Haupteinstiegspunkt"""
starter = SkillMateStarter()
try:
success = starter.run()
if not success:
sys.exit(1)
except Exception as e:
print(f"\n❌ Unerwarteter Fehler: {e}")
starter.cleanup()
sys.exit(1)
if __name__ == "__main__":
main()

Datei anzeigen

@ -0,0 +1,74 @@
const Database = require('better-sqlite3');
const bcrypt = require('bcrypt');
const CryptoJS = require('crypto-js');
const path = require('path');
// Get encryption key from environment
const FIELD_ENCRYPTION_KEY = process.env.FIELD_ENCRYPTION_KEY || 'dev_field_key_change_in_production_32chars_min!';
// Encryption function
function encrypt(text) {
if (!text) return null;
try {
return CryptoJS.AES.encrypt(text, FIELD_ENCRYPTION_KEY).toString();
} catch (error) {
console.error('Encryption error:', error);
return text;
}
}
async function resetAdmin() {
const dbPath = path.join(__dirname, 'skillmate.dev.db');
console.log(`Opening database at: ${dbPath}`);
const db = new Database(dbPath);
try {
console.log('\n=== Resetting Admin User ===\n');
// Check if admin exists
const existingAdmin = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
const password = 'admin123';
const hashedPassword = await bcrypt.hash(password, 10);
const encryptedEmail = encrypt('admin@skillmate.local');
if (existingAdmin) {
console.log('Admin user exists, updating...');
db.prepare(`
UPDATE users
SET password = ?, email = ?, role = 'admin', is_active = 1, updated_at = ?
WHERE username = 'admin'
`).run(hashedPassword, encryptedEmail, new Date().toISOString());
} else {
console.log('Creating new admin user...');
const adminId = 'admin-' + Date.now();
db.prepare(`
INSERT INTO users (id, username, email, password, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
adminId,
'admin',
encryptedEmail,
hashedPassword,
'admin',
1,
new Date().toISOString(),
new Date().toISOString()
);
}
console.log('\n✓ Admin user reset successfully!');
console.log('Username: admin');
console.log('Password: admin123');
console.log('Email: admin@skillmate.local');
} catch (error) {
console.error('Error resetting admin:', error);
process.exit(1);
} finally {
db.close();
}
}
// Run the reset
resetAdmin();

67
.cleanup-backup/test-auth.js Normale Datei
Datei anzeigen

@ -0,0 +1,67 @@
const Database = require('better-sqlite3');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const path = require('path');
// Hash function for email
function hashEmail(email) {
if (!email) return null;
return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex');
}
async function testAuth() {
const dbPath = path.join(__dirname, 'skillmate.dev.db');
console.log(`Opening database at: ${dbPath}`);
const db = new Database(dbPath);
try {
console.log('\n=== Testing Authentication ===\n');
// Get admin user
const adminUser = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
if (adminUser) {
console.log('Admin user found:');
console.log('- ID:', adminUser.id);
console.log('- Username:', adminUser.username);
console.log('- Email (encrypted):', adminUser.email ? adminUser.email.substring(0, 20) + '...' : null);
console.log('- Email hash:', adminUser.email_hash);
console.log('- Role:', adminUser.role);
console.log('- Is Active:', adminUser.is_active);
// Test password
const password = 'admin123';
const isValidPassword = await bcrypt.compare(password, adminUser.password);
console.log('\nPassword test (admin123):', isValidPassword ? 'PASS' : 'FAIL');
// Check email hash
const expectedEmailHash = hashEmail('admin@skillmate.local');
console.log('\nExpected email hash:', expectedEmailHash);
console.log('Actual email hash:', adminUser.email_hash);
console.log('Email hash match:', adminUser.email_hash === expectedEmailHash ? 'PASS' : 'FAIL');
// Test email login
const emailHash = hashEmail('admin@skillmate.local');
const userByEmail = db.prepare('SELECT * FROM users WHERE email_hash = ? AND is_active = 1').get(emailHash);
console.log('\nEmail login test:', userByEmail ? 'User found' : 'User NOT found');
// Test username login
const userByUsername = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get('admin');
console.log('Username login test:', userByUsername ? 'User found' : 'User NOT found');
} else {
console.log('Admin user not found!');
}
console.log('\n=== Test complete ===');
} catch (error) {
console.error('Error testing auth:', error);
process.exit(1);
} finally {
db.close();
}
}
// Run the test
testAuth();

Datei anzeigen

@ -0,0 +1,10 @@
@echo off
echo Testing Backend...
cd backend
echo.
echo Running npm run dev...
npm run dev
pause

Datei anzeigen

@ -0,0 +1,87 @@
const axios = require('axios');
const API_URL = 'http://localhost:3004/api';
async function testCreateEmployee() {
try {
console.log('=== Testing Employee Creation with User Account ===\n');
// First login as admin
console.log('1. Logging in as admin...');
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
username: 'admin',
password: 'admin123'
});
const token = loginResponse.data.token;
console.log('✓ Login successful, token received\n');
// Create a new employee with user account
console.log('2. Creating new employee with superuser account...');
const employeeData = {
firstName: 'Max',
lastName: 'Mustermann',
email: 'max.mustermann@test.com',
department: 'IT',
userRole: 'superuser',
createUser: true
};
const createResponse = await axios.post(
`${API_URL}/employees`,
employeeData,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
}
);
console.log('✓ Employee created successfully!');
console.log('Response:', JSON.stringify(createResponse.data, null, 2));
if (createResponse.data.data?.temporaryPassword) {
console.log('\n=== IMPORTANT ===');
console.log('Temporary Password:', createResponse.data.data.temporaryPassword);
console.log('Employee ID:', createResponse.data.data.id);
console.log('User ID:', createResponse.data.data.userId);
console.log('=================\n');
}
// Try to fetch users to verify decryption works
console.log('3. Fetching users list to verify decryption...');
const usersResponse = await axios.get(
`${API_URL}/admin/users`,
{
headers: {
'Authorization': `Bearer ${token}`
}
}
);
console.log('✓ Users fetched successfully!');
console.log('Total users:', usersResponse.data.data.length);
const newUser = usersResponse.data.data.find(u => u.email === employeeData.email);
if (newUser) {
console.log('✓ New user found in list:', {
username: newUser.username,
email: newUser.email,
role: newUser.role
});
}
console.log('\n✓ All tests passed successfully!');
} catch (error) {
console.error('\n✗ Test failed:', error.response?.data || error.message);
if (error.response?.data?.error?.details) {
console.error('Validation errors:', error.response.data.error.details);
}
process.exit(1);
}
}
// Run the test
testCreateEmployee();

Datei anzeigen

@ -0,0 +1,88 @@
const axios = require('axios');
const API_URL = 'http://localhost:3005/api';
async function testEmployeeCreation() {
try {
console.log('1. Testing login...');
// First, login to get a token
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
username: 'admin',
password: 'ChangeMe123!@#'
});
const token = loginResponse.data.data.token.accessToken;
console.log('✅ Login successful');
// Create headers with auth token
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
console.log('\n2. Creating a test employee...');
// Create a test employee
const employeeData = {
firstName: 'Max',
lastName: 'Mustermann',
employeeNumber: 'EMP-' + Date.now(),
position: 'Software Developer',
department: 'IT',
email: 'max.mustermann@example.com',
phone: '+49 123 456789',
mobile: '+49 170 123456',
office: 'Building A, Room 123',
availability: 'available',
skills: [],
languages: [
{ code: 'de', level: 'native' },
{ code: 'en', level: 'fluent' }
],
specializations: ['Backend Development', 'Database Design'],
clearance: {
level: 'confidential',
validUntil: '2025-12-31',
issuedDate: '2024-01-01'
},
createUser: true,
userRole: 'user'
};
const createResponse = await axios.post(
`${API_URL}/employees`,
employeeData,
{ headers }
);
console.log('✅ Employee created successfully!');
console.log('Employee ID:', createResponse.data.data.id);
if (createResponse.data.data.temporaryPassword) {
console.log('Temporary Password:', createResponse.data.data.temporaryPassword);
}
// Fetch the created employee
console.log('\n3. Fetching created employee...');
const getResponse = await axios.get(
`${API_URL}/employees/${createResponse.data.data.id}`,
{ headers }
);
console.log('✅ Employee fetched successfully!');
console.log('Employee Data:');
console.log('- Name:', getResponse.data.data.firstName, getResponse.data.data.lastName);
console.log('- Email (decrypted):', getResponse.data.data.email);
console.log('- Phone (decrypted):', getResponse.data.data.phone);
console.log('- Department:', getResponse.data.data.department);
console.log('- Clearance:', getResponse.data.data.clearance);
console.log('\n✅ All tests passed! Employee creation and encryption working correctly.');
} catch (error) {
console.error('❌ Test failed:', error.response?.data || error.message);
if (error.response?.data?.error?.details) {
console.error('Details:', error.response.data.error.details);
}
}
}
testEmployeeCreation();

Datei anzeigen

@ -0,0 +1,68 @@
const express = require('express');
const Database = require('better-sqlite3');
const bcryptjs = require('bcryptjs');
const crypto = require('crypto');
const path = require('path');
const app = express();
app.use(express.json());
// Hash function for email
function hashEmail(email) {
if (!email) return null;
return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex');
}
// Test login endpoint
app.post('/test-login', async (req, res) => {
try {
const { username, password } = req.body;
console.log('=== TEST LOGIN ===');
console.log('Username:', username);
console.log('Password:', password);
const dbPath = path.join(__dirname, 'skillmate.dev.db');
const db = new Database(dbPath);
// Find user by username
const userRow = db.prepare(`
SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at, email_hash
FROM users
WHERE username = ? AND is_active = 1
`).get(username);
console.log('User found:', !!userRow);
if (userRow) {
console.log('User details:', {
id: userRow.id,
username: userRow.username,
email_hash: userRow.email_hash,
is_active: userRow.is_active,
password_hash: userRow.password ? userRow.password.substring(0, 20) + '...' : null
});
// Check password
const isValidPassword = await bcryptjs.compare(password, userRow.password);
console.log('Password valid:', isValidPassword);
if (isValidPassword) {
res.json({ success: true, message: 'Login successful!', user: { id: userRow.id, username: userRow.username } });
} else {
res.json({ success: false, message: 'Invalid password' });
}
} else {
res.json({ success: false, message: 'User not found' });
}
db.close();
} catch (error) {
console.error('Test login error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
app.listen(3005, () => {
console.log('Test server running on port 3005');
console.log('Test with: curl -X POST http://localhost:3005/test-login -H "Content-Type: application/json" -d "{\\"username\\":\\"admin\\",\\"password\\":\\"admin123\\"}"');
});

Datei anzeigen

@ -0,0 +1,72 @@
const axios = require('axios');
const API_URL = 'http://localhost:3005/api';
async function testMinimalEmployeeCreation() {
try {
console.log('1. Testing login...');
// First, login to get a token
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
username: 'admin',
password: 'ChangeMe123!@#'
});
const token = loginResponse.data.data.token.accessToken;
console.log('✅ Login successful');
// Create headers with auth token
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
console.log('\n2. Creating employee with minimal data (like Admin Panel)...');
// Create a test employee with minimal data
const minimalEmployeeData = {
firstName: 'Test',
lastName: 'Minimal',
email: 'test.minimal@example.com',
department: 'IT',
createUser: true,
userRole: 'user'
};
const createResponse = await axios.post(
`${API_URL}/employees`,
minimalEmployeeData,
{ headers }
);
console.log('✅ Employee created successfully with minimal data!');
console.log('Employee ID:', createResponse.data.data.id);
if (createResponse.data.data.temporaryPassword) {
console.log('Temporary Password:', createResponse.data.data.temporaryPassword);
}
// Fetch the created employee
console.log('\n3. Fetching created employee...');
const getResponse = await axios.get(
`${API_URL}/employees/${createResponse.data.data.id}`,
{ headers }
);
console.log('✅ Employee fetched successfully!');
console.log('Employee Data:');
console.log('- Name:', getResponse.data.data.firstName, getResponse.data.data.lastName);
console.log('- Email:', getResponse.data.data.email);
console.log('- Phone:', getResponse.data.data.phone, '(Default value)');
console.log('- Position:', getResponse.data.data.position, '(Default value)');
console.log('- Department:', getResponse.data.data.department);
console.log('- Employee Number:', getResponse.data.data.employeeNumber, '(Auto-generated)');
console.log('\n✅ Minimal employee creation working! User can complete profile on first login.');
} catch (error) {
console.error('❌ Test failed:', error.response?.data || error.message);
if (error.response?.data?.error?.details) {
console.error('Details:', error.response.data.error.details);
}
}
}
testMinimalEmployeeCreation();

Datei anzeigen

@ -0,0 +1,89 @@
const axios = require('axios');
const API_URL = 'http://localhost:3005/api';
async function testUserAdmin() {
try {
console.log('🧪 Testing Admin Panel User Management...\n');
console.log('1. Testing admin login...');
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
username: 'admin',
password: 'ChangeMe123!@#'
});
const token = loginResponse.data.data.token.accessToken;
console.log('✅ Admin login successful');
const headers = {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
console.log('\n2. Testing user list endpoint...');
const usersResponse = await axios.get(`${API_URL}/admin/users`, { headers });
console.log('✅ User list fetched successfully');
console.log(` Found ${usersResponse.data.data.length} users`);
console.log('\n3. Creating test employee with user account...');
const newEmployee = {
firstName: 'AdminTest',
lastName: 'User',
email: 'admin.test@example.com',
department: 'Testing',
createUser: true,
userRole: 'user'
};
const createResponse = await axios.post(`${API_URL}/employees`, newEmployee, { headers });
console.log('✅ Employee with user account created');
const newUserId = createResponse.data.data.userId;
const tempPassword = createResponse.data.data.temporaryPassword;
console.log(` User ID: ${newUserId}`);
console.log(` Temp Password: ${tempPassword}`);
if (newUserId) {
console.log('\n4. Testing role change...');
await axios.put(`${API_URL}/admin/users/${newUserId}/role`,
{ role: 'superuser' },
{ headers }
);
console.log('✅ User role changed to superuser');
console.log('\n5. Testing password reset...');
const resetResponse = await axios.post(`${API_URL}/admin/users/${newUserId}/reset-password`,
{ },
{ headers }
);
console.log('✅ Password reset successfully');
console.log(` New temp password: ${resetResponse.data.data.temporaryPassword}`);
console.log('\n6. Testing status toggle...');
await axios.put(`${API_URL}/admin/users/${newUserId}/status`,
{ isActive: false },
{ headers }
);
console.log('✅ User deactivated');
await axios.put(`${API_URL}/admin/users/${newUserId}/status`,
{ isActive: true },
{ headers }
);
console.log('✅ User reactivated');
console.log('\n7. Cleaning up - deleting test user...');
await axios.delete(`${API_URL}/admin/users/${newUserId}`, { headers });
console.log('✅ Test user deleted');
}
console.log('\n🎉 All Admin Panel User Management tests passed!');
} catch (error) {
console.error('\n❌ Test failed:', error.response?.data || error.message);
if (error.response?.status) {
console.error(` Status: ${error.response.status}`);
}
}
}
testUserAdmin();

69
.gitignore vendored Normale Datei
Datei anzeigen

@ -0,0 +1,69 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Production
dist/
build/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database
*.db
*.sqlite
*.sqlite3
skillmate.dev.db
data/
# Electron
out/
electron-builder.yml
# Testing
coverage/
.nyc_output
# Temporary files
*.tmp
*.temp
.cache/
# TypeScript
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity

177
ANWENDUNGSBESCHREIBUNG.txt Normale Datei
Datei anzeigen

@ -0,0 +1,177 @@
SkillMate – Detaillierte Anwendungsbeschreibung
================================================
1) Überblick
-------------
SkillMate ist eine interne Anwendung zur Erfassung, Suche und Verwaltung von Kompetenzen (Skills) von Mitarbeitenden. Sie besteht aus drei Hauptteilen:
- Frontend (Endanwender): React/TypeScript App (Vite) im Ordner `frontend`.
- Admin-Panel: Separates React/TypeScript Frontend im Ordner `admin-panel` für Administratoren/Superuser.
- Backend: Express/TypeScript API mit SQLite (verschlüsselte Speicherung) im Ordner `backend`.
- Gemeinsame Typen/Konstanten im Paket `shared`.
Ziel ist es, Kompetenzen hierarchisch zu pflegen (Kategorie → Unterkategorie → Skill), Mitarbeitende zu verwalten und anhand freier Suche oder Skills passende Personen zu finden.
2) Hauptfunktionen
-------------------
- Mitarbeitendenverwaltung (Lesen/Detailansicht im Frontend, Erstellen/Bearbeiten im Admin-Panel)
- Skill-Suche nach Freitext und/oder spezifischen Skills (Frontend)
- Mein Profil: Eigenes Profil einsehen und Kompetenz-Level pflegen (Frontend)
- Skill-Verwaltung: Kategorien, Unterkategorien und Skills pflegen (Admin-Panel)
- Benutzerverwaltung: Benutzer anlegen, Rollen verwalten, Status ändern (Admin-Panel)
- Uploads: Mitarbeiterfotos (vom Backend ausgeliefert)
3) Architektur und Code-Struktur
---------------------------------
Repository-Wurzel:
- `frontend/` – Endanwender-App
- `admin-panel/` – Admin-Frontend
- `backend/` – Express API
- `shared/` – Gemeinsame Typen/Konstanten (z. B. `User`, `Employee`)
- `main.py`, Skripte und Hilfsdateien
Frontend (Ordner `frontend`):
- `src/App.tsx`: Routing (Login, Dashboard, Mitarbeiterliste/-detail, Skill-Suche, Mein Profil, Einstellungen)
- `src/views/`: Seiten wie `Dashboard.tsx`, `EmployeeList.tsx`, `EmployeeDetail.tsx`, `EmployeeForm.tsx`, `SkillSearch.tsx`, `MyProfile.tsx`, `Settings.tsx`
- `src/components/`: UI-Komponenten (Layout, Sidebar, EmployeeCard, SkillLevelBar etc.)
- `src/services/api.ts`: Axios-Client, Endpunkte `auth`, `employees`, `skills`
- Zustandsspeicher: `stores` (z. B. `authStore`, `themeStore`)
Admin-Panel (Ordner `admin-panel`):
- `src/App.tsx`: Routen (Dashboard, Benutzerverwaltung, Skill-Verwaltung, Sync-/E-Mail-Einstellungen)
- `src/views/`: `UserManagement.tsx`, `SkillManagement.tsx`, `CreateEmployee.tsx` u. a.
- `src/services/api.ts`: Axios-Client mit Token-Interceptor
Backend (Ordner `backend`):
- `src/index.ts`: Express-App, Routing-Registrierung
- `src/config/secureDatabase.ts`: SQLite-Initialisierung (verschlüsselte Felder, Hash-Spalten für Suche), Tabellen-Definitionen
- `src/routes/`:
- `auth.ts` – Login über Benutzername/E-Mail (JWT)
- `employeesSecure.ts` – Mitarbeitenden-Routen (verschlüsselte Felder, Join-Tabellen)
- `skills.ts` – Skills und Hierarchien (Kontrolliertes Vokabular)
- `users.ts`, `usersAdmin.ts` – Benutzer-Endpoints (Admin)
- weitere (upload, settings, workspaces …)
- `src/middleware/`: Authentifizierung, Rollen-/Berechtigungsprüfungen
- `src/services/`: Verschlüsselung, E-Mail, Sync
Shared (Ordner `shared`):
- `index.d.ts` TypeScript-Typen (User, Employee, Skill, usw.)
- `index.js`, `skills.js` Konstanten (z. B. `ROLE_PERMISSIONS`, `SKILL_HIERARCHY`)
4) Datenmodell (vereinfacht)
-----------------------------
- `users`: Benutzer (username, email verschlüsselt + Hash, password (bcrypt), role, employee_id, is_active, …)
- `employees`: Mitarbeitende (first_name, last_name, employee_number, photo, position, department, email/phone verschlüsselt+Hash, availability, …)
- `skills`: Skills (id, name, category = `catId.subId`, description, requires_certification, expires_after)
- `employee_skills`: Zuordnung Mitarbeitende↔Skills mit Level/Verifikation
- `language_skills`: Sprachkenntnisse (Kompatibilitäts- und Suchzwecke)
- `specializations`: Freitext-Spezialisierungen je Mitarbeitendem
- `controlled_vocabulary`: Kontrolliertes Vokabular (z. B. `skill_category` und `skill_subcategory`)
- Diverse weitere Tabellen für optionale Module (Workspaces, Bookings etc.)
5) Sicherheit & Authentifizierung
----------------------------------
- Login via `/api/auth/login` (JWT), Token wird im Frontend gespeichert und als `Authorization: Bearer <token>` gesendet.
- Berechtigungen über Rollen (`admin`, `superuser`, `user`) und `ROLE_PERMISSIONS` in `shared/index.js`.
- Sensible Felder (z. B. E-Mail, Telefonnummern) in `employees` verschlüsselt gespeichert; Hash-Spalten dienen der Suche.
- Admin-Panel-Endpunkte prüfen zusätzliche Berechtigungen (`users:read/update/create`, `skills:update`, `admin:panel:access`).
6) Frontend-Routen (Auszug)
----------------------------
- `/login`: Anmeldung
- `/`: Dashboard
- `/employees`: Mitarbeiterliste (Filter, Suche)
- `/employees/:id`: Mitarbeiter-Detail (Kontakt, allgemeine Infos, Kompetenzen)
- `/employees/new`: Neuanlage (sofern Rolle berechtigt)
- `/search`: Skill-Suche (Freitext + Skillfilter mit Hierarchieauswahl)
- `/profile`: Mein Profil (eigene Daten, Kompetenzen pflegen)
- `/settings`: Einstellungen
Admin-Panel-Routen (Auszug):
- `/`: Dashboard (Statistiken)
- `/users`: Benutzerverwaltung (Rollen, Aktivierung, Passwort zurücksetzen)
- `/skills`: Skill-Verwaltung (Kategorien, Unterkategorien, Skills pflegen)
- weitere: Sync-/E-Mail-/Systemeinstellungen
7) API-Endpunkte (Auszug)
-------------------------
Authentifizierung
- `POST /api/auth/login` – Login via Benutzername oder E-Mail + Passwort → JWT
Mitarbeitende
- `GET /api/employees` – Liste (für Admin-Panel, erfordert Berechtigung)
- `GET /api/employees/public` – Gefilterte Liste für Frontend (nur verknüpfte, aktive Nutzer)
- `GET /api/employees/:id` – Detailansicht
- `POST /api/employees` – Anlegen (berechtigungsbasiert)
- `PUT /api/employees/:id` – Aktualisieren (eigene oder Admin/Superuser)
- `DELETE /api/employees/:id` – Löschen (Admin)
Skills
- `GET /api/skills` – Liste aller Skills
- `GET /api/skills/hierarchy` – Hierarchische Struktur (Kategorien → Unterkategorien → Skills)
- `POST /api/skills` – Skill anlegen (Admin/Superuser), benötigt vorhandene Unterkategorie
- `PUT /api/skills/:id` – Skill ändern (Admin)
- `DELETE /api/skills/:id` – Skill löschen (Admin)
- Kategorien/Unterkategorien: `POST/PUT/DELETE /api/skills/categories…` (Admin)
Benutzer (Admin)
- `GET /api/admin/users` – Liste aller Benutzer
- `PUT /api/admin/users/:id/role` – Rolle setzen
- `PUT /api/admin/users/:id/status` – Aktiv/Inaktiv
- `POST /api/admin/users/:id/reset-password` – Passwort zurücksetzen (Temp-Passwort)
- `POST /api/admin/users/bulk-create-from-employees` – Benutzer aus Mitarbeitenden anlegen
8) Datenfluss (Beispiele)
--------------------------
- Login: Frontend → `POST /api/auth/login` → Token speichern → künftige Requests mit `Authorization`-Header.
- Mitarbeiterliste (Frontend): `GET /api/employees/public` → Kartenansicht mit `EmployeeCard`.
- Skill-Suche: Frontend lädt per `GET /api/skills/hierarchy` Kategorien/Unterkategorien/Skills, filtert lokal; Trefferanzeige über `employeeApi.getAll()` / Filterung nach ausgewählten Skill-IDs.
- Mein Profil: `GET /api/employees/:id` → Anzeige + lokale Bearbeitung → `PUT /api/employees/:id` zum Speichern.
- Admin: Skill-Verwaltung liest `GET /api/skills/hierarchy`; Kategorien/Unterkategorien/Skills werden via `POST/PUT/DELETE` gepflegt.
9) Konfiguration & Umgebungsvariablen
--------------------------------------
Frontend/Admin-Panel (`.env`):
- `VITE_API_URL` – Basis-URL der API (z. B. `http://localhost:3004/api`)
Backend (`.env`):
- `PORT` – Port der API (Standard 3004)
- `JWT_SECRET` – Geheimnis für Tokens
- `DATABASE_ENCRYPTION_KEY` – Schlüssel für DB-Dateiverschlüsselung
- `FIELD_ENCRYPTION_KEY` – Schlüssel für Feldverschlüsselung (E-Mail/Telefon …)
- optional: `DATABASE_PATH` (Ablageort der verschlüsselten DB-Datei)
10) Sicherheitshinweise
------------------------
- In Produktion müssen `JWT_SECRET`, `DATABASE_ENCRYPTION_KEY` und `FIELD_ENCRYPTION_KEY` gesetzt sein.
- Passwörter werden mittels bcrypt gespeichert, sensible Felder per AES verschlüsselt.
- CORS, Helmet und Token-Validierung sind aktiv; Admin-Panel-Endpunkte prüfen Rollen/Permissions.
11) Typische Probleme & Hinweise
---------------------------------
- Bei „keine Daten“ in Frontends: Prüfen, ob Token gültig und Backend erreichbar (Netzwerk/Port, 401/403/5xx in Browser-Konsole).
- Skill-Hierarchie leer: Kontrolliertes Vokabular wurde ggf. noch nicht gepflegt; Skills erscheinen erst bei gültiger Kategorie/Unterkategorie.
- E-Mail im Profil ist im Frontend schreibgeschützt (kommt aus dem Login/Benutzerkonto). Änderungen über Admin-Panel/Benutzerverwaltung.
12) Weiterentwicklung
----------------------
- Team-Zusammenstellung: Platzhalter-Ansicht vorhanden; Logik kann um Matching nach Rollen/Skills erweitert werden.
- Erweiterte Suche: Gewichtung nach Skill-Level, Verfügbarkeit, Sprache o. Ä.
- Audit-/Sync-Funktionen ausbauen; E-Mail-Benachrichtigungen bei Änderungen.
Ansprechperson & Lizenz
-----------------------
Interner Einsatz. Für Fragen zur Architektur und Erweiterung bitte an das Entwicklungsteam wenden.

277
CLAUDE_PROJECT_README.md Normale Datei
Datei anzeigen

@ -0,0 +1,277 @@
# SkillMate
*This README was automatically generated by Claude Project Manager*
## Project Overview
- **Path**: `A:/GiTea/SkillMate`
- **Files**: 222 files
- **Size**: 10.5 MB
- **Last Modified**: 2025-09-18 22:20
## Technology Stack
### Languages
- JavaScript
- Python
- React TypeScript
- Shell
- TypeScript
### Frameworks & Libraries
- React
## Project Structure
```
ANWENDUNGSBESCHREIBUNG.txt
CLAUDE_PROJECT_README.md
debug-console.cmd
EXE-ERSTELLEN.md
install-dependencies.cmd
INSTALLATION.md
LICENSE.txt
main.py
README.md
admin-panel/
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── postcss.config.js
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── vite.config.ts
│ ├── dist/
│ │ ├── index.html
│ │ └── assets/
│ │ ├── index-BRjBeEgH.css
│ │ └── index-gij1mIll.js
│ ├── public
│ └── src/
│ ├── App.tsx
│ ├── index.css
│ ├── main.tsx
│ ├── components/
│ │ ├── HomeIcon.tsx
│ │ ├── icons.tsx
│ │ ├── index.ts
│ │ ├── Layout.tsx
│ │ ├── MoonIcon.tsx
│ │ ├── SearchIcon.tsx
│ │ ├── SettingsIcon.tsx
│ │ ├── SunIcon.tsx
│ │ ├── SyncStatus.tsx
│ │ └── UsersIcon.tsx
│ ├── data/
│ │ ├── skillCategories.ts
│ │ └── skills.ts
│ ├── services/
│ │ ├── api.ts
│ │ └── networkApi.ts
│ ├── stores/
│ │ ├── authStore.ts
│ │ └── themeStore.ts
│ ├── styles/
│ │ └── index.css
│ └── views/
│ ├── CreateEmployee.tsx
│ ├── Dashboard.tsx
│ ├── EmailSettings.tsx
│ ├── EmployeeForm.tsx
│ ├── EmployeeFormComplete.tsx
│ ├── EmployeeManagement.tsx
│ ├── Login.tsx
│ ├── SkillManagement.tsx
│ ├── SyncSettings.tsx
│ └── UserManagement.tsx
backend/
│ ├── create-test-user.js
│ ├── full-backend-3005.js
│ ├── package-lock.json
│ ├── package.json
│ ├── skillmate.dev.db
│ ├── skillmate.dev.encrypted.db
│ ├── skillmate.dev.encrypted.db-shm
│ ├── dist/
│ │ ├── index.js
│ │ ├── index.js.map
│ │ ├── config/
│ │ │ ├── database.js
│ │ │ ├── database.js.map
│ │ │ ├── secureDatabase.js
│ │ │ └── secureDatabase.js.map
│ │ ├── middleware/
│ │ │ ├── auth.js
│ │ │ ├── auth.js.map
│ │ │ ├── errorHandler.js
│ │ │ ├── errorHandler.js.map
│ │ │ ├── roleAuth.js
│ │ │ └── roleAuth.js.map
│ │ ├── routes/
│ │ │ ├── analytics.js
│ │ │ ├── analytics.js.map
│ │ │ ├── auth.js
│ │ │ ├── auth.js.map
│ │ │ ├── bookings.js
│ │ │ ├── bookings.js.map
│ │ │ ├── employees.js
│ │ │ ├── employees.js.map
│ │ │ ├── employeesSecure.js
│ │ │ └── employeesSecure.js.map
│ │ ├── services/
│ │ │ ├── emailService.js
│ │ │ ├── emailService.js.map
│ │ │ ├── encryption.js
│ │ │ ├── encryption.js.map
│ │ │ ├── reminderService.js
│ │ │ ├── reminderService.js.map
│ │ │ ├── syncScheduler.js
│ │ │ ├── syncScheduler.js.map
│ │ │ ├── syncService.js
│ │ │ └── syncService.js.map
│ │ └── utils/
│ │ ├── logger.js
│ │ └── logger.js.map
│ ├── logs/
│ │ ├── combined.log
│ │ └── error.log
│ ├── scripts/
│ │ ├── migrate-users.js
│ │ ├── purge-users.js
│ │ ├── reset-admin.js
│ │ └── seed-skills-from-frontend.js
│ ├── src/
│ │ ├── index.ts
│ │ ├── config/
│ │ │ ├── database.ts
│ │ │ └── secureDatabase.ts
│ │ ├── middleware/
│ │ │ ├── auth.ts
│ │ │ ├── errorHandler.ts
│ │ │ └── roleAuth.ts
│ │ ├── routes/
│ │ │ ├── analytics.ts
│ │ │ ├── auth.ts
│ │ │ ├── bookings.ts
│ │ │ ├── employees.ts
│ │ │ ├── employeesSecure.ts
│ │ │ ├── network.ts
│ │ │ ├── profiles.ts
│ │ │ ├── settings.ts
│ │ │ ├── skills.ts
│ │ │ └── sync.ts
│ │ ├── services/
│ │ │ ├── emailService.ts
│ │ │ ├── encryption.ts
│ │ │ ├── reminderService.ts
│ │ │ ├── syncScheduler.ts
│ │ │ └── syncService.ts
│ │ └── utils/
│ │ └── logger.ts
│ └── uploads/
│ └── photos/
│ ├── 0def5f6f-c1ef-4f88-9105-600c75278f10.jpg
│ ├── 72c09fa1-f0a8-444c-918f-95258ca56f61.gif
│ └── 80c44681-d6b4-474e-8ff1-c6d02da0cd7d.gif
frontend/
│ ├── electron-builder.json
│ ├── index-electron.html
│ ├── index.html
│ ├── package-lock.json
│ ├── package.json
│ ├── postcss.config.js
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── dist/
│ │ ├── debug.html
│ │ ├── icon.svg
│ │ ├── index.html
│ │ └── assets/
│ │ ├── index-BUJNM8Sh.css
│ │ └── index-n0FiY1wQ.js
│ ├── electron/
│ │ ├── main.js
│ │ ├── preload.js
│ │ └── renderer-preload.js
│ ├── installer/
│ │ └── skillmate-setup.iss
│ ├── public/
│ │ ├── debug.html
│ │ └── icon.svg
│ └── src/
│ ├── App.tsx
│ ├── main.tsx
│ ├── components/
│ │ ├── EmployeeCard.tsx
│ │ ├── Header.tsx
│ │ ├── Layout.tsx
│ │ ├── PhotoPreview.tsx
│ │ ├── PhotoUpload.tsx
│ │ ├── Sidebar.tsx
│ │ ├── SkillLevelBar.tsx
│ │ └── WindowControls.tsx
│ ├── data/
│ │ └── skillCategories.ts
│ ├── hooks/
│ │ └── usePermissions.ts
│ ├── services/
│ │ └── api.ts
│ ├── stores/
│ │ ├── authStore.ts
│ │ └── themeStore.ts
│ ├── styles/
│ │ └── index.css
│ ├── temp/
│ │ └── skills.ts
│ ├── types/
│ │ └── electron.d.ts
│ └── views/
│ ├── Dashboard.tsx
│ ├── DeskBooking.tsx
│ ├── EmployeeDetail.tsx
│ ├── EmployeeForm.tsx
│ ├── EmployeeList.tsx
│ ├── FloorPlan.tsx
│ ├── Login.tsx
│ ├── MyProfile.tsx
│ ├── ProfileEdit.tsx
│ └── ProfileSearch.tsx
shared/
├── index.d.ts
├── index.js
├── package.json
└── skills.js
```
## Key Files
- `README.md`
- `requirements.txt`
- `package.json`
- `package.json`
- `package.json`
- `package.json`
## Claude Integration
This project is managed with Claude Project Manager. To work with this project:
1. Open Claude Project Manager
2. Click on this project's tile
3. Claude will open in the project directory
## Notes
*Add your project-specific notes here*
---
## Development Log
- README generated on 2025-07-15 11:57:16
- README updated on 2025-07-15 11:57:23
- README updated on 2025-08-01 23:08:41
- README updated on 2025-08-01 23:08:52
- README updated on 2025-09-20 21:30:35

139
EXE-ERSTELLEN.md Normale Datei
Datei anzeigen

@ -0,0 +1,139 @@
# SkillMate EXE-Installer erstellen
## 🎯 Übersicht
Es gibt mehrere Möglichkeiten, aus den SkillMate-Installationsskripten ausführbare Windows-EXE-Dateien zu erstellen:
## 1. 🟢 Sofort verwendbar: Batch-Datei
Die einfachste Lösung - bereits erstellt:
```
SkillMate-Setup.bat
```
Diese Datei kann direkt ausgeführt werden und startet den GUI-Installer.
## 2. 🔧 Mit Windows-Bordmitteln: IExpress
**Vorteile:** Bereits in Windows enthalten, keine zusätzliche Software nötig
1. Führen Sie das Hilfsskript aus:
```powershell
.\create-exe-installer.ps1
```
2. Wählen Sie Option 1 (IExpress)
3. Die EXE wird automatisch erstellt
## 3. 💎 Professionell: Inno Setup (Empfohlen)
**Vorteile:** Professioneller Installer mit GUI, Multi-Language, Uninstaller
1. **Installieren Sie Inno Setup:**
- Download: https://jrsoftware.org/isdl.php
- Installieren Sie die neueste Version
2. **Kompilieren Sie das Setup:**
- Öffnen Sie `SkillMate-InnoSetup.iss` in Inno Setup
- Klicken Sie auf "Compile" (Strg+F9)
- Die EXE wird im `output` Ordner erstellt
3. **Ergebnis:** `output\SkillMate-Setup.exe`
## 4. 🛠️ Alternative: NSIS
**Vorteile:** Sehr kleiner Installer, flexibel, Open Source
1. **Installieren Sie NSIS:**
- Download: https://nsis.sourceforge.io/Download
- Installieren Sie Version 3.0 oder höher
2. **Kompilieren Sie das Setup:**
```cmd
makensis SkillMate-NSIS-Installer.nsi
```
3. **Ergebnis:** `SkillMate-Setup.exe`
## 5. 🔵 PowerShell zu EXE: PS2EXE
**Vorteile:** Direkte Konvertierung von PowerShell-Scripts
1. **Installieren Sie PS2EXE:**
```powershell
Install-Module -Name ps2exe -Scope CurrentUser
```
2. **Führen Sie das Hilfsskript aus:**
```powershell
.\create-exe-installer.ps1
```
3. Wählen Sie Option 2 (PS2EXE)
## 6. 🟡 Bat to Exe Converter
**Vorteile:** Einfache GUI, viele Optionen
1. **Download eines Converters:**
- Bat To Exe Converter: http://www.f2ko.de/en/b2e.php
- Advanced BAT to EXE: https://www.battoexeconverter.com/
2. **Konvertieren:**
- Öffnen Sie `SkillMate-Setup-Advanced.bat`
- Wählen Sie Ihre Einstellungen (Icon, Admin-Rechte, etc.)
- Erstellen Sie die EXE
## 📋 Vergleich der Methoden
| Methode | Größe | Features | Schwierigkeit | Empfehlung |
|---------|-------|----------|---------------|------------|
| Batch | ~2 KB | Basis | ⭐ | Quick & Dirty |
| IExpress | ~500 KB | Einfach | ⭐⭐ | Windows Built-in |
| Inno Setup | ~5 MB | Professionell | ⭐⭐⭐ | **Beste Option** |
| NSIS | ~3 MB | Flexibel | ⭐⭐⭐⭐ | Für Experten |
| PS2EXE | ~1 MB | PowerShell | ⭐⭐ | Für Scripts |
| Bat2Exe | ~2 MB | Vielseitig | ⭐⭐ | Gute Alternative |
## 🎨 Icon hinzufügen
Für ein professionelles Aussehen:
1. Erstellen Sie eine `icon.ico` Datei (256x256px empfohlen)
2. Platzieren Sie sie im SkillMate-Hauptverzeichnis
3. Die Installer-Scripts verwenden sie automatisch
## 🔒 Code-Signierung (Optional)
Für Produktionsumgebungen empfohlen:
```powershell
# Mit einem Code-Signing Zertifikat
signtool sign /t http://timestamp.digicert.com /a "SkillMate-Setup.exe"
```
## 📦 Fertige EXE verteilen
Nach der Erstellung:
1. **Testen Sie die EXE** auf einem sauberen System
2. **Prüfen Sie Antivirus-Kompatibilität**
3. **Dokumentieren Sie System-Anforderungen:**
- Windows 10/11 (64-bit)
- Node.js 18+ (wird geprüft)
- 500 MB freier Speicher
- Administrator-Rechte
## ⚡ Quick Start
Für die schnellste Lösung:
```powershell
# PowerShell als Administrator
.\create-exe-installer.ps1
# Wählen Sie Option 4 (Alle erstellen)
```
Dies erstellt alle möglichen Varianten, aus denen Sie wählen können!

267
INSTALLATION.md Normale Datei
Datei anzeigen

@ -0,0 +1,267 @@
# SkillMate Installation und Einrichtung
## 📋 Systemvoraussetzungen
### Minimum:
- **Node.js**: Version 18.0.0 oder höher
- **NPM**: Version 8.0.0 oder höher (wird mit Node.js installiert)
- **Speicherplatz**: Mindestens 500 MB freier Speicher
- **RAM**: Mindestens 4 GB RAM
- **Betriebssystem**: Windows 10/11, macOS 10.15+, Linux (Ubuntu 20.04+)
### Empfohlen:
- **Node.js**: Version 20.x LTS
- **RAM**: 8 GB oder mehr
- **CPU**: Dual-Core oder besser
## 🚀 Schnellstart
### Windows
1. **Laden Sie Node.js herunter und installieren Sie es:**
- Besuchen Sie https://nodejs.org/
- Laden Sie die LTS-Version herunter
- Führen Sie den Installer aus
2. **Führen Sie den SkillMate Installer aus:**
```powershell
# PowerShell als Administrator öffnen (nicht erforderlich, aber empfohlen)
cd C:\Pfad\zu\SkillMate
# Installer ausführen
.\install.ps1
```
3. **Starten Sie SkillMate:**
- Doppelklicken Sie auf die Desktop-Verknüpfung "SkillMate"
- Oder führen Sie aus: `.\start-skillmate.bat`
### Linux/macOS
1. **Installieren Sie Node.js:**
```bash
# Ubuntu/Debian
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
# macOS mit Homebrew
brew install node
```
2. **Führen Sie den Installer aus:**
```bash
cd /pfad/zu/SkillMate
chmod +x install.sh
./install.sh
```
3. **Starten Sie SkillMate:**
```bash
./start-skillmate.sh
```
## 📁 Projektstruktur nach Installation
```
SkillMate/
├── backend/
│ ├── .env # Backend-Konfiguration
│ ├── data/
│ │ └── skillmate.db # SQLite Datenbank
│ ├── uploads/ # Hochgeladene Dateien
│ └── logs/ # Log-Dateien
├── frontend/
│ └── .env # Frontend-Konfiguration
├── admin-panel/
│ └── .env # Admin-Panel-Konfiguration
├── shared/ # Gemeinsame TypeScript-Definitionen
├── start-skillmate.bat # Windows Start-Script
├── start-skillmate.sh # Linux/Mac Start-Script
└── start-skillmate.ps1 # PowerShell Start-Script
```
## ⚙️ Konfiguration
### Backend (.env)
Die wichtigsten Einstellungen in `backend/.env`:
```env
# Server-Port (Standard: 3001)
PORT=3001
# Umgebung (development/production)
NODE_ENV=production
# Datenbank-Pfad
DATABASE_PATH=./data/skillmate.db
# JWT Secret (automatisch generiert, NICHT TEILEN!)
JWT_SECRET=ihr-geheimer-schlüssel
# JWT Ablaufzeit
JWT_EXPIRES_IN=7d
# CORS URLs (Frontend und Admin-Panel)
CORS_ORIGIN=http://localhost:5173,http://localhost:5174
# Upload-Einstellungen
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=5242880 # 5MB in Bytes
# Sync-Einstellungen für Netzwerk
NODE_ID=eindeutige-node-id
NODE_TYPE=local # oder "admin" für Admin-Server
# Logging
LOG_LEVEL=info # debug, info, warn, error
LOG_DIR=./logs
```
### Frontend/Admin-Panel (.env)
```env
# Backend API URL
VITE_API_URL=http://localhost:3001/api
# App-Name und Version
VITE_APP_NAME=SkillMate
VITE_APP_VERSION=1.0.0
```
## 🔐 Erster Login
**Standard-Zugangsdaten:**
- Benutzername: `admin`
- Passwort: `admin123`
⚠️ **WICHTIG**: Ändern Sie das Passwort sofort nach dem ersten Login!
## 🌐 Netzwerk-Konfiguration
### Admin-Server einrichten
1. Setzen Sie in `backend/.env`:
```env
NODE_TYPE=admin
```
2. Öffnen Sie das Admin-Panel
3. Navigieren Sie zu "Netzwerk & Synchronisation"
4. Fügen Sie lokale Knoten hinzu
### Lokalen Knoten einrichten
1. Setzen Sie in `backend/.env`:
```env
NODE_TYPE=local
```
2. Verwenden Sie den API-Key vom Admin-Server
### Firewall-Einstellungen
Öffnen Sie folgende Ports falls nötig:
- **3001**: Backend API
- **5173**: Frontend (nur für Entwicklung)
- **5174**: Admin-Panel (nur für Entwicklung)
## 🛠️ Fehlerbehebung
### "Node.js ist nicht installiert"
- Installieren Sie Node.js von https://nodejs.org/
- Starten Sie Ihr Terminal/PowerShell neu
### "Port bereits belegt"
- Ein anderer Dienst nutzt bereits den Port
- Beenden Sie den Dienst oder ändern Sie den Port in der .env
### "Datenbank-Fehler"
- Löschen Sie `backend/data/skillmate.db`
- Führen Sie den Installer erneut aus
### "Build-Fehler bei SQLite"
**Linux/macOS:**
```bash
sudo apt-get install build-essential python3
# oder
brew install python
```
**Windows:**
```powershell
npm install --global windows-build-tools
```
## 📊 Datenbank-Verwaltung
### Backup erstellen
```bash
# Linux/macOS
cp backend/data/skillmate.db backend/data/skillmate_backup_$(date +%Y%m%d).db
# Windows
copy backend\data\skillmate.db backend\data\skillmate_backup_%date:~-4,4%%date:~-10,2%%date:~-7,2%.db
```
### Datenbank zurücksetzen
1. Stoppen Sie alle SkillMate-Dienste
2. Löschen Sie `backend/data/skillmate.db`
3. Starten Sie SkillMate neu (Datenbank wird automatisch erstellt)
## 🔄 Updates
### Automatisches Update (wenn Git installiert)
```bash
git pull origin main
npm install # im Hauptverzeichnis
cd backend && npm install && npm run build
cd ../frontend && npm install
cd ../admin-panel && npm install
```
### Manuelles Update
1. Laden Sie die neueste Version herunter
2. Sichern Sie Ihre .env Dateien und die Datenbank
3. Überschreiben Sie die Dateien
4. Führen Sie den Installer erneut aus
## 🆘 Support
Bei Problemen:
1. Überprüfen Sie die Logs in `backend/logs/`
2. Stellen Sie sicher, dass alle Abhängigkeiten installiert sind
3. Führen Sie den Installer erneut aus
## 🔒 Sicherheitshinweise
1. **Ändern Sie das Standard-Passwort** sofort nach der Installation
2. **Sichern Sie die .env Dateien** - sie enthalten sensible Daten
3. **Verwenden Sie HTTPS** in Produktionsumgebungen
4. **Beschränken Sie den Zugriff** auf die Admin-Panel URL
5. **Regelmäßige Backups** der Datenbank durchführen
## 📈 Performance-Optimierung
### Für Produktionsumgebungen:
1. **Frontend/Admin-Panel bauen:**
```bash
cd frontend && npm run build
cd ../admin-panel && npm run build
```
2. **Nginx als Reverse Proxy verwenden**
3. **PM2 für Prozess-Management:**
```bash
npm install -g pm2
pm2 start backend/dist/index.js --name skillmate-backend
pm2 save
pm2 startup
```
## 📝 Lizenz
SkillMate ist für den internen Gebrauch in Sicherheitsbehörden entwickelt.
Weitergabe und kommerzielle Nutzung nur mit ausdrücklicher Genehmigung.

29
LICENSE.txt Normale Datei
Datei anzeigen

@ -0,0 +1,29 @@
SKILLMATE LIZENZVEREINBARUNG
Copyright (c) 2024 SkillMate Development
WICHTIGER HINWEIS: Diese Software ist ausschließlich für den internen Gebrauch in
deutschen Sicherheitsbehörden lizenziert.
1. LIZENZGEWÄHRUNG
Die Software darf ausschließlich von autorisierten Mitarbeitern deutscher
Sicherheitsbehörden genutzt werden.
2. EINSCHRÄNKUNGEN
- Die Software darf nicht verkauft, vermietet oder anderweitig kommerziell
genutzt werden.
- Die Software darf nicht an Dritte weitergegeben werden.
- Der Quellcode darf nicht ohne ausdrückliche Genehmigung modifiziert werden.
3. DATENSCHUTZ
Die Software verarbeitet personenbezogene Daten gemäß DSGVO und den geltenden
Datenschutzbestimmungen für Sicherheitsbehörden.
4. HAFTUNGSAUSSCHLUSS
Die Software wird "wie besehen" zur Verfügung gestellt, ohne jegliche
ausdrückliche oder stillschweigende Gewährleistung.
5. BEENDIGUNG
Diese Lizenz endet automatisch bei Verstoß gegen die Lizenzbedingungen.
Durch die Installation oder Nutzung der Software akzeptieren Sie diese Bedingungen.

136
README.md Normale Datei
Datei anzeigen

@ -0,0 +1,136 @@
# SkillMate - Mitarbeiter-Skills-Management für Sicherheitsbehörden
SkillMate ist eine spezialisierte Anwendung zur Verwaltung von Mitarbeiterfähigkeiten und -kompetenzen in Sicherheitsbehörden. Die Anwendung läuft lokal bei jedem Nutzer und kann über ein Admin-Panel zentral verwaltet werden.
## Features
- 🔍 **Skill-basierte Suche**: Finden Sie Mitarbeiter anhand ihrer Fähigkeiten
- 👥 **Mitarbeiterverwaltung**: Umfassende Profile mit Skills, Sprachen und Spezialisierungen
- 🔐 **Sicherheitsüberprüfungen**: Verwaltung von Ü2/Ü3-Clearances
- 🌓 **Dark/Light Mode**: Anpassbare Benutzeroberfläche
- 🔄 **Synchronisation**: Zentrale Datenverwaltung über Admin-Panel
- 💻 **Offline-fähig**: Lokale SQLite-Datenbank für Offline-Betrieb
## Systemanforderungen
- Node.js 18.x oder höher
- npm 8.x oder höher
- Windows 10/11, macOS 10.15+, oder Linux
## Installation
### Windows
1. Führen Sie die Datei `setup.bat` aus
2. Folgen Sie den Anweisungen auf dem Bildschirm
### macOS/Linux
1. Öffnen Sie ein Terminal im Projektverzeichnis
2. Führen Sie aus: `./setup.sh`
3. Folgen Sie den Anweisungen
## Entwicklung
### Alle Services starten
**Windows:** `start-dev.bat`
**macOS/Linux:** `./start-dev.sh`
Dies startet:
- Backend auf http://localhost:3001
- Frontend auf http://localhost:5173
- Admin Panel auf http://localhost:3002
### Einzelne Services
```bash
# Backend
cd backend && npm run dev
# Frontend (Electron)
cd frontend && npm run dev
# Admin Panel
cd admin-panel && npm run dev
```
## Production Build
### Kompletten Build erstellen
**Windows:** `build-production.bat`
**macOS/Linux:** `./build-production.sh`
### Ausgabe
- **Electron App**: `frontend/dist/` (Installer für Windows/macOS/Linux)
- **Backend**: `backend/dist/` (Node.js Server)
- **Admin Panel**: `admin-panel/dist/` (Static Web Files)
## Projektstruktur
```
SkillMate/
├── frontend/ # Electron Desktop App
├── backend/ # Express.js API Server
├── admin-panel/ # React Admin Interface
├── shared/ # Gemeinsame TypeScript-Typen
├── setup.bat # Windows Setup-Script
├── setup.sh # Unix Setup-Script
├── start-dev.bat # Windows Dev-Start
├── start-dev.sh # Unix Dev-Start
└── README.md # Diese Datei
```
## Standard-Login
**Admin Panel:**
- Benutzername: `admin`
- Passwort: `admin123`
⚠️ **WICHTIG**: Ändern Sie das Standard-Passwort nach der ersten Anmeldung!
## Konfiguration
### Backend (.env)
```env
JWT_SECRET=your-secret-key
PORT=3001
NODE_ENV=production
LOG_LEVEL=info
```
### Admin Panel (.env)
```env
VITE_API_URL=http://your-server:3001/api
```
## Deployment
### Lokale Desktop-App
1. Erstellen Sie den Production Build
2. Verteilen Sie den Installer aus `frontend/dist/`
3. Die App enthält das Backend und läuft komplett lokal
### Admin Panel im Netzwerk
1. Kopieren Sie `admin-panel/dist/` auf einen Webserver
2. Konfigurieren Sie die Backend-URL in der `.env`
3. Stellen Sie sicher, dass das Backend erreichbar ist
## Sicherheit
- JWT-basierte Authentifizierung
- Rollenbasierte Zugriffskontrolle (Admin, Poweruser, User)
- Lokale Datenhaltung mit SQLite
- Verschlüsselte Verbindungen (HTTPS in Production)
## Support
Bei Fragen oder Problemen erstellen Sie bitte ein Issue im Projekt-Repository.
## Lizenz
Proprietär - Nur für den internen Gebrauch in Sicherheitsbehörden.

Datei anzeigen

@ -0,0 +1,689 @@
================================================================================
SKILLMATE - VOLLSTÄNDIGE ANWENDUNGSDOKUMENTATION
================================================================================
PROJEKTÜBERSICHT
================================================================================
SkillMate ist ein Mitarbeiter-Skills-Management-System für Sicherheitsbehörden.
Die Anwendung besteht aus drei Hauptmodulen:
- Frontend (React-basierte Benutzeroberfläche)
- Admin-Panel (React-basierte Administrationsoberfläche)
- Backend (Node.js/Express API-Server mit verschlüsselter SQLite-Datenbank)
================================================================================
1. PROJEKTSTRUKTUR
================================================================================
SkillMate/
├── backend/ # Node.js/Express API-Server
│ ├── src/
│ │ ├── config/ # Datenbank- und Sicherheitskonfiguration
│ │ ├── middleware/ # Auth, Error-Handler, Role-Auth
│ │ ├── routes/ # 14 API-Route-Module
│ │ ├── services/ # Email, Encryption, Sync-Services
│ │ └── utils/ # Logger und Hilfsfunktionen
│ ├── package.json
│ └── tsconfig.json
├── frontend/ # React Benutzeroberfläche
│ ├── src/
│ │ ├── components/ # Wiederverwendbare UI-Komponenten
│ │ ├── views/ # Hauptansichten/Seiten
│ │ ├── stores/ # Zustand-Management (Zustand)
│ │ ├── services/ # API-Client
│ │ └── data/ # Statische Daten
│ ├── package.json
│ ├── tailwind.config.js
│ └── tsconfig.json
├── admin-panel/ # React Admin-Oberfläche
│ ├── src/
│ │ ├── components/
│ │ ├── views/
│ │ ├── services/
│ │ └── stores/
│ ├── package.json
│ └── tailwind.config.js
├── shared/ # Gemeinsame TypeScript-Typen (referenziert)
└── main.py # Python-Anwendungsstarter
================================================================================
2. TECHNOLOGIE-STACK
================================================================================
FRONTEND & ADMIN-PANEL:
- React 18.2.0 mit TypeScript 5.3.0
- Vite als Build-Tool
- React Router 6.20.0 für Routing
- Zustand 4.4.7 für State Management
- Axios 1.6.2 für HTTP-Requests
- Tailwind CSS 3.3.6 für Styling
- Lucide React für Icons
BACKEND:
- Node.js mit Express 4.18.2
- TypeScript 5.3.0
- better-sqlite3-multiple-ciphers 12.2.0 (verschlüsselte Datenbank)
- jsonwebtoken 9.0.2 für JWT-Authentifizierung
- bcrypt 6.0.0 für Passwort-Hashing
- crypto-js 4.2.0 für Feld-Verschlüsselung
- winston 3.11.0 für Logging
- nodemailer 7.0.6 für E-Mail-Versand
- multer 2.0.1 für Datei-Uploads
- helmet 7.1.0 für Sicherheits-Header
- cors 2.8.5 für Cross-Origin Requests
================================================================================
3. DATENBANK-SCHEMA (SQLite mit AES-256 Verschlüsselung)
================================================================================
TABELLE: employees
------------------
id TEXT PRIMARY KEY # UUID
first_name TEXT NOT NULL
last_name TEXT NOT NULL
employee_number TEXT UNIQUE NOT NULL
photo TEXT # Base64 oder Pfad
position TEXT NOT NULL
department TEXT NOT NULL
email TEXT NOT NULL # VERSCHLÜSSELT
email_hash TEXT # Für Suche
phone TEXT NOT NULL # VERSCHLÜSSELT
phone_hash TEXT # Für Suche
mobile TEXT # VERSCHLÜSSELT
office TEXT
availability TEXT NOT NULL # 'available', 'busy', 'offline'
clearance_level TEXT # VERSCHLÜSSELT
clearance_valid_until TEXT # VERSCHLÜSSELT
clearance_issued_date TEXT
created_at TEXT NOT NULL
updated_at TEXT NOT NULL
created_by TEXT NOT NULL
updated_by TEXT
TABELLE: users
--------------
id TEXT PRIMARY KEY
username TEXT UNIQUE NOT NULL
email TEXT NOT NULL # VERSCHLÜSSELT
email_hash TEXT # Für Suche
password TEXT NOT NULL # Bcrypt gehashed
role TEXT NOT NULL # 'admin', 'superuser', 'user'
employee_id TEXT # FK zu employees
last_login TEXT
is_active INTEGER DEFAULT 1
created_at TEXT NOT NULL
updated_at TEXT NOT NULL
TABELLE: skills
---------------
id TEXT PRIMARY KEY
name TEXT NOT NULL
category TEXT NOT NULL
description TEXT
requires_certification INTEGER DEFAULT 0
expires_after INTEGER # Tage bis Ablauf
TABELLE: employee_skills (Verknüpfungstabelle)
-----------------------------------------------
employee_id TEXT NOT NULL
skill_id TEXT NOT NULL
level TEXT # 'basic', 'intermediate', 'advanced', 'expert'
verified INTEGER DEFAULT 0
verified_by TEXT
verified_date TEXT
PRIMARY KEY (employee_id, skill_id)
TABELLE: language_skills
------------------------
id TEXT PRIMARY KEY
employee_id TEXT NOT NULL
language TEXT NOT NULL
proficiency TEXT NOT NULL # A1-C2, Muttersprachler
certified INTEGER DEFAULT 0
certificate_type TEXT
is_native INTEGER DEFAULT 0
can_interpret INTEGER DEFAULT 0
TABELLE: specializations
------------------------
id TEXT PRIMARY KEY
employee_id TEXT NOT NULL
name TEXT NOT NULL
TABELLE: security_audit_log
---------------------------
id TEXT PRIMARY KEY
entity_type TEXT NOT NULL
entity_id TEXT NOT NULL
action TEXT NOT NULL # CRUD, login, logout, failed_login
user_id TEXT
changes TEXT # JSON der Änderungen
timestamp TEXT NOT NULL
ip_address TEXT
user_agent TEXT
risk_level TEXT # low, medium, high, critical
TABELLE: system_settings
------------------------
key TEXT PRIMARY KEY
value TEXT NOT NULL
description TEXT
updated_at TEXT NOT NULL
updated_by TEXT
================================================================================
4. API-ENDPUNKTE
================================================================================
AUTHENTIFIZIERUNG (/api/auth)
-----------------------------
POST /login - Benutzeranmeldung (username/email + password)
Returns: { user, token }
POST /logout - Benutzerabmeldung
MITARBEITER (/api/employees)
----------------------------
GET / - Alle Mitarbeiter mit Skills abrufen
GET /:id - Einzelnen Mitarbeiter abrufen
POST / - Neuen Mitarbeiter anlegen (admin/poweruser)
PUT /:id - Mitarbeiter aktualisieren
DELETE /:id - Mitarbeiter löschen (admin)
POST /search - Mitarbeiter nach Skills suchen
Body: { skills: string[], category?: string }
BENUTZER (/api/users, /api/admin/users)
---------------------------------------
GET / - Alle Benutzer abrufen (admin)
GET /:id - Benutzer abrufen
POST / - Neuen Benutzer anlegen (admin)
PUT /:id - Benutzer aktualisieren (admin)
DELETE /:id - Benutzer löschen (admin)
SKILLS (/api/skills)
-------------------
GET / - Alle verfügbaren Skills abrufen
POST /initialize - Standard-Skills initialisieren (admin)
DATEI-UPLOAD (/api/upload)
--------------------------
POST /photo - Mitarbeiterfoto hochladen
FormData: file (multipart)
NETZWERK & SYNC (/api/network, /api/sync)
-----------------------------------------
GET /network/discover - Andere Instanzen im Netzwerk finden
POST /sync/push - Daten an andere Instanz senden
POST /sync/pull - Daten von anderer Instanz holen
GET /sync/status - Synchronisationsstatus
SYSTEM (/api/admin/settings)
----------------------------
GET / - Alle Einstellungen abrufen
PUT /:key - Einstellung aktualisieren
HEALTH CHECK
-----------
GET /api/health - Server-Status prüfen
================================================================================
5. AUTHENTIFIZIERUNGS-FLOW
================================================================================
1. LOGIN-PROZESS:
- Benutzer sendet username/email + password an /api/auth/login
- Backend validiert Credentials gegen verschlüsselte Datenbank
- Passwort wird mit bcrypt verifiziert
- JWT-Token wird mit 24h Gültigkeit generiert
- User-Objekt und Token werden zurückgegeben
2. AUTORISIERUNG:
- JWT-Token wird bei jedem Request im Authorization-Header gesendet
- Middleware verifiziert Token und extrahiert User-Daten
- Rollenbasierte Zugriffskontrolle (admin > superuser > user)
- Granulare Berechtigungen für spezifische Aktionen
3. SICHERHEITS-FEATURES:
- Feld-Level-Verschlüsselung für sensible Daten
- Security Audit Logging aller kritischen Aktionen
- CSRF-Schutz durch Helmet
- Input-Validierung und Sanitization
- Rate-Limiting für Login-Versuche
================================================================================
6. GUI-AUFBAU UND STYLING
================================================================================
LAYOUT-STRUKTUR (Frontend & Admin-Panel)
----------------------------------------
┌─────────────────────────────────────────────────────────┐
│ HEADER (64px) │
│ Logo | Navigation User | Theme │
├──────────┬──────────────────────────────────────────────┤
│ │ │
│ │ MAIN CONTENT │
│ SIDEBAR │ │
│ (256px) │ (Responsive Grid/Flex) │
│ │ │
│ │ │
└──────────┴──────────────────────────────────────────────┘
FARBSCHEMA
----------
LIGHT MODE:
- Primär Blau: #3182CE (hover: #2563EB)
- Hintergrund: #F8FAFC (main), #FFFFFF (cards)
- Text Primär: #1A365D
- Text Sekundär: #2D3748
- Text Tertiär: #4A5568
- Text Quaternär: #718096
- Erfolg: #059669
- Warnung: #D97706
- Fehler: #DC2626
- Rahmen: #E2E8F0
DARK MODE:
- Hintergrund: #000000 (main), #1A1F3A (secondary)
- Akzent Cyan: #00D4FF
- Text Primär: #FFFFFF
- Text Sekundär: rgba(255,255,255,0.9)
- Text Tertiär: rgba(255,255,255,0.7)
- Rahmen: #2D3748
TYPOGRAFIE
----------
- Schriftart: Poppins (Primary), system-ui (Fallback)
- Titel Groß: 32px, font-semibold
- Titel Dialog: 24px, font-semibold
- Titel Sektion: 20px, font-semibold
- Body: 14px, font-normal
- Small: 12px, font-normal
- Caption: 10px, font-medium
ABSTÄNDE
--------
- Container: 40px padding
- Card: 32px padding
- Element: 16px padding
- Inline: 8px padding
BORDER-RADIUS
-------------
- Card: 16px
- Button: 24px
- Input: 8px
- Badge: 12px
- Avatar: 50% (kreisförmig)
KOMPONENTEN-STYLING
-------------------
BUTTONS:
- Primär: bg-blue-600, hover:bg-blue-700, text-white
- Sekundär: bg-gray-200, hover:bg-gray-300, text-gray-700
- Gefahr: bg-red-600, hover:bg-red-700, text-white
- Höhe: 40px (standard), 32px (small), 48px (large)
- Padding: 16px horizontal
INPUTS:
- Höhe: 40px
- Padding: 12px
- Border: 1px solid #E2E8F0
- Focus: ring-2 ring-blue-500
- Dark Mode: bg-gray-800, border-gray-600
CARDS:
- Background: white (light), #1A1F3A (dark)
- Shadow: 0 1px 3px rgba(0,0,0,0.1)
- Hover Shadow: 0 4px 6px rgba(0,0,0,0.1)
- Transition: all 0.2s ease
SIDEBAR:
- Width: 256px (expanded), 64px (collapsed)
- Background: white (light), #1A1F3A (dark)
- Items: 48px height, hover:bg-gray-100
- Active Item: bg-blue-50, border-left: 3px solid blue
TABLES:
- Header: bg-gray-50, font-semibold
- Row Height: 48px
- Hover: bg-gray-50
- Border: 1px solid #E2E8F0
RESPONSIVE BREAKPOINTS
---------------------
- Mobile: < 640px
- Tablet: 640px - 1024px
- Desktop: > 1024px
ANIMATIONEN
-----------
- Transition Default: 200ms ease
- Hover Scale: scale(1.02)
- Click Scale: scale(0.98)
- Fade In: opacity 0 to 1, 300ms
- Slide In: translateX(-100%) to 0, 300ms
================================================================================
7. FRONTEND KOMPONENTEN-HIERARCHIE
================================================================================
APP.TSX (Router-Konfiguration)
-------------------------------
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/employees" element={<EmployeeList />} />
<Route path="/employees/new" element={<EmployeeForm />} />
<Route path="/employees/:id" element={<EmployeeDetail />} />
<Route path="/employees/:id/edit" element={<EmployeeForm />} />
<Route path="/search" element={<SkillSearch />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<WorkspaceAnalytics />} />
<Route path="/floor-plan" element={<FloorPlan />} />
<Route path="/desk-booking" element={<DeskBooking />} />
</Route>
</Routes>
</BrowserRouter>
LAYOUT-KOMPONENTE
-----------------
<div className="flex h-screen">
<Sidebar>
- Logo
- NavigationItems[]
- Dashboard
- Mitarbeiter
- Suche
- Analytics
- Einstellungen
- UserInfo
</Sidebar>
<div className="flex-1 flex flex-col">
<Header>
- Breadcrumbs
- SearchBar
- NotificationBell
- UserMenu
- ThemeToggle
</Header>
<main className="flex-1 overflow-auto">
<Outlet /> {/* Routed Content */}
</main>
</div>
</div>
WIEDERVERWENDBARE KOMPONENTEN
-----------------------------
EmployeeCard:
- Foto (Avatar)
- Name & Position
- Abteilung
- Verfügbarkeitsstatus
- Skills-Tags
- Kontakt-Icons
PhotoUpload:
- Drag & Drop Zone
- File Input
- Preview
- Upload Progress
- Error Handling
SkillBadge:
- Skill Name
- Level Indicator
- Verified Icon
- Category Color
SearchFilter:
- Kategorie-Dropdown
- Skill-Multi-Select
- Level-Filter
- Verfügbarkeits-Filter
- Reset-Button
DataTable:
- Sortierbare Header
- Pagination
- Row Selection
- Bulk Actions
- Export Funktion
Modal:
- Overlay
- Content Container
- Close Button
- Title
- Footer Actions
================================================================================
8. STATE MANAGEMENT (ZUSTAND STORES)
================================================================================
AUTH STORE
----------
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
// Actions
login: (user: User, token: string) => void
logout: () => void
updateUser: (updates: Partial<User>) => void
checkAuth: () => Promise<boolean>
}
THEME STORE
-----------
interface ThemeState {
theme: 'light' | 'dark'
sidebarCollapsed: boolean
// Actions
toggleTheme: () => void
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
}
EMPLOYEE STORE (implizit)
------------------------
- Employees werden via React Query/SWR gecached
- Optimistic Updates bei Änderungen
- Background Refetching
- Pagination State lokal
================================================================================
9. ENTWICKLUNGS- UND BUILD-PROZESS
================================================================================
ENTWICKLUNG
-----------
Frontend:
npm run dev # Startet Vite Dev-Server auf Port 5173
Backend:
npm run dev # Startet Nodemon mit ts-node
Admin-Panel:
npm run dev # Startet Vite Dev-Server auf Port 5174
PRODUCTION BUILD
---------------
Frontend:
npm run build # Erstellt optimierten Build in dist/
Backend:
npm run build # Kompiliert TypeScript zu JavaScript
npm start # Startet Production Server
Admin-Panel:
npm run build # Erstellt optimierten Build
UMGEBUNGSVARIABLEN
-----------------
Backend (.env):
PORT=3000
DATABASE_PATH=./data/skillmate.db
DATABASE_KEY=your-encryption-key
JWT_SECRET=your-jwt-secret
FIELD_ENCRYPTION_KEY=your-field-key
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=user@example.com
SMTP_PASS=password
Frontend (.env):
VITE_API_URL=http://localhost:3000/api
VITE_APP_TITLE=SkillMate
================================================================================
10. SICHERHEITS-FEATURES
================================================================================
1. DATENBANK-VERSCHLÜSSELUNG:
- AES-256 Verschlüsselung der gesamten Datenbank
- Zusätzliche Feld-Level-Verschlüsselung für PII
2. AUTHENTIFIZIERUNG:
- JWT mit 24h Expiration
- Refresh Token Mechanismus
- Session Management
3. AUTORISIERUNG:
- Rollenbasierte Zugriffskontrolle (RBAC)
- Granulare Berechtigungen
- API-Endpoint-Level Security
4. AUDIT LOGGING:
- Alle CRUD-Operationen
- Login/Logout Events
- Failed Login Attempts
- Risk Level Assessment
5. INPUT VALIDIERUNG:
- Express-Validator für alle Inputs
- SQL Injection Prevention
- XSS Protection
- CSRF Token Validation
6. NETZWERK-SICHERHEIT:
- HTTPS in Production
- Helmet Security Headers
- CORS Configuration
- Rate Limiting
================================================================================
11. SPEZIELLE FEATURES
================================================================================
MULTI-INSTANZ-SYNCHRONISATION
-----------------------------
- Automatische Netzwerk-Discovery
- Bidirektionale Daten-Synchronisation
- Konfliktauflösung
- Scheduled Sync Jobs
- Sync History & Rollback
SKILL-VERWALTUNG
---------------
- Hierarchische Skill-Kategorien
- Skill-Level (Basic bis Expert)
- Zertifizierungsverwaltung
- Ablaufdaten-Tracking
- Skill-Verifizierung
SPRACHKENNTNISSE
---------------
- GER-Level (A1-C2)
- Muttersprachler-Kennzeichnung
- Dolmetscher-Fähigkeiten
- Zertifikats-Verwaltung
ARBEITSPLATZ-FUNKTIONEN
----------------------
- Büro-Zuweisung
- Verfügbarkeitsstatus
- Desk Booking System
- Floor Plan Visualization
E-MAIL-INTEGRATION
-----------------
- SMTP-Konfiguration
- Benachrichtigungen
- Passwort-Reset
- Skill-Ablauf-Erinnerungen
================================================================================
12. INSTALLATION UND DEPLOYMENT
================================================================================
VORAUSSETZUNGEN
--------------
- Node.js 18+ mit npm
- Python 3.8+ (für Launcher)
- SQLite3
- 4GB RAM minimum
- Windows 10/11 oder Linux
INSTALLATION
-----------
1. Repository klonen
2. Dependencies installieren:
cd backend && npm install
cd ../frontend && npm install
cd ../admin-panel && npm install
3. Datenbank initialisieren:
cd backend
npm run init-db
4. Umgebungsvariablen konfigurieren
5. Anwendung starten:
python main.py
oder
npm run dev (in jedem Ordner)
DEPLOYMENT
----------
1. Production Builds erstellen
2. Reverse Proxy konfigurieren (nginx/Apache)
3. SSL-Zertifikate einrichten
4. Systemd Service erstellen (Linux)
5. Backup-Strategie implementieren
================================================================================
13. WARTUNG UND MONITORING
================================================================================
LOGGING
-------
- Winston Logger mit Rotation
- Log-Level: error, warn, info, debug
- Separate Logs für Security Events
- Performance Metrics
BACKUP
------
- Automatische Datenbank-Backups
- Verschlüsselte Backup-Dateien
- Retention Policy (30 Tage)
- Restore-Funktionalität
MONITORING
----------
- Health Check Endpoint
- Performance Metrics
- Error Tracking
- User Activity Dashboard
================================================================================
ENDE DER DOKUMENTATION
================================================================================

13
admin-panel/index.html Normale Datei
Datei anzeigen

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SkillMate Admin Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3456
admin-panel/package-lock.json generiert Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

31
admin-panel/package.json Normale Datei
Datei anzeigen

@ -0,0 +1,31 @@
{
"name": "@skillmate/admin-panel",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@skillmate/shared": "file:../shared",
"axios": "^1.6.2",
"date-fns": "^2.30.0",
"lucide-react": "^0.525.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-router-dom": "^6.20.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.3.0",
"vite": "^5.0.7"
}
}

Datei anzeigen

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

49
admin-panel/src/App.tsx Normale Datei
Datei anzeigen

@ -0,0 +1,49 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from './stores/authStore'
import Layout from './components/Layout'
import Login from './views/Login'
import Dashboard from './views/Dashboard'
import CreateEmployee from './views/CreateEmployee'
import SkillManagement from './views/SkillManagement'
import UserManagement from './views/UserManagement'
import EmailSettings from './views/EmailSettings'
import SyncSettings from './views/SyncSettings'
import { useEffect } from 'react'
function App() {
const { isAuthenticated } = useAuthStore()
useEffect(() => {
// Always use light mode for admin panel
document.documentElement.classList.remove('dark')
}, [])
if (!isAuthenticated) {
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Router>
)
}
return (
<Router>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/skills" element={<SkillManagement />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/users/create-employee" element={<CreateEmployee />} />
<Route path="/email-settings" element={<EmailSettings />} />
<Route path="/sync" element={<SyncSettings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</Router>
)
}
export default App

Datei anzeigen

@ -0,0 +1,7 @@
export default function HomeIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,85 @@
import { ReactNode } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/authStore'
import {
HomeIcon,
UsersIcon,
SettingsIcon,
MailIcon
} from './icons'
interface LayoutProps {
children: ReactNode
}
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Benutzerverwaltung', href: '/users', icon: UsersIcon },
{ name: 'Skills verwalten', href: '/skills', icon: SettingsIcon },
{ name: 'E-Mail-Einstellungen', href: '/email-settings', icon: MailIcon },
{ name: 'Synchronisation', href: '/sync', icon: SettingsIcon },
]
export default function Layout({ children }: LayoutProps) {
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="flex h-screen bg-bg-main">
<div className="relative w-[260px] bg-white border-r border-border-default flex flex-col pb-28">
<div className="p-5">
<h1 className="text-title-card font-poppins font-semibold text-primary">
SkillMate Admin
</h1>
</div>
<nav className="px-5 space-y-1">
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
`sidebar-item ${isActive ? 'sidebar-item-active' : ''}`
}
>
<item.icon className="w-5 h-5 mr-3 flex-shrink-0" />
<span className="font-poppins font-medium">{item.name}</span>
</NavLink>
))}
</nav>
<div className="absolute bottom-0 left-0 right-0 p-5 border-t border-border-default">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-body font-medium text-secondary">{user?.username}</p>
<p className="text-small text-tertiary capitalize">{user?.role}</p>
</div>
</div>
<button
onClick={handleLogout}
className="btn-secondary w-full"
>
Abmelden
</button>
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-white border-b border-border-default h-16 flex items-center px-container">
<h2 className="text-title-dialog font-poppins font-semibold text-primary">
Admin Panel
</h2>
</header>
<main className="flex-1 overflow-y-auto p-container bg-bg-main">
{children}
</main>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function MoonIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function SearchIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,8 @@
export default function SettingsIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function SunIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,130 @@
import { useState, useEffect } from 'react'
import { RefreshCw, AlertCircle, CheckCircle } from 'lucide-react'
import { api } from '../services/api'
interface SyncStatusData {
pendingItems: number
recentSync: any[]
pendingConflicts: number
isSyncing: boolean
}
export default function SyncStatus() {
const [status, setStatus] = useState<SyncStatusData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetchStatus()
// Refresh every 30 seconds
const interval = setInterval(fetchStatus, 30000)
return () => clearInterval(interval)
}, [])
const fetchStatus = async () => {
try {
const response = await api.get('/sync/status')
setStatus(response.data.data)
setError('')
} catch (err) {
console.error('Failed to fetch sync status:', err)
setError('Fehler beim Abrufen des Sync-Status')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center text-red-600">
<AlertCircle className="w-5 h-5 mr-2" />
<span className="text-sm">{error}</span>
</div>
</div>
)
}
if (!status) return null
return (
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Sync-Status</h3>
<button
onClick={fetchStatus}
className="text-gray-500 hover:text-gray-700"
>
<RefreshCw className={`w-5 h-5 ${status.isSyncing ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-gray-500">Ausstehend</div>
<div className="text-2xl font-bold text-gray-900">{status.pendingItems}</div>
</div>
<div>
<div className="text-gray-500">Konflikte</div>
<div className="text-2xl font-bold text-orange-600">{status.pendingConflicts}</div>
</div>
<div>
<div className="text-gray-500">Status</div>
<div className="mt-1">
{status.isSyncing ? (
<span className="flex items-center text-blue-600">
<RefreshCw className="w-4 h-4 mr-1 animate-spin" />
Läuft...
</span>
) : (
<span className="flex items-center text-green-600">
<CheckCircle className="w-4 h-4 mr-1" />
Bereit
</span>
)}
</div>
</div>
</div>
{status.recentSync && status.recentSync.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Sync-Vorgänge</h4>
<div className="space-y-1">
{status.recentSync.slice(0, 3).map((sync, index) => (
<div key={index} className="flex items-center justify-between text-xs">
<span className="text-gray-600">
{sync.sync_type} - {sync.sync_action}
</span>
<span className={`px-2 py-1 rounded-full ${
sync.status === 'completed' ? 'bg-green-100 text-green-800' :
sync.status === 'failed' ? 'bg-red-100 text-red-800' :
sync.status === 'conflict' ? 'bg-orange-100 text-orange-800' :
'bg-gray-100 text-gray-800'
}`}>
{sync.status === 'completed' ? 'Erfolgreich' :
sync.status === 'failed' ? 'Fehlgeschlagen' :
sync.status === 'conflict' ? 'Konflikt' :
'Ausstehend'}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,7 @@
export default function UsersIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
)
}

Datei anzeigen

@ -0,0 +1,110 @@
export const DashboardIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
export const UsersIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)
export const SkillsIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
)
export const UserManagementIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
)
export const SyncIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
export const LogoutIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
)
export const HomeIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)
export const SettingsIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
interface IconProps {
className?: string
}
export function PencilIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)
}
export function TrashIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
)
}
export function ShieldIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)
}
export function KeyIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
)
}
export function UserIcon({ className }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
}
export const MailIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
)
export const ToggleLeftIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12H9m6 0a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
export const ToggleRightIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)

Datei anzeigen

@ -0,0 +1,6 @@
export { default as HomeIcon } from './HomeIcon'
export { default as UsersIcon } from './UsersIcon'
export { default as SearchIcon } from './SearchIcon'
export { default as SettingsIcon } from './SettingsIcon'
export { default as SunIcon } from './SunIcon'
export { default as MoonIcon } from './MoonIcon'

198
admin-panel/src/index.css Normale Datei
Datei anzeigen

@ -0,0 +1,198 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--primary-blue: #3182CE;
--primary-blue-hover: #2563EB;
--primary-blue-active: #1D4ED8;
--primary-blue-dark: #1E40AF;
--bg-main: #F8FAFC;
--bg-white: #FFFFFF;
--bg-gray: #F0F4F8;
--bg-accent: #E6F2FF;
--text-primary: #1A365D;
--text-secondary: #2D3748;
--text-tertiary: #4A5568;
--text-quaternary: #718096;
--text-placeholder: #A0AEC0;
--border-default: #E2E8F0;
--border-input: #CBD5E0;
--success: #059669;
--warning: #D97706;
--error: #DC2626;
--info: #2563EB;
}
.dark {
--primary-blue: #232D53;
--primary-blue-hover: #232D53;
--primary-blue-active: #232D53;
--bg-main: #000000;
--bg-white: #1A1F3A;
--bg-gray: #232D53;
--bg-accent: #232D53;
--text-primary: #FFFFFF;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.6);
--text-quaternary: rgba(255, 255, 255, 0.5);
--text-placeholder: rgba(255, 255, 255, 0.4);
--border-default: rgba(255, 255, 255, 0.1);
--border-input: rgba(255, 255, 255, 0.2);
--success: #4CAF50;
--warning: #FFC107;
--error: #FF4444;
--info: #2196F3;
}
* {
@apply transition-colors duration-default ease-default;
}
body {
@apply bg-bg-main text-text-secondary font-sans;
}
.dark body {
@apply bg-dark-bg text-dark-text-primary;
}
}
@layer components {
.btn-primary {
@apply bg-primary-blue text-white rounded-button h-12 px-8 font-poppins font-semibold text-nav
hover:bg-primary-blue-hover active:bg-primary-blue-active shadow-sm
transition-all duration-default ease-default
dark:bg-dark-accent dark:text-dark-bg dark:hover:bg-dark-accent-hover dark:hover:text-white;
}
.btn-secondary {
@apply bg-transparent text-text-primary border border-border-default rounded-button h-12 px-8
font-poppins font-semibold text-nav hover:bg-bg-main hover:border-border-input
transition-all duration-default ease-default
dark:text-white dark:border-dark-primary dark:hover:bg-dark-primary;
}
.input-field {
@apply bg-white border border-border-input rounded-input px-4 py-3 text-text-secondary
placeholder:text-text-placeholder focus:border-primary-blue focus:shadow-focus
focus:outline-none transition-all duration-fast
dark:bg-dark-primary dark:border-transparent dark:text-white
dark:placeholder:text-dark-text-tertiary dark:focus:bg-dark-bg-focus;
}
.card {
@apply bg-white border border-border-default rounded-card p-card shadow-sm
hover:border-primary-blue hover:bg-bg-main hover:shadow-md hover:-translate-y-0.5
transition-all duration-default ease-default cursor-pointer
dark:bg-dark-bg-secondary dark:border-transparent dark:hover:border-dark-accent
dark:hover:bg-dark-primary;
}
.badge {
@apply px-3 py-1 rounded-badge text-help font-semibold uppercase tracking-wide;
}
.badge-success {
@apply bg-success-bg text-success dark:bg-success dark:text-white;
}
.badge-warning {
@apply bg-warning-bg text-warning dark:bg-warning dark:text-dark-bg;
}
.badge-error {
@apply bg-error-bg text-error dark:bg-error dark:text-white;
}
.badge-info {
@apply bg-info-bg text-info dark:bg-info dark:text-white;
}
.sidebar-item {
@apply flex items-center px-4 py-3 rounded-input text-nav font-medium
hover:bg-bg-main transition-all duration-fast cursor-pointer
dark:hover:bg-dark-primary/50;
}
.sidebar-item-active {
@apply bg-bg-accent text-primary-blue-dark border-l-4 border-primary-blue
dark:bg-dark-primary dark:text-white dark:border-dark-accent;
}
.dialog {
@apply bg-white border border-border-default rounded-input shadow-xl
dark:bg-dark-bg dark:border-dark-border;
}
.dialog-header {
@apply bg-bg-gray h-10 flex items-center justify-between px-4 rounded-t-input
dark:bg-dark-primary;
}
.table-header {
@apply bg-bg-gray text-text-primary font-semibold border-b border-border-default
dark:bg-dark-primary dark:text-white dark:border-dark-border;
}
.table-row {
@apply bg-white hover:bg-bg-main border-b border-border-default transition-colors duration-fast
dark:bg-transparent dark:hover:bg-dark-bg-secondary dark:border-dark-border;
}
.scrollbar {
@apply scrollbar-thin scrollbar-track-divider scrollbar-thumb-border-input
hover:scrollbar-thumb-text-placeholder
dark:scrollbar-track-dark-bg-secondary dark:scrollbar-thumb-dark-accent
dark:hover:scrollbar-thumb-dark-accent-hover;
}
}
@layer utilities {
.text-primary {
@apply text-text-primary dark:text-white;
}
.text-secondary {
@apply text-text-secondary dark:text-dark-text-secondary;
}
.text-tertiary {
@apply text-text-tertiary dark:text-dark-text-tertiary;
}
.bg-primary {
@apply bg-bg-main dark:bg-dark-bg;
}
.bg-secondary {
@apply bg-white dark:bg-dark-bg-secondary;
}
.bg-tertiary {
@apply bg-bg-gray dark:bg-dark-primary;
}
.border-primary {
@apply border-border-default dark:border-dark-border;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.-webkit-app-region-drag {
-webkit-app-region: drag;
}
.-webkit-app-region-no-drag {
-webkit-app-region: no-drag;
}
}

10
admin-panel/src/main.tsx Normale Datei
Datei anzeigen

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

Datei anzeigen

@ -0,0 +1,39 @@
import axios from 'axios'
import { useAuthStore } from '../stores/authStore'
const API_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api'
export const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
api.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor to handle auth errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout()
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

Datei anzeigen

@ -0,0 +1,66 @@
import api from './api'
export interface NetworkNode {
id: string
name: string
location: string
ipAddress: string
port: number
apiKey: string
isOnline: boolean
lastSync: Date | null
lastPing: Date | null
type: 'admin' | 'local'
}
export interface SyncSettings {
autoSyncInterval: string
conflictResolution: 'admin' | 'newest' | 'manual'
syncEmployees: boolean
syncSkills: boolean
syncUsers: boolean
syncSettings: boolean
bandwidthLimit: number | null
}
export const networkApi = {
// Network nodes
getNodes: async (): Promise<NetworkNode[]> => {
const response = await api.get('/network/nodes')
return response.data.data
},
createNode: async (node: Partial<NetworkNode>): Promise<{ id: string; apiKey: string }> => {
const response = await api.post('/network/nodes', node)
return response.data.data
},
updateNode: async (id: string, updates: Partial<NetworkNode>): Promise<void> => {
await api.put(`/network/nodes/${id}`, updates)
},
deleteNode: async (id: string): Promise<void> => {
await api.delete(`/network/nodes/${id}`)
},
pingNode: async (id: string): Promise<{ isOnline: boolean; lastPing: string; responseTime: number | null }> => {
const response = await api.post(`/network/nodes/${id}/ping`)
return response.data.data
},
// Sync settings
getSyncSettings: async (): Promise<SyncSettings> => {
const response = await api.get('/network/sync-settings')
return response.data.data
},
updateSyncSettings: async (settings: Partial<SyncSettings>): Promise<void> => {
await api.put('/network/sync-settings', settings)
},
// Sync operations
triggerSync: async (nodeIds?: string[]): Promise<{ syncedAt: string; nodeCount: number | string }> => {
const response = await api.post('/network/sync/trigger', { nodeIds })
return response.data.data
}
}

Datei anzeigen

@ -0,0 +1,26 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '@skillmate/shared'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
login: (user: User, token: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
login: (user, token) => set({ user, token, isAuthenticated: true }),
logout: () => set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'auth-storage',
}
)
)

Datei anzeigen

@ -0,0 +1,21 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ThemeState {
isDarkMode: boolean
toggleTheme: () => void
setTheme: (isDark: boolean) => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
isDarkMode: false,
toggleTheme: () => set((state) => ({ isDarkMode: !state.isDarkMode })),
setTheme: (isDark) => set({ isDarkMode: isDark }),
}),
{
name: 'theme-storage',
}
)
)

Datei anzeigen

@ -0,0 +1,203 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--primary-blue: #3182CE;
--primary-blue-hover: #2563EB;
--primary-blue-active: #1D4ED8;
--primary-blue-dark: #1E40AF;
--bg-main: #F8FAFC;
--bg-white: #FFFFFF;
--bg-gray: #F0F4F8;
--bg-accent: #E6F2FF;
--text-primary: #1A365D;
--text-secondary: #2D3748;
--text-tertiary: #4A5568;
--text-quaternary: #718096;
--text-placeholder: #A0AEC0;
--border-default: #E2E8F0;
--border-input: #CBD5E0;
--success: #059669;
--warning: #D97706;
--error: #DC2626;
--info: #2563EB;
}
.dark {
--primary-blue: #232D53;
--primary-blue-hover: #232D53;
--primary-blue-active: #232D53;
--bg-main: #000000;
--bg-white: #1A1F3A;
--bg-gray: #232D53;
--bg-accent: #232D53;
--text-primary: #FFFFFF;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-tertiary: rgba(255, 255, 255, 0.6);
--text-quaternary: rgba(255, 255, 255, 0.5);
--text-placeholder: rgba(255, 255, 255, 0.4);
--border-default: rgba(255, 255, 255, 0.1);
--border-input: rgba(255, 255, 255, 0.2);
--success: #4CAF50;
--warning: #FFC107;
--error: #FF4444;
--info: #2196F3;
}
* {
@apply transition-colors duration-default ease-default;
}
body {
@apply bg-bg-main text-text-secondary font-sans;
}
.dark body {
@apply bg-dark-bg text-dark-text-primary;
}
}
@layer components {
.btn-primary {
@apply bg-primary-blue text-white rounded-button h-12 px-8 font-poppins font-semibold text-nav
hover:bg-primary-blue-hover active:bg-primary-blue-active shadow-sm
transition-all duration-default ease-default
dark:bg-dark-accent dark:text-dark-bg dark:hover:bg-dark-accent-hover dark:hover:text-white;
}
.btn-secondary {
@apply bg-transparent text-text-primary border border-border-default rounded-button h-12 px-8
font-poppins font-semibold text-nav hover:bg-bg-main hover:border-border-input
transition-all duration-default ease-default
dark:text-white dark:border-dark-primary dark:hover:bg-dark-primary;
}
.input-field {
@apply bg-white border border-border-input rounded-input px-4 py-3 text-text-secondary
placeholder:text-text-placeholder focus:border-primary-blue focus:shadow-focus
focus:outline-none transition-all duration-fast
dark:bg-dark-primary dark:border-transparent dark:text-white
dark:placeholder:text-dark-text-tertiary dark:focus:bg-dark-bg-focus;
}
.card {
@apply bg-white border border-border-default rounded-card p-card shadow-sm
hover:border-primary-blue hover:bg-bg-main hover:shadow-md hover:-translate-y-0.5
transition-all duration-default ease-default cursor-pointer
dark:bg-dark-bg-secondary dark:border-transparent dark:hover:border-dark-accent
dark:hover:bg-dark-primary;
}
.form-card {
@apply bg-white border border-gray-300 rounded-lg p-6 shadow-sm
dark:bg-gray-800 dark:border-gray-600;
}
.badge {
@apply px-3 py-1 rounded-badge text-help font-semibold uppercase tracking-wide;
}
.badge-success {
@apply bg-success-bg text-success dark:bg-success dark:text-white;
}
.badge-warning {
@apply bg-warning-bg text-warning dark:bg-warning dark:text-dark-bg;
}
.badge-error {
@apply bg-error-bg text-error dark:bg-error dark:text-white;
}
.badge-info {
@apply bg-info-bg text-info dark:bg-info dark:text-white;
}
.sidebar-item {
@apply flex items-center px-4 py-3 rounded-input text-nav font-medium
hover:bg-bg-main transition-all duration-fast cursor-pointer
dark:hover:bg-dark-primary/50;
}
.sidebar-item-active {
@apply bg-bg-accent text-primary-blue-dark border-l-4 border-primary-blue
dark:bg-dark-primary dark:text-white dark:border-dark-accent;
}
.dialog {
@apply bg-white border border-border-default rounded-input shadow-xl
dark:bg-dark-bg dark:border-dark-border;
}
.dialog-header {
@apply bg-bg-gray h-10 flex items-center justify-between px-4 rounded-t-input
dark:bg-dark-primary;
}
.table-header {
@apply bg-bg-gray text-text-primary font-semibold border-b border-border-default
dark:bg-dark-primary dark:text-white dark:border-dark-border;
}
.table-row {
@apply bg-white hover:bg-bg-main border-b border-border-default transition-colors duration-fast
dark:bg-transparent dark:hover:bg-dark-bg-secondary dark:border-dark-border;
}
.scrollbar {
@apply scrollbar-thin scrollbar-track-divider scrollbar-thumb-border-input
hover:scrollbar-thumb-text-placeholder
dark:scrollbar-track-dark-bg-secondary dark:scrollbar-thumb-dark-accent
dark:hover:scrollbar-thumb-dark-accent-hover;
}
}
@layer utilities {
.text-primary {
@apply text-text-primary dark:text-white;
}
.text-secondary {
@apply text-text-secondary dark:text-dark-text-secondary;
}
.text-tertiary {
@apply text-text-tertiary dark:text-dark-text-tertiary;
}
.bg-primary {
@apply bg-bg-main dark:bg-dark-bg;
}
.bg-secondary {
@apply bg-white dark:bg-dark-bg-secondary;
}
.bg-tertiary {
@apply bg-bg-gray dark:bg-dark-primary;
}
.border-primary {
@apply border-border-default dark:border-dark-border;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.-webkit-app-region-drag {
-webkit-app-region: drag;
}
.-webkit-app-region-no-drag {
-webkit-app-region: no-drag;
}
}

Datei anzeigen

@ -0,0 +1,281 @@
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useState } from 'react'
import { api } from '../services/api'
import type { UserRole } from '@skillmate/shared'
interface CreateEmployeeData {
firstName: string
lastName: string
email: string
department: string
userRole: 'admin' | 'superuser' | 'user'
createUser: boolean
}
export default function CreateEmployee() {
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
const [creationDone, setCreationDone] = useState(false)
const { register, handleSubmit, formState: { errors }, watch } = useForm<CreateEmployeeData>({
defaultValues: {
userRole: 'user',
createUser: true
}
})
const watchCreateUser = watch('createUser')
const onSubmit = async (data: CreateEmployeeData) => {
try {
setLoading(true)
setError('')
setSuccess('')
const payload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
department: data.department,
userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser
}
const response = await api.post('/employees', payload)
if (response.data.data?.temporaryPassword) {
setCreatedUser({ password: response.data.data.temporaryPassword })
setSuccess(`Mitarbeiter und Benutzerkonto erfolgreich erstellt!`)
} else {
setSuccess('Mitarbeiter erfolgreich erstellt!')
}
// Manuelles Weiterklicken statt Auto-Navigation, damit Passwort kopiert werden kann
setCreationDone(true)
} catch (err: any) {
console.error('Error:', err.response?.data)
const errorMessage = err.response?.data?.error?.message || 'Speichern fehlgeschlagen'
const errorDetails = err.response?.data?.error?.details
if (errorDetails && Array.isArray(errorDetails)) {
const detailMessages = errorDetails.map((d: any) => d.msg || d.message).join(', ')
setError(`${errorMessage}: ${detailMessages}`)
} else {
setError(errorMessage)
}
} finally {
setLoading(false)
}
}
const getRoleDescription = (role: UserRole): string => {
const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
superuser: 'Kann Mitarbeiter anlegen und verwalten, aber kein Zugriff auf Admin Panel',
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeiter durchsuchen'
}
return descriptions[role]
}
return (
<div>
<div className="mb-8">
<button
onClick={() => navigate('/users')}
className="text-primary-blue hover:text-primary-blue-hover mb-4"
>
Zurück zur Benutzerverwaltung
</button>
<h1 className="text-title-lg font-poppins font-bold text-primary">
Neuen Mitarbeiter & Benutzer anlegen
</h1>
<p className="text-body text-secondary mt-2">
Erstellen Sie einen neuen Mitarbeiter-Datensatz und optional ein Benutzerkonto
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
{success && (
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
{createdUser.password && (
<div className="mt-4 p-4 bg-white rounded border border-green-300">
<h4 className="font-semibold mb-2">🔑 Temporäres Passwort:</h4>
<code className="text-lg bg-gray-100 px-3 py-2 rounded block">
{createdUser.password}
</code>
<p className="text-sm mt-2 text-green-700">
Bitte notieren Sie dieses Passwort und geben Sie es sicher an den Mitarbeiter weiter.
Das Passwort muss beim ersten Login geändert werden.
</p>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={() => navigate('/users')}
className="btn-primary"
>
Weiter zur Benutzerverwaltung
</button>
</div>
</div>
)}
</div>
)}
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Mitarbeiter-Grunddaten
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Vorname *
</label>
<input
{...register('firstName', { required: 'Vorname ist erforderlich' })}
className="input-field w-full"
placeholder="Max"
/>
{errors.firstName && (
<p className="text-error text-sm mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nachname *
</label>
<input
{...register('lastName', { required: 'Nachname ist erforderlich' })}
className="input-field w-full"
placeholder="Mustermann"
/>
{errors.lastName && (
<p className="text-error text-sm mt-1">{errors.lastName.message}</p>
)}
</div>
<div className="col-span-2">
<label className="block text-body font-medium text-secondary mb-2">
E-Mail *
</label>
<input
{...register('email', {
required: 'E-Mail ist erforderlich',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Ungültige E-Mail-Adresse'
}
})}
type="email"
className="input-field w-full"
placeholder="max.mustermann@firma.de"
/>
{errors.email && (
<p className="text-error text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div className="col-span-2">
<label className="block text-body font-medium text-secondary mb-2">
Abteilung *
</label>
<input
{...register('department', { required: 'Abteilung ist erforderlich' })}
className="input-field w-full"
placeholder="IT, Personal, Marketing, etc."
/>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Benutzerkonto
</h2>
<div className="space-y-4">
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
{...register('createUser')}
type="checkbox"
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
/>
<span className="text-body font-medium text-secondary">
Benutzerkonto für System-Zugang erstellen
</span>
</label>
<p className="text-sm text-secondary-light mt-2 ml-7">
Erstellt ein Benutzerkonto, mit dem sich der Mitarbeiter im System anmelden kann.
Ein sicheres temporäres Passwort wird automatisch generiert.
</p>
</div>
{watchCreateUser && (
<div>
<label className="block text-body font-medium text-secondary mb-2">
Benutzerrolle *
</label>
<select
{...register('userRole')}
className="input-field w-full"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<p className="text-sm text-secondary-light mt-2">
{getRoleDescription(watch('userRole'))}
</p>
</div>
)}
</div>
</div>
<div className="card mb-6 bg-blue-50 border-blue-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
📋 Was passiert als Nächstes?
</h3>
<ul className="space-y-2 text-body text-secondary">
<li> Der Mitarbeiter wird mit Grunddaten angelegt (Position: "Mitarbeiter", Telefon: "Nicht angegeben")</li>
<li> {watchCreateUser ? 'Ein Benutzerkonto wird erstellt und ein temporäres Passwort generiert' : 'Kein Benutzerkonto wird erstellt'}</li>
<li> Der Mitarbeiter kann später im Frontend seine Profildaten vervollständigen</li>
<li> Alle Daten werden verschlüsselt in der Datenbank gespeichert</li>
</ul>
</div>
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => navigate('/users')}
className="btn-secondary"
disabled={loading}
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? 'Erstelle...' : 'Mitarbeiter erstellen'}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
import SyncStatus from '../components/SyncStatus'
interface DashboardStats {
totalEmployees: number
totalSkills: number
totalUsers: number
lastSync?: {
timestamp: string
success: boolean
itemsSynced: number
}
}
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats>({
totalEmployees: 0,
totalSkills: 0,
totalUsers: 0,
})
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchStats()
}, [])
const fetchStats = async () => {
try {
const [employeesRes, hierarchyRes, usersRes] = await Promise.all([
// Zähle nur Mitarbeitende mit verknüpftem aktivem Benutzerkonto (wie im Frontend)
api.get('/employees/public'),
// Zähle nur in der Hierarchie sichtbare Skills (entspricht Skill-Verwaltung)
api.get('/skills/hierarchy'),
api.get('/admin/users'),
])
const publicEmployees = employeesRes.data.data || []
const users = (usersRes.data.data || [])
.filter((u: any) => u.username !== 'admin' && u.isActive !== false)
// Summe Skills aus Hierarchie bilden
const hierarchy = (hierarchyRes.data.data || []) as any[]
const totalSkills = hierarchy.reduce((sum, cat) => sum + (cat.subcategories || []).reduce((s2: number, sub: any) => s2 + ((sub.skills || []).length), 0), 0)
setStats({
totalEmployees: publicEmployees.length,
totalSkills,
totalUsers: users.length,
})
} catch (error) {
console.error('Failed to fetch stats:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-tertiary">Daten werden geladen...</p>
</div>
)
}
const statsCards = [
{
title: 'Mitarbeiter',
value: stats.totalEmployees,
color: 'text-primary-blue',
bgColor: 'bg-bg-accent',
},
{
title: 'Skills',
value: stats.totalSkills,
color: 'text-success',
bgColor: 'bg-success-bg',
},
{
title: 'Benutzer',
value: stats.totalUsers,
color: 'text-info',
bgColor: 'bg-info-bg',
},
]
return (
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-8">
Dashboard
</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{statsCards.map((stat, index) => (
<div key={index} className="card">
<div className={`w-16 h-16 rounded-card ${stat.bgColor} flex items-center justify-center mb-4`}>
<span className={`text-2xl font-bold ${stat.color}`}>
{stat.value}
</span>
</div>
<h3 className="text-body font-medium text-tertiary">
{stat.title}
</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Letzte Synchronisation
</h2>
{stats.lastSync ? (
<div className="space-y-2">
<p className="text-body text-secondary">
<span className="font-medium">Zeitpunkt:</span>{' '}
{new Date(stats.lastSync.timestamp).toLocaleString('de-DE')}
</p>
<p className="text-body text-secondary">
<span className="font-medium">Status:</span>{' '}
<span className={stats.lastSync.success ? 'text-success' : 'text-error'}>
{stats.lastSync.success ? 'Erfolgreich' : 'Fehlgeschlagen'}
</span>
</p>
<p className="text-body text-secondary">
<span className="font-medium">Synchronisierte Elemente:</span>{' '}
{stats.lastSync.itemsSynced}
</p>
</div>
) : (
<p className="text-body text-tertiary">
Noch keine Synchronisation durchgeführt
</p>
)}
</div>
<div className="card">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-4">
Systemstatus
</h2>
<div className="space-y-2">
<p className="text-body text-secondary">
<span className="font-medium">Backend:</span>{' '}
<span className="text-success">Online</span>
</p>
<p className="text-body text-secondary">
<span className="font-medium">Datenbank:</span>{' '}
<span className="text-success">Verbunden</span>
</p>
<p className="text-body text-secondary">
<span className="font-medium">Version:</span> 1.0.0
</p>
</div>
</div>
</div>
<SyncStatus />
</div>
)
}

Datei anzeigen

@ -0,0 +1,157 @@
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import { MailIcon, ToggleLeftIcon, ToggleRightIcon } from '../components/icons'
interface SystemSettings {
[key: string]: {
value: boolean | string
description?: string
}
}
export default function EmailSettings() {
const [settings, setSettings] = useState<SystemSettings>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
setLoading(true)
const response = await api.get('/admin/settings')
setSettings(response.data.data)
} catch (err: any) {
console.error('Failed to fetch settings:', err)
setError('Einstellungen konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const handleToggleSetting = async (key: string, currentValue: boolean) => {
try {
setSaving(true)
setError('')
setSuccess('')
await api.put(`/admin/settings/${key}`, {
value: !currentValue
})
// Update local state
setSettings(prev => ({
...prev,
[key]: {
...prev[key],
value: !currentValue
}
}))
setSuccess('Einstellung erfolgreich gespeichert')
setTimeout(() => setSuccess(''), 3000)
} catch (err: any) {
console.error('Failed to update setting:', err)
setError('Einstellung konnte nicht gespeichert werden')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-secondary">Lade Einstellungen...</div>
</div>
)
}
return (
<div>
<div className="mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">
E-Mail-Einstellungen
</h1>
<p className="text-body text-secondary">
Konfigurieren Sie die automatischen E-Mail-Benachrichtigungen des Systems
</p>
</div>
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
{success && (
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
</div>
)}
<div className="card">
<div className="flex items-center mb-6">
<MailIcon className="w-6 h-6 text-primary-blue mr-3" />
<h2 className="text-title-card font-poppins font-semibold text-primary">
Benachrichtigungseinstellungen
</h2>
</div>
<div className="space-y-6">
{Object.entries(settings).map(([key, setting]) => (
<div key={key} className="flex items-center justify-between p-4 border border-border-light rounded-input hover:bg-bg-hover transition-colors">
<div className="flex-1">
<h3 className="text-body font-medium text-primary mb-1">
{key === 'email_notifications_enabled' ? 'Passwort-E-Mails versenden' : key}
</h3>
<p className="text-small text-secondary">
{setting.description || 'Automatische E-Mail-Benachrichtigungen für neue Benutzerpasswörter'}
</p>
</div>
<div className="ml-4">
<button
onClick={() => handleToggleSetting(key, setting.value as boolean)}
disabled={saving}
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-colors ${
setting.value
? 'bg-primary-blue text-white'
: 'bg-gray-200 text-gray-600'
} ${saving ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}`}
>
{setting.value ? (
<>
<ToggleRightIcon className="w-5 h-5" />
<span className="text-sm font-medium">Ein</span>
</>
) : (
<>
<ToggleLeftIcon className="w-5 h-5" />
<span className="text-sm font-medium">Aus</span>
</>
)}
</button>
</div>
</div>
))}
</div>
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-input">
<h3 className="text-body font-semibold text-primary mb-2">
💡 Hinweise zur E-Mail-Konfiguration
</h3>
<ul className="text-small text-secondary space-y-1">
<li> E-Mail-Versand erfordert die Konfiguration von SMTP-Einstellungen in den Umgebungsvariablen</li>
<li> Umgebungsvariablen: EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM</li>
<li> Die Standardeinstellung ist "Aus" aus Datenschutzgründen</li>
<li> Passwörter werden nur einmal per E-Mail versendet und können nicht erneut abgerufen werden</li>
</ul>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,251 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import type { UserRole } from '@skillmate/shared'
interface EmployeeFormData {
firstName: string
lastName: string
email: string
department: string
userRole: 'admin' | 'superuser' | 'user'
createUser: boolean
}
export default function EmployeeForm() {
const navigate = useNavigate()
const { id } = useParams()
const isEdit = !!id
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { register, handleSubmit, formState: { errors }, reset } = useForm<EmployeeFormData>()
useEffect(() => {
if (isEdit) {
fetchEmployee()
}
}, [id])
const fetchEmployee = async () => {
try {
const response = await api.get(`/employees/${id}`)
const employee = response.data.data
reset({
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
department: employee.department,
userRole: 'user',
createUser: false
})
} catch (error) {
console.error('Failed to fetch employee:', error)
setError('Mitarbeiter konnte nicht geladen werden')
}
}
const onSubmit = async (data: EmployeeFormData) => {
try {
setLoading(true)
setError('')
const payload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
department: data.department,
// Optional fields - Backend will handle defaults
userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser
}
if (isEdit) {
await api.put(`/employees/${id}`, payload)
} else {
await api.post('/employees', payload)
}
navigate('/employees')
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Speichern fehlgeschlagen')
} finally {
setLoading(false)
}
}
const getRoleDescription = (role: UserRole): string => {
const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
superuser: 'Kann Mitarbeiter anlegen und verwalten, aber kein Zugriff auf Admin Panel',
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeiter durchsuchen'
}
return descriptions[role]
}
return (
<div>
<div className="mb-8">
<button
onClick={() => navigate('/employees')}
className="text-primary-blue hover:text-primary-blue-hover mb-4"
>
Zurück zur Übersicht
</button>
<h1 className="text-title-lg font-poppins font-bold text-primary">
{isEdit ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'}
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Mitarbeiterdaten
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Vorname
</label>
<input
{...register('firstName', { required: 'Vorname ist erforderlich' })}
className="input-field w-full"
placeholder="Max"
/>
{errors.firstName && (
<p className="text-error text-sm mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nachname
</label>
<input
{...register('lastName', { required: 'Nachname ist erforderlich' })}
className="input-field w-full"
placeholder="Mustermann"
/>
{errors.lastName && (
<p className="text-error text-sm mt-1">{errors.lastName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
E-Mail
</label>
<input
{...register('email', {
required: 'E-Mail ist erforderlich',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Ungültige E-Mail-Adresse'
}
})}
type="email"
className="input-field w-full"
placeholder="max.mustermann@firma.de"
/>
{errors.email && (
<p className="text-error text-sm mt-1">{errors.email.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Abteilung
</label>
<input
{...register('department', { required: 'Abteilung ist erforderlich' })}
className="input-field w-full"
placeholder="IT, Personal, Buchhaltung, etc."
/>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Benutzerkonto erstellen (optional)
</h2>
<div className="space-y-4">
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
{...register('createUser')}
type="checkbox"
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
/>
<span className="text-body text-secondary">
Benutzerkonto für diesen Mitarbeiter erstellen
</span>
</label>
<p className="text-small text-tertiary mt-1">
Wenn aktiviert, wird automatisch ein Benutzerkonto mit der E-Mail-Adresse erstellt
</p>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nutzerrolle
</label>
<div className="space-y-3">
{(['admin', 'superuser', 'user'] as const).map((role) => (
<label key={role} className="flex items-start space-x-3 cursor-pointer group">
<input
{...register('userRole', { required: false })}
type="radio"
value={role}
className="w-5 h-5 mt-0.5 text-primary-blue focus:ring-primary-blue"
/>
<div className="flex-1">
<div className="font-medium text-secondary capitalize">
{role === 'admin' ? 'Administrator' :
role === 'superuser' ? 'Superuser' : 'Benutzer'}
</div>
<div className="text-small text-tertiary mt-1" title={getRoleDescription(role)}>
{getRoleDescription(role)}
</div>
</div>
</label>
))}
</div>
</div>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => navigate('/employees')}
className="btn-secondary"
>
Abbrechen
</button>
<button
type="submit"
disabled={loading}
className="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Speichern...' : (isEdit ? 'Änderungen speichern' : 'Mitarbeiter anlegen')}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,398 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useState, useEffect } from 'react'
import { api } from '../services/api'
import type { UserRole } from '@skillmate/shared'
interface EmployeeFormData {
firstName: string
lastName: string
employeeNumber: string
email: string
phone: string
position: string
department: string
office?: string
mobile?: string
availability: 'available' | 'busy' | 'away' | 'unavailable'
userRole: 'admin' | 'superuser' | 'user'
createUser: boolean
}
export default function EmployeeFormComplete() {
const navigate = useNavigate()
const { id } = useParams()
const isEdit = !!id
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [createdUser, setCreatedUser] = useState<{password?: string}>({})
const { register, handleSubmit, formState: { errors }, reset, watch } = useForm<EmployeeFormData>({
defaultValues: {
availability: 'available',
userRole: 'user',
createUser: false
}
})
const watchCreateUser = watch('createUser')
useEffect(() => {
if (isEdit) {
fetchEmployee()
}
}, [id])
const fetchEmployee = async () => {
try {
const response = await api.get(`/employees/${id}`)
const employee = response.data.data
reset({
firstName: employee.firstName,
lastName: employee.lastName,
employeeNumber: employee.employeeNumber,
email: employee.email,
phone: employee.phone,
position: employee.position,
department: employee.department,
office: employee.office,
mobile: employee.mobile,
availability: employee.availability,
userRole: 'user',
createUser: false
})
} catch (error) {
console.error('Failed to fetch employee:', error)
setError('Mitarbeiter konnte nicht geladen werden')
}
}
const onSubmit = async (data: EmployeeFormData) => {
try {
setLoading(true)
setError('')
setSuccess('')
const payload = {
firstName: data.firstName,
lastName: data.lastName,
employeeNumber: data.employeeNumber || `EMP${Date.now()}`,
email: data.email,
phone: data.phone,
position: data.position,
department: data.department,
office: data.office || null,
mobile: data.mobile || null,
availability: data.availability,
skills: [],
languages: [],
specializations: [],
userRole: data.createUser ? data.userRole : undefined,
createUser: data.createUser
}
let response
if (isEdit) {
response = await api.put(`/employees/${id}`, payload)
setSuccess('Mitarbeiter erfolgreich aktualisiert!')
} else {
response = await api.post('/employees', payload)
if (response.data.data.temporaryPassword) {
setCreatedUser({ password: response.data.data.temporaryPassword })
setSuccess(`Mitarbeiter erfolgreich erstellt! Temporäres Passwort: ${response.data.data.temporaryPassword}`)
} else {
setSuccess('Mitarbeiter erfolgreich erstellt!')
}
}
setTimeout(() => {
navigate('/employees')
}, 3000)
} catch (err: any) {
console.error('Error:', err.response?.data)
const errorMessage = err.response?.data?.error?.message || 'Speichern fehlgeschlagen'
const errorDetails = err.response?.data?.error?.details
if (errorDetails && Array.isArray(errorDetails)) {
const detailMessages = errorDetails.map((d: any) => d.msg || d.message).join(', ')
setError(`${errorMessage}: ${detailMessages}`)
} else {
setError(errorMessage)
}
} finally {
setLoading(false)
}
}
const getRoleDescription = (role: UserRole): string => {
const descriptions = {
admin: 'Vollzugriff auf alle Funktionen inklusive Admin Panel und Benutzerverwaltung',
superuser: 'Kann Mitarbeiter anlegen und verwalten, aber kein Zugriff auf Admin Panel',
user: 'Kann nur das eigene Profil bearbeiten und Mitarbeiter durchsuchen'
}
return descriptions[role]
}
return (
<div>
<div className="mb-8">
<button
onClick={() => navigate('/employees')}
className="text-primary-blue hover:text-primary-blue-hover mb-4"
>
Zurück zur Übersicht
</button>
<h1 className="text-title-lg font-poppins font-bold text-primary">
{isEdit ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'}
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="max-w-4xl">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
{success && (
<div className="bg-green-50 text-green-800 px-4 py-3 rounded-input text-sm mb-6">
{success}
{createdUser.password && (
<div className="mt-2 p-2 bg-white rounded border border-green-300">
<strong>Bitte notieren Sie das temporäre Passwort:</strong>
<br />
<code className="text-lg">{createdUser.password}</code>
</div>
)}
</div>
)}
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Persönliche Daten
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Vorname *
</label>
<input
{...register('firstName', { required: 'Vorname ist erforderlich' })}
className="input-field w-full"
placeholder="Max"
/>
{errors.firstName && (
<p className="text-error text-sm mt-1">{errors.firstName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Nachname *
</label>
<input
{...register('lastName', { required: 'Nachname ist erforderlich' })}
className="input-field w-full"
placeholder="Mustermann"
/>
{errors.lastName && (
<p className="text-error text-sm mt-1">{errors.lastName.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Mitarbeiternummer *
</label>
<input
{...register('employeeNumber', { required: 'Mitarbeiternummer ist erforderlich' })}
className="input-field w-full"
placeholder="EMP001"
/>
{errors.employeeNumber && (
<p className="text-error text-sm mt-1">{errors.employeeNumber.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
E-Mail *
</label>
<input
{...register('email', {
required: 'E-Mail ist erforderlich',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Ungültige E-Mail-Adresse'
}
})}
type="email"
className="input-field w-full"
placeholder="max.mustermann@firma.de"
/>
{errors.email && (
<p className="text-error text-sm mt-1">{errors.email.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Kontaktdaten & Position
</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-body font-medium text-secondary mb-2">
Telefon *
</label>
<input
{...register('phone', { required: 'Telefonnummer ist erforderlich' })}
className="input-field w-full"
placeholder="+49 123 456789"
/>
{errors.phone && (
<p className="text-error text-sm mt-1">{errors.phone.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Mobil (optional)
</label>
<input
{...register('mobile')}
className="input-field w-full"
placeholder="+49 170 123456"
/>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Position *
</label>
<input
{...register('position', { required: 'Position ist erforderlich' })}
className="input-field w-full"
placeholder="Software Developer"
/>
{errors.position && (
<p className="text-error text-sm mt-1">{errors.position.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Abteilung *
</label>
<input
{...register('department', { required: 'Abteilung ist erforderlich' })}
className="input-field w-full"
placeholder="IT, Personal, Buchhaltung, etc."
/>
{errors.department && (
<p className="text-error text-sm mt-1">{errors.department.message}</p>
)}
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Büro (optional)
</label>
<input
{...register('office')}
className="input-field w-full"
placeholder="Gebäude A, Raum 123"
/>
</div>
<div>
<label className="block text-body font-medium text-secondary mb-2">
Verfügbarkeit *
</label>
<select
{...register('availability', { required: 'Verfügbarkeit ist erforderlich' })}
className="input-field w-full"
>
<option value="available">Verfügbar</option>
<option value="busy">Beschäftigt</option>
<option value="away">Abwesend</option>
<option value="unavailable">Nicht verfügbar</option>
</select>
{errors.availability && (
<p className="text-error text-sm mt-1">{errors.availability.message}</p>
)}
</div>
</div>
</div>
<div className="card mb-6">
<h2 className="text-title-card font-poppins font-semibold text-primary mb-6">
Benutzerkonto erstellen (optional)
</h2>
<div className="space-y-4">
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
{...register('createUser')}
type="checkbox"
className="w-5 h-5 rounded border-border-input text-primary-blue focus:ring-primary-blue"
/>
<span className="text-body font-medium text-secondary">
Benutzerkonto für diesen Mitarbeiter erstellen
</span>
</label>
<p className="text-sm text-secondary-light mt-1 ml-7">
Ein temporäres Passwort wird generiert und muss beim ersten Login geändert werden.
</p>
</div>
{watchCreateUser && (
<div>
<label className="block text-body font-medium text-secondary mb-2">
Benutzerrolle
</label>
<select
{...register('userRole')}
className="input-field w-full"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<p className="text-sm text-secondary-light mt-1">
{getRoleDescription(watch('userRole'))}
</p>
</div>
)}
</div>
</div>
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => navigate('/employees')}
className="btn-secondary"
disabled={loading}
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary"
disabled={loading}
>
{loading ? 'Speichert...' : (isEdit ? 'Aktualisieren' : 'Erstellen')}
</button>
</div>
</form>
</div>
)
}

Datei anzeigen

@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import type { Employee } from '@skillmate/shared'
import { api } from '../services/api'
export default function EmployeeManagement() {
const navigate = useNavigate()
const [employees, setEmployees] = useState<Employee[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => {
fetchEmployees()
}, [])
const fetchEmployees = async () => {
try {
const response = await api.get('/employees')
setEmployees(response.data.data)
} catch (error) {
console.error('Failed to fetch employees:', error)
} finally {
setLoading(false)
}
}
const filteredEmployees = employees.filter(emp =>
`${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.employeeNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
emp.department.toLowerCase().includes(searchTerm.toLowerCase())
)
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-tertiary">Mitarbeiter werden geladen...</p>
</div>
)
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary">
Mitarbeiterverwaltung
</h1>
<button
onClick={() => navigate('/employees/new')}
className="btn-primary"
>
Neuer Mitarbeiter
</button>
</div>
<div className="card mb-6">
<input
type="text"
placeholder="Suche nach Name, Personalnummer oder Abteilung..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="input-field w-full"
/>
</div>
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="table-header">
<th className="px-4 py-3 text-left">Personalnr.</th>
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-left">Position</th>
<th className="px-4 py-3 text-left">Abteilung</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-left">Aktionen</th>
</tr>
</thead>
<tbody>
{filteredEmployees.map((employee) => (
<tr key={employee.id} className="table-row">
<td className="px-4 py-3">{employee.employeeNumber}</td>
<td className="px-4 py-3">
{employee.firstName} {employee.lastName}
</td>
<td className="px-4 py-3">{employee.position}</td>
<td className="px-4 py-3">{employee.department}</td>
<td className="px-4 py-3">
<span className={`badge ${
employee.availability === 'available' ? 'badge-success' : 'badge-warning'
}`}>
{employee.availability}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(`/employees/${employee.id}/edit`)}
className="text-primary-blue hover:text-primary-blue-hover mr-3"
>
Bearbeiten
</button>
<button className="text-error hover:text-error/80">
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredEmployees.length === 0 && (
<div className="text-center py-8">
<p className="text-tertiary">Keine Mitarbeiter gefunden</p>
</div>
)}
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,99 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { useAuthStore } from '../stores/authStore'
import { api } from '../services/api'
import type { LoginRequest } from '@skillmate/shared'
export default function Login() {
const navigate = useNavigate()
const { login } = useAuthStore()
const [error, setError] = useState('')
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginRequest>()
const onSubmit = async (data: LoginRequest) => {
try {
setError('')
const response = await api.post('/auth/login', data)
if (response.data.success) {
const { user, token } = response.data.data
login(user, token.accessToken)
navigate('/')
}
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Anmeldung fehlgeschlagen')
}
}
return (
<div className="min-h-screen bg-bg-main flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="card">
<div className="text-center mb-8">
<h1 className="text-title-lg font-poppins font-bold text-primary">
SkillMate Admin
</h1>
<p className="text-body text-tertiary mt-2">
Melden Sie sich an, um fortzufahren
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-body font-medium text-secondary mb-2">
Benutzername
</label>
<input
{...register('username', { required: 'Benutzername ist erforderlich' })}
type="text"
id="username"
className="input-field w-full"
placeholder="admin"
/>
{errors.username && (
<p className="text-error text-sm mt-1">{errors.username.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-body font-medium text-secondary mb-2">
Passwort
</label>
<input
{...register('password', { required: 'Passwort ist erforderlich' })}
type="password"
id="password"
className="input-field w-full"
placeholder="••••••••"
/>
{errors.password && (
<p className="text-error text-sm mt-1">{errors.password.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Anmeldung läuft...' : 'Anmelden'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-small text-tertiary">
Standard-Login: admin / admin123
</p>
</div>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,283 @@
import { useEffect, useState } from 'react'
import { api } from '../services/api'
type Skill = { id: string; name: string; description?: string | null }
type Subcategory = { id: string; name: string; skills: Skill[] }
type Category = { id: string; name: string; subcategories: Subcategory[] }
export default function SkillManagement() {
const [hierarchy, setHierarchy] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [openMain, setOpenMain] = useState<Record<string, boolean>>({})
const [openSub, setOpenSub] = useState<Record<string, boolean>>({})
// Inline create/edit states
const [addingCat, setAddingCat] = useState(false)
const [newCat, setNewCat] = useState({ name: '' })
const [addingSub, setAddingSub] = useState<Record<string, boolean>>({})
const [newSub, setNewSub] = useState<Record<string, { name: string }>>({})
const [addingSkill, setAddingSkill] = useState<Record<string, boolean>>({}) // key: catId.subId
const [newSkill, setNewSkill] = useState<Record<string, { name: string; description?: string }>>({})
const [editingCat, setEditingCat] = useState<string | null>(null)
const [editCatName, setEditCatName] = useState<string>('')
const [editingSub, setEditingSub] = useState<string | null>(null) // key: catId.subId
const [editSubName, setEditSubName] = useState<string>('')
const [editingSkill, setEditingSkill] = useState<string | null>(null) // skill id
const [editSkillData, setEditSkillData] = useState<{ name: string; description?: string }>({ name: '' })
useEffect(() => { fetchHierarchy() }, [])
async function fetchHierarchy() {
try {
setLoading(true)
const res = await api.get('/skills/hierarchy')
setHierarchy(res.data.data || [])
} catch (e) {
setError('Skills konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
function slugify(input: string) {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.slice(0, 64)
}
function keyFor(catId: string, subId: string) { return `${catId}.${subId}` }
function toggleMain(catId: string) { setOpenMain(prev => ({ ...prev, [catId]: !prev[catId] })) }
function toggleSub(catId: string, subId: string) { const k = keyFor(catId, subId); setOpenSub(prev => ({ ...prev, [k]: !prev[k] })) }
// Category actions
async function createCategory() {
if (!newCat.name) return
try {
const id = slugify(newCat.name)
await api.post('/skills/categories', { id, name: newCat.name })
setNewCat({ name: '' })
setAddingCat(false)
fetchHierarchy()
} catch { setError('Kategorie konnte nicht erstellt werden') }
}
function startEditCategory(cat: Category) { setEditingCat(cat.id); setEditCatName(cat.name) }
async function saveCategory(catId: string) {
try {
await api.put(`/skills/categories/${catId}`, { name: editCatName })
setEditingCat(null)
fetchHierarchy()
} catch { setError('Kategorie konnte nicht aktualisiert werden') }
}
async function deleteCategory(catId: string) {
if (!confirm('Kategorie und alle Inhalte löschen?')) return
try { await api.delete(`/skills/categories/${catId}`); fetchHierarchy() } catch { setError('Kategorie konnte nicht gelöscht werden') }
}
// Subcategory actions
function startAddSub(catId: string) { setAddingSub(prev => ({ ...prev, [catId]: true })); setNewSub(prev => ({ ...prev, [catId]: { name: '' } })) }
async function createSub(catId: string) {
const data = newSub[catId]
if (!data || !data.name) return
try {
const id = slugify(data.name)
await api.post(`/skills/categories/${catId}/subcategories`, { id, name: data.name })
setAddingSub(prev => ({ ...prev, [catId]: false }))
fetchHierarchy()
} catch { setError('Unterkategorie konnte nicht erstellt werden') }
}
function startEditSub(catId: string, sub: Subcategory) { setEditingSub(keyFor(catId, sub.id)); setEditSubName(sub.name) }
async function saveSub(catId: string, subId: string) {
try {
await api.put(`/skills/categories/${catId}/subcategories/${subId}`, { name: editSubName })
setEditingSub(null)
fetchHierarchy()
} catch { setError('Unterkategorie konnte nicht aktualisiert werden') }
}
async function deleteSub(catId: string, subId: string) {
if (!confirm('Unterkategorie und enthaltene Skills löschen?')) return
try { await api.delete(`/skills/categories/${catId}/subcategories/${subId}`); fetchHierarchy() } catch { setError('Unterkategorie konnte nicht gelöscht werden') }
}
// Skill actions
function startAddSkill(catId: string, subId: string) {
const k = keyFor(catId, subId)
setAddingSkill(prev => ({ ...prev, [k]: true }))
setNewSkill(prev => ({ ...prev, [k]: { name: '', description: '' } }))
}
async function createSkill(catId: string, subId: string) {
const k = keyFor(catId, subId)
const data = newSkill[k]
if (!data || !data.name) return
try {
const id = slugify(data.name)
await api.post('/skills', { id, name: data.name, category: `${catId}.${subId}`, description: data.description || null })
setAddingSkill(prev => ({ ...prev, [k]: false }))
fetchHierarchy()
} catch { setError('Skill konnte nicht erstellt werden') }
}
function startEditSkill(skill: Skill) { setEditingSkill(skill.id); setEditSkillData({ name: skill.name, description: skill.description || '' }) }
async function saveSkill(id: string) {
try {
await api.put(`/skills/${id}`, { name: editSkillData.name, description: editSkillData.description ?? null })
setEditingSkill(null)
fetchHierarchy()
} catch { setError('Skill konnte nicht aktualisiert werden') }
}
async function deleteSkill(id: string) {
if (!confirm('Skill löschen?')) return
try { await api.delete(`/skills/${id}`); fetchHierarchy() } catch { setError('Skill konnte nicht gelöscht werden') }
}
return (
<div>
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">Skill-Verwaltung</h1>
<p className="text-body text-secondary">Kategorien und Unterkategorien wie im Frontend (ohne Niveaus)</p>
</div>
<div>
{!addingCat ? (
<button className="btn-primary" onClick={() => setAddingCat(true)}>+ Kategorie</button>
) : (
<div className="flex items-center gap-2">
<input className="input-field" placeholder="Name der Kategorie (z. B. Technische Fähigkeiten)" value={newCat.name} onChange={(e) => setNewCat({ ...newCat, name: e.target.value })} />
<button className="btn-primary" onClick={createCategory}>Speichern</button>
<button className="btn-secondary" onClick={() => { setAddingCat(false); setNewCat({ name: '' }) }}>Abbrechen</button>
</div>
)}
</div>
</div>
{error && <div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">{error}</div>}
{loading ? (
<div className="text-secondary">Lade Skills...</div>
) : (
hierarchy.map((cat) => (
<div key={cat.id} className="card mb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button className="btn-secondary h-8 px-3" onClick={() => toggleMain(cat.id)}>{openMain[cat.id] ? '' : '+'}</button>
{editingCat === cat.id ? (
<div className="flex items-center gap-2">
<input className="input-field" value={editCatName} onChange={(e) => setEditCatName(e.target.value)} />
<button className="btn-primary h-8 px-3" onClick={() => saveCategory(cat.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingCat(null)}></button>
</div>
) : (
<h3 className="text-title-card font-semibold text-primary">{cat.name}</h3>
)}
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary h-8 px-3" onClick={() => startEditCategory(cat)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => startAddSub(cat.id)}>+ Unterkategorie</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteCategory(cat.id)}>Löschen</button>
</div>
</div>
{addingSub[cat.id] && (
<div className="mt-3 flex items-center gap-2">
<input className="input-field" placeholder="Name der Unterkategorie (z. B. Programmierung)" value={newSub[cat.id]?.name || ''} onChange={(e) => setNewSub(prev => ({ ...prev, [cat.id]: { ...(prev[cat.id] || { name: '' }), name: e.target.value } }))} />
<button className="btn-primary h-8 px-3" onClick={() => createSub(cat.id)}>Speichern</button>
<button className="btn-secondary h-8 px-3" onClick={() => setAddingSub(prev => ({ ...prev, [cat.id]: false }))}>Abbrechen</button>
</div>
)}
{openMain[cat.id] && (
<div className="mt-3">
{cat.subcategories.map((sub) => (
<div key={`${cat.id}.${sub.id}`} className="mb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button className="btn-secondary h-8 px-3" onClick={() => toggleSub(cat.id, sub.id)}>{openSub[keyFor(cat.id, sub.id)] ? '' : '+'}</button>
{editingSub === keyFor(cat.id, sub.id) ? (
<div className="flex items-center gap-2">
<input className="input-field" value={editSubName} onChange={(e) => setEditSubName(e.target.value)} />
<button className="btn-primary h-8 px-3" onClick={() => saveSub(cat.id, sub.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingSub(null)}></button>
</div>
) : (
<div className="font-medium text-secondary">{sub.name}</div>
)}
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary h-8 px-3" onClick={() => startEditSub(cat.id, sub)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => startAddSkill(cat.id, sub.id)}>+ Skill</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteSub(cat.id, sub.id)}>Löschen</button>
</div>
</div>
{addingSkill[keyFor(cat.id, sub.id)] && (
<div className="mt-2 flex items-center gap-2">
<input className="input-field" placeholder="Skill-Name (z. B. Python)" value={newSkill[keyFor(cat.id, sub.id)]?.name || ''} onChange={(e) => setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), name: e.target.value } }))} />
<input className="input-field" placeholder="Beschreibung (optional)" value={newSkill[keyFor(cat.id, sub.id)]?.description || ''} onChange={(e) => setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), description: e.target.value } }))} />
<button className="btn-primary h-8 px-3" onClick={() => createSkill(cat.id, sub.id)}>Speichern</button>
<button className="btn-secondary h-8 px-3" onClick={() => setAddingSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: false }))}>Abbrechen</button>
</div>
)}
{openSub[keyFor(cat.id, sub.id)] && (
<div className="divide-y divide-border-default mt-2">
{sub.skills.map((sk) => (
<div key={sk.id} className="py-2 flex items-center justify-between">
{editingSkill === sk.id ? (
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-3 pr-4">
<input className="input-field" value={editSkillData.name} onChange={(e) => setEditSkillData(prev => ({ ...prev, name: e.target.value }))} />
<input className="input-field" placeholder="Beschreibung (optional)" value={editSkillData.description || ''} onChange={(e) => setEditSkillData(prev => ({ ...prev, description: e.target.value }))} />
</div>
) : (
<div className="flex-1 pr-4">
<div className="font-medium text-primary">{sk.name}</div>
{sk.description && <div className="text-small text-tertiary">{sk.description}</div>}
</div>
)}
<div className="flex items-center gap-2">
{editingSkill === sk.id ? (
<>
<button className="btn-primary h-8 px-3" onClick={() => saveSkill(sk.id)}></button>
<button className="btn-secondary h-8 px-3" onClick={() => setEditingSkill(null)}></button>
</>
) : (
<>
<input type="checkbox" className="w-4 h-4 mr-2" checked readOnly aria-label="Aktiv" />
<button className="btn-secondary h-8 px-3" onClick={() => startEditSkill(sk)}>Bearbeiten</button>
<button className="btn-secondary h-8 px-3" onClick={() => deleteSkill(sk.id)}>Löschen</button>
</>
)}
</div>
</div>
))}
{sub.skills.length === 0 && (
<div className="py-2 text-small text-tertiary">Keine Skills in dieser Unterkategorie</div>
)}
</div>
)}
</div>
))}
{cat.subcategories.length === 0 && (
<div className="text-small text-tertiary mt-2">Keine Unterkategorien</div>
)}
</div>
)}
</div>
))
)}
</div>
)
}

Datei anzeigen

@ -0,0 +1,517 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, Edit2, Save, X, RefreshCw, Monitor, Globe } from 'lucide-react'
import { networkApi, NetworkNode, SyncSettings as SyncSettingsType } from '../services/networkApi'
export default function SyncSettings() {
const [nodes, setNodes] = useState<NetworkNode[]>([])
const [editingNode, setEditingNode] = useState<string | null>(null)
const [newNode, setNewNode] = useState<Partial<NetworkNode>>({
name: '',
location: '',
ipAddress: '',
port: 3005,
apiKey: '',
type: 'local'
})
const [showNewNodeForm, setShowNewNodeForm] = useState(false)
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle')
const [syncSettings, setSyncSettings] = useState<SyncSettingsType>({
autoSyncInterval: 'disabled',
conflictResolution: 'admin',
syncEmployees: true,
syncSkills: true,
syncUsers: true,
syncSettings: false,
bandwidthLimit: null
})
useEffect(() => {
// Load nodes and settings from backend
fetchNodes()
fetchSyncSettings()
}, [])
const fetchNodes = async () => {
try {
const data = await networkApi.getNodes()
setNodes(data)
} catch (error) {
console.error('Failed to fetch nodes:', error)
}
}
const fetchSyncSettings = async () => {
try {
const data = await networkApi.getSyncSettings()
setSyncSettings(data)
} catch (error) {
console.error('Failed to fetch sync settings:', error)
}
}
const handleAddNode = async () => {
try {
const result = await networkApi.createNode({
name: newNode.name || '',
location: newNode.location || '',
ipAddress: newNode.ipAddress || '',
port: newNode.port || 3005,
type: newNode.type as 'admin' | 'local'
})
// Refresh nodes list
await fetchNodes()
setNewNode({
name: '',
location: '',
ipAddress: '',
port: 3005,
apiKey: '',
type: 'local'
})
setShowNewNodeForm(false)
// Show API key to user
alert(`Node created successfully!\n\nAPI Key: ${result.apiKey}\n\nPlease save this key securely. It won't be shown again.`)
} catch (error) {
console.error('Failed to add node:', error)
alert('Failed to add node')
}
}
const handleUpdateNode = async (nodeId: string, updates: Partial<NetworkNode>) => {
try {
await networkApi.updateNode(nodeId, updates)
setNodes(nodes.map(node =>
node.id === nodeId ? { ...node, ...updates } : node
))
setEditingNode(null)
} catch (error) {
console.error('Failed to update node:', error)
alert('Failed to update node')
}
}
const handleDeleteNode = async (nodeId: string) => {
if (!confirm('Sind Sie sicher, dass Sie diesen Knoten löschen möchten?')) {
return
}
try {
await networkApi.deleteNode(nodeId)
setNodes(nodes.filter(node => node.id !== nodeId))
} catch (error) {
console.error('Failed to delete node:', error)
alert('Failed to delete node')
}
}
const handleSyncAll = async () => {
setSyncStatus('syncing')
try {
await networkApi.triggerSync()
setSyncStatus('success')
setTimeout(() => setSyncStatus('idle'), 3000)
// Refresh nodes to update sync status
fetchNodes()
} catch (error) {
setSyncStatus('error')
setTimeout(() => setSyncStatus('idle'), 3000)
}
}
const handleSaveSyncSettings = async () => {
try {
await networkApi.updateSyncSettings(syncSettings)
alert('Sync settings saved successfully')
} catch (error) {
console.error('Failed to save sync settings:', error)
alert('Failed to save sync settings')
}
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Netzwerk & Synchronisation
</h1>
<div className="flex space-x-4">
<button
onClick={handleSyncAll}
disabled={syncStatus === 'syncing'}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-colors ${
syncStatus === 'syncing'
? 'bg-gray-300 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
<RefreshCw className={`w-5 h-5 ${syncStatus === 'syncing' ? 'animate-spin' : ''}`} />
<span>
{syncStatus === 'syncing' ? 'Synchronisiere...' :
syncStatus === 'success' ? 'Erfolgreich!' :
syncStatus === 'error' ? 'Fehler!' : 'Alle synchronisieren'}
</span>
</button>
<button
onClick={() => setShowNewNodeForm(true)}
className="flex items-center space-x-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"
>
<Plus className="w-5 h-5" />
<span>Knoten hinzufügen</span>
</button>
</div>
</div>
{/* Network Overview */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<Globe className="w-8 h-8 text-blue-600" />
<span className="text-2xl font-bold text-gray-900">{nodes.length}</span>
</div>
<h3 className="text-lg font-semibold text-gray-700">Gesamte Knoten</h3>
<p className="text-sm text-gray-500">Im Netzwerk</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<Monitor className="w-8 h-8 text-green-600" />
<span className="text-2xl font-bold text-gray-900">
{nodes.filter(n => n.isOnline).length}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-700">Online</h3>
<p className="text-sm text-gray-500">Aktive Verbindungen</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<RefreshCw className="w-8 h-8 text-blue-600" />
<span className="text-sm font-medium text-gray-900">
{nodes[0]?.lastSync ? new Date(nodes[0].lastSync).toLocaleString('de-DE') : 'Nie'}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-700">Letzte Sync</h3>
<p className="text-sm text-gray-500">Admin-Knoten</p>
</div>
</div>
{/* New Node Form */}
{showNewNodeForm && (
<div className="bg-white rounded-lg shadow mb-6 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Neuen Knoten hinzufügen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
value={newNode.name || ''}
onChange={(e) => setNewNode({ ...newNode, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. Außenstelle Frankfurt"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Standort
</label>
<input
type="text"
value={newNode.location || ''}
onChange={(e) => setNewNode({ ...newNode, location: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. Frankfurt, Deutschland"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
IP-Adresse
</label>
<input
type="text"
value={newNode.ipAddress || ''}
onChange={(e) => setNewNode({ ...newNode, ipAddress: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="z.B. 192.168.1.100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Port
</label>
<input
type="number"
value={newNode.port || 3005}
onChange={(e) => setNewNode({ ...newNode, port: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Typ
</label>
<select
value={newNode.type || 'local'}
onChange={(e) => setNewNode({ ...newNode, type: e.target.value as 'admin' | 'local' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="local">Lokaler Knoten</option>
<option value="admin">Admin Knoten</option>
</select>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => setShowNewNodeForm(false)}
className="px-4 py-2 text-gray-700 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleAddNode}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Hinzufügen
</button>
</div>
</div>
)}
{/* Nodes List */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
Netzwerkknoten
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Standort
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
IP-Adresse
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Typ
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Letzte Sync
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{nodes.map((node) => (
<tr key={node.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`w-3 h-3 rounded-full ${
node.isOnline ? 'bg-green-500' : 'bg-red-500'
}`} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingNode === node.id ? (
<input
type="text"
value={node.name}
onChange={(e) => handleUpdateNode(node.id, { name: e.target.value })}
className="px-2 py-1 border border-gray-300 rounded"
/>
) : (
<span className="text-sm font-medium text-gray-900">{node.name}</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{node.location}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{node.ipAddress}:{node.port}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
node.type === 'admin'
? 'bg-purple-100 text-purple-800'
: 'bg-blue-100 text-blue-800'
}`}>
{node.type === 'admin' ? 'Admin' : 'Lokal'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{node.lastSync ? new Date(node.lastSync).toLocaleString('de-DE') : 'Nie'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{editingNode === node.id ? (
<>
<button
onClick={() => setEditingNode(null)}
className="text-green-600 hover:text-green-900"
>
<Save className="w-4 h-4" />
</button>
<button
onClick={() => setEditingNode(null)}
className="text-gray-600 hover:text-gray-900"
>
<X className="w-4 h-4" />
</button>
</>
) : (
<>
<button
onClick={() => setEditingNode(node.id)}
className="text-blue-600 hover:text-primary-900"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteNode(node.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Sync Configuration */}
<div className="mt-8 bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
Synchronisationseinstellungen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Automatische Synchronisation
</label>
<select
value={syncSettings.autoSyncInterval}
onChange={(e) => setSyncSettings({ ...syncSettings, autoSyncInterval: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="disabled">Deaktiviert</option>
<option value="5min">Alle 5 Minuten</option>
<option value="15min">Alle 15 Minuten</option>
<option value="30min">Alle 30 Minuten</option>
<option value="1hour">Jede Stunde</option>
<option value="daily">Täglich</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Konfliktauflösung
</label>
<select
value={syncSettings.conflictResolution}
onChange={(e) => setSyncSettings({ ...syncSettings, conflictResolution: e.target.value as 'admin' | 'newest' | 'manual' })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="admin">Admin hat Vorrang</option>
<option value="newest">Neueste Änderung gewinnt</option>
<option value="manual">Manuell auflösen</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Daten-Synchronisation
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncEmployees}
onChange={(e) => setSyncSettings({ ...syncSettings, syncEmployees: e.target.checked })}
/>
<span className="text-sm">Mitarbeiterdaten</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncSkills}
onChange={(e) => setSyncSettings({ ...syncSettings, syncSkills: e.target.checked })}
/>
<span className="text-sm">Skills und Qualifikationen</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncUsers}
onChange={(e) => setSyncSettings({ ...syncSettings, syncUsers: e.target.checked })}
/>
<span className="text-sm">Benutzerkonten</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
className="mr-2"
checked={syncSettings.syncSettings}
onChange={(e) => setSyncSettings({ ...syncSettings, syncSettings: e.target.checked })}
/>
<span className="text-sm">Systemeinstellungen</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Bandbreitenbegrenzung
</label>
<div className="flex items-center space-x-3">
<input
type="number"
placeholder="Unbegrenzt"
value={syncSettings.bandwidthLimit || ''}
onChange={(e) => setSyncSettings({ ...syncSettings, bandwidthLimit: e.target.value ? parseInt(e.target.value) : null })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<span className="text-sm text-gray-500">KB/s</span>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<button
onClick={handleSaveSyncSettings}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Einstellungen speichern
</button>
</div>
</div>
</div>
)
}

Datei anzeigen

@ -0,0 +1,658 @@
import { useState, useEffect, DragEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../services/api'
import { User, UserRole } from '@skillmate/shared'
import { TrashIcon, ShieldIcon, KeyIcon } from '../components/icons'
import { useAuthStore } from '../stores/authStore'
interface UserWithEmployee extends User {
employeeName?: string
}
export default function UserManagement() {
const navigate = useNavigate()
const { user: currentUser } = useAuthStore()
const [users, setUsers] = useState<UserWithEmployee[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [editingUser, setEditingUser] = useState<string | null>(null)
const [editRole, setEditRole] = useState<UserRole>('user')
const [resetPasswordUser, setResetPasswordUser] = useState<string | null>(null)
const [newPassword, setNewPassword] = useState('')
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
setLoading(true)
const [usersResponse, employeesResponse] = await Promise.all([
api.get('/admin/users'),
api.get('/employees')
])
const usersData = usersResponse.data.data || []
const employeesData = employeesResponse.data.data || []
// Match users with employee names
const enrichedUsers = usersData.map((user: User) => {
const employee = employeesData.find((emp: any) => emp.id === user.employeeId)
return {
...user,
employeeName: employee ? `${employee.firstName} ${employee.lastName}` : undefined
}
})
setUsers(enrichedUsers)
setEmployees(employeesData)
} catch (err: any) {
console.error('Failed to fetch users:', err)
setError('Benutzer konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const [employees, setEmployees] = useState<any[]>([])
// Import state
type ImportRow = { firstName: string; lastName: string; email: string; department: string }
const [dragActive, setDragActive] = useState(false)
const [parsedRows, setParsedRows] = useState<ImportRow[]>([])
const [parseError, setParseError] = useState('')
const [isImporting, setIsImporting] = useState(false)
const [importResults, setImportResults] = useState<{
index: number
status: 'created' | 'error'
employeeId?: string
userId?: string
username?: string
email?: string
temporaryPassword?: string
error?: string
}[]>([])
// Store temporary passwords per user to show + email
const [tempPasswords, setTempPasswords] = useState<Record<string, { password: string }>>({})
// removed legacy creation helpers for employees without user accounts
const handleRoleChange = async (userId: string) => {
try {
await api.put(`/admin/users/${userId}/role`, { role: editRole })
await fetchUsers()
setEditingUser(null)
} catch (err: any) {
setError('Rolle konnte nicht geändert werden')
}
}
const handleToggleActive = async (userId: string, isActive: boolean) => {
try {
await api.put(`/admin/users/${userId}/status`, { isActive: !isActive })
await fetchUsers()
} catch (err: any) {
setError('Status konnte nicht geändert werden')
}
}
const handlePasswordReset = async (userId: string) => {
try {
const response = await api.post(`/admin/users/${userId}/reset-password`, {
newPassword: newPassword || undefined
})
const tempPassword = response.data.data?.temporaryPassword
if (tempPassword) {
setTempPasswords(prev => ({ ...prev, [userId]: { password: tempPassword } }))
}
setResetPasswordUser(null)
setNewPassword('')
} catch (err: any) {
setError('Passwort konnte nicht zurückgesetzt werden')
}
}
const sendTempPasswordEmail = async (userId: string, password: string) => {
try {
await api.post(`/admin/users/${userId}/send-temp-password`, { password })
alert('Temporäres Passwort per E-Mail versendet.')
} catch (err: any) {
setError('E-Mail-Versand des temporären Passworts fehlgeschlagen')
}
}
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragActive(true)
}
const onDragLeave = () => setDragActive(false)
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0])
}
}
const handleFile = (file: File) => {
setParseError('')
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result || '')
if (file.name.toLowerCase().endsWith('.json')) {
const json = JSON.parse(text)
const rows: ImportRow[] = Array.isArray(json) ? json : [json]
validateAndSetRows(rows)
} else {
// Simple CSV parser (comma or semicolon)
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0)
if (lines.length === 0) throw new Error('Leere Datei')
const header = lines[0].split(/[;,]/).map(h => h.trim().toLowerCase())
const idx = (name: string) => header.indexOf(name)
const rows: ImportRow[] = []
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(/[;,]/).map(c => c.trim())
rows.push({
firstName: cols[idx('firstname')] || cols[idx('vorname')] || '',
lastName: cols[idx('lastname')] || cols[idx('nachname')] || '',
email: cols[idx('email')] || '',
department: cols[idx('department')] || cols[idx('abteilung')] || ''
})
}
validateAndSetRows(rows)
}
} catch (err: any) {
setParseError(err.message || 'Datei konnte nicht gelesen werden')
setParsedRows([])
}
}
reader.readAsText(file)
}
const validateAndSetRows = (rows: ImportRow[]) => {
const valid: ImportRow[] = []
for (const r of rows) {
if (!r.firstName || !r.lastName || !r.email || !r.department) continue
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.email)) continue
valid.push({ ...r })
}
setParsedRows(valid)
}
const startImport = async () => {
if (parsedRows.length === 0) return
setIsImporting(true)
setImportResults([])
try {
const results: any[] = []
for (let i = 0; i < parsedRows.length; i++) {
const r = parsedRows[i]
try {
const res = await api.post('/employees', {
firstName: r.firstName,
lastName: r.lastName,
email: r.email,
department: r.department,
createUser: true,
userRole: 'user'
})
const data = res.data?.data || {}
results.push({
index: i,
status: 'created',
employeeId: data.id,
userId: data.userId,
email: r.email,
username: r.email?.split('@')[0],
temporaryPassword: data.temporaryPassword
})
} catch (err: any) {
results.push({ index: i, status: 'error', error: err.response?.data?.error?.message || 'Fehler beim Import' })
}
}
setImportResults(results)
await fetchUsers()
} finally {
setIsImporting(false)
}
}
const handleDeleteUser = async (userId: string) => {
if (!confirm('Möchten Sie diesen Benutzer wirklich löschen?')) return
try {
await api.delete(`/admin/users/${userId}`)
await fetchUsers()
} catch (err: any) {
setError('Benutzer konnte nicht gelöscht werden')
}
}
const getRoleBadgeColor = (role: UserRole) => {
switch (role) {
case 'admin':
return 'bg-red-100 text-red-800'
case 'superuser':
return 'bg-blue-100 text-blue-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
const getRoleLabel = (role: UserRole) => {
switch (role) {
case 'admin':
return 'Administrator'
case 'superuser':
return 'Poweruser'
default:
return 'Benutzer'
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-secondary">Lade Benutzer...</div>
</div>
)
}
return (
<div>
<div className="mb-8 flex justify-between items-start">
<div>
<h1 className="text-title-lg font-poppins font-bold text-primary mb-2">
Benutzerverwaltung
</h1>
<p className="text-body text-secondary">
Verwalten Sie Benutzerkonten, Rollen und Zugriffsrechte
</p>
</div>
<button
onClick={() => navigate('/users/create-employee')}
className="btn-primary"
>
+ Neuen Mitarbeiter anlegen
</button>
</div>
{currentUser?.role === 'admin' && (
<div className="card mb-6 bg-red-50 border border-red-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-2">Administrative Aktionen</h3>
<PurgeUsersPanel onDone={fetchUsers} />
</div>
)}
{error && (
<div className="bg-error-bg text-error px-4 py-3 rounded-input text-sm mb-6">
{error}
</div>
)}
<div className="card">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Benutzer
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Mitarbeiter
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Rolle
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Status
</th>
<th className="text-left py-3 px-4 font-poppins font-medium text-secondary">
Letzter Login
</th>
<th className="text-right py-3 px-4 font-poppins font-medium text-secondary">
Aktionen
</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-border-light hover:bg-bg-hover">
<td className="py-4 px-4">
<div className="flex items-center">
<div className="w-8 h-8 bg-primary-blue rounded-full flex items-center justify-center text-white text-sm font-medium mr-3">
{user.username === 'admin' ? 'A' : user.username.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-primary">
{user.username === 'admin' ? 'admin' : user.username}
</span>
</div>
</td>
<td className="py-4 px-4">
{user.employeeName ? (
<span className="text-secondary">{user.employeeName}</span>
) : (
<span className="text-tertiary italic">Nicht verknüpft</span>
)}
</td>
<td className="py-4 px-4">
{editingUser === user.id ? (
<div className="flex items-center space-x-2">
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as UserRole)}
className="input-field py-1 text-sm"
>
<option value="user">Benutzer</option>
<option value="superuser">Poweruser</option>
<option value="admin">Administrator</option>
</select>
<button
onClick={() => handleRoleChange(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => setEditingUser(null)}
className="text-error hover:text-red-700"
>
</button>
</div>
) : (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(user.role)}`}>
{getRoleLabel(user.role)}
</span>
)}
</td>
<td className="py-4 px-4">
<button
onClick={() => handleToggleActive(user.id, user.isActive)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Aktiv' : 'Inaktiv'}
</button>
</td>
<td className="py-4 px-4 text-secondary text-sm">
{user.lastLogin ? new Date(user.lastLogin).toLocaleString('de-DE') : 'Nie'}
</td>
<td className="py-4 px-4">
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setEditingUser(user.id)
setEditRole(user.role)
}}
className="p-1 text-secondary hover:text-primary-blue transition-colors"
title="Rolle bearbeiten"
>
<ShieldIcon className="w-5 h-5" />
</button>
{resetPasswordUser === user.id ? (
<div className="flex items-center space-x-2">
<input
type="text"
placeholder="Neues Passwort (leer = zufällig)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="input-field py-1 text-sm w-40"
/>
<button
onClick={() => handlePasswordReset(user.id)}
className="text-primary-blue hover:text-primary-blue-hover"
>
</button>
<button
onClick={() => {
setResetPasswordUser(null)
setNewPassword('')
}}
className="text-error hover:text-red-700"
>
</button>
</div>
) : (
<button
onClick={() => setResetPasswordUser(user.id)}
className="p-1 text-secondary hover:text-warning transition-colors"
title="Passwort zurücksetzen"
>
<KeyIcon className="w-5 h-5" />
</button>
)}
<button
onClick={() => handleDeleteUser(user.id)}
className="p-1 text-secondary hover:text-error transition-colors"
title="Benutzer löschen"
disabled={user.username === 'admin'}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
{tempPasswords[user.id] && (
<div className="mt-2 bg-green-50 border border-green-200 rounded-input p-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-green-800">
Temporäres Passwort: <code className="bg-gray-100 px-2 py-0.5 rounded">{tempPasswords[user.id].password}</code>
</div>
<div className="flex gap-2">
<button
className="btn-secondary h-8 px-3"
onClick={() => navigator.clipboard.writeText(tempPasswords[user.id].password)}
>
Kopieren
</button>
<button
className="btn-primary h-8 px-3"
onClick={() => sendTempPasswordEmail(user.id, tempPasswords[user.id].password)}
>
E-Mail senden
</button>
</div>
</div>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<div className="text-center py-8 text-secondary">
Keine Benutzer gefunden
</div>
)}
</div>
</div>
<div className="mt-6 card bg-blue-50 border-blue-200">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
Hinweise zur Benutzerverwaltung
</h3>
<ul className="space-y-2 text-body text-secondary">
<li> <strong>Administrator:</strong> Vollzugriff auf alle Funktionen und Einstellungen</li>
<li> <strong>Poweruser:</strong> Kann Mitarbeiter und Skills verwalten, aber keine Systemeinstellungen ändern</li>
<li> <strong>Benutzer:</strong> Kann nur eigenes Profil bearbeiten und Daten einsehen</li>
<li> Neue Benutzer können über den Import oder die Mitarbeiterverwaltung angelegt werden</li>
<li> Der Admin-Benutzer kann nicht gelöscht werden</li>
</ul>
</div>
<div className="mt-8 card">
<h3 className="text-title-card font-poppins font-semibold text-primary mb-3">
Import neue Nutzer (CSV oder JSON)
</h3>
<div
className={`border-2 border-dashed rounded-input p-6 text-center cursor-pointer ${dragActive ? 'border-primary-blue' : 'border-border-default'}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<p className="text-secondary mb-2">Datei hierher ziehen oder auswählen</p>
<input
type="file"
accept=".csv,.json,application/json,text/csv"
onChange={(e) => e.target.files && e.target.files[0] && handleFile(e.target.files[0])}
className="hidden"
id="user-import-input"
/>
<label htmlFor="user-import-input" className="btn-secondary inline-block">Datei auswählen</label>
{parseError && <div className="text-error text-sm mt-3">{parseError}</div>}
</div>
<div className="mt-4 text-body text-secondary">
<p className="mb-2 font-medium">Eingabekonventionen:</p>
<ul className="list-disc ml-5 space-y-1">
<li>CSV mit Kopfzeile: firstName;lastName;email;department (Komma oder Semikolon)</li>
<li>JSON: Array von Objekten mit Schlüsseln firstName, lastName, email, department</li>
<li>E-Mail muss valide sein; Rolle wird initial immer user</li>
<li>Es wird stets ein temporäres Passwort erzeugt; Anzeige nach Import</li>
</ul>
</div>
{parsedRows.length > 0 && (
<div className="mt-4">
<h4 className="text-body font-semibold text-secondary mb-2">Vorschau ({parsedRows.length} Einträge)</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-2 px-3 text-secondary">Vorname</th>
<th className="text-left py-2 px-3 text-secondary">Nachname</th>
<th className="text-left py-2 px-3 text-secondary">E-Mail</th>
<th className="text-left py-2 px-3 text-secondary">Abteilung</th>
</tr>
</thead>
<tbody>
{parsedRows.map((r, idx) => (
<tr key={idx} className="border-b border-border-light">
<td className="py-2 px-3">{r.firstName}</td>
<td className="py-2 px-3">{r.lastName}</td>
<td className="py-2 px-3">{r.email}</td>
<td className="py-2 px-3">{r.department}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 flex justify-end">
<button className="btn-primary" onClick={startImport} disabled={isImporting}>
{isImporting ? 'Importiere...' : 'Import starten'}
</button>
</div>
</div>
)}
{importResults.length > 0 && (
<div className="mt-6">
<h4 className="text-body font-semibold text-secondary mb-2">Import-Ergebnis</h4>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border-default">
<th className="text-left py-2 px-3 text-secondary">Zeile</th>
<th className="text-left py-2 px-3 text-secondary">E-Mail</th>
<th className="text-left py-2 px-3 text-secondary">Status</th>
<th className="text-left py-2 px-3 text-secondary">Temporäres Passwort</th>
<th className="text-right py-2 px-3 text-secondary">Aktionen</th>
</tr>
</thead>
<tbody>
{importResults.map((r, idx) => (
<tr key={idx} className="border-b border-border-light">
<td className="py-2 px-3">{r.index + 1}</td>
<td className="py-2 px-3">{r.email || '—'}</td>
<td className="py-2 px-3">{r.status === 'created' ? 'Erstellt' : `Fehler: ${r.error}`}</td>
<td className="py-2 px-3">
{r.temporaryPassword ? (
<code className="bg-gray-100 px-2 py-1 rounded">{r.temporaryPassword}</code>
) : (
'—'
)}
</td>
<td className="py-2 px-3 text-right">
{r.userId && r.temporaryPassword && (
<div className="flex justify-end gap-2">
<button
className="btn-secondary h-9 px-3"
onClick={() => navigator.clipboard.writeText(r.temporaryPassword!)}
>
Kopieren
</button>
<button
className="btn-primary h-9 px-3"
onClick={() => sendTempPasswordEmail(r.userId!, r.temporaryPassword!)}
>
E-Mail senden
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)
}
function PurgeUsersPanel({ onDone }: { onDone: () => void }) {
const [email, setEmail] = useState('hendrik.gebhardt@polizei.nrw.de')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const runPurge = async () => {
if (!email || !email.includes('@')) {
setMsg('Bitte gültige E-Mail eingeben')
return
}
const ok = confirm(`Achtung: Dies löscht alle Benutzer außer 'admin' und ${email}. Fortfahren?`)
if (!ok) return
try {
setBusy(true)
setMsg('')
const res = await api.post('/admin/users/purge', { email })
const { kept, deleted } = res.data.data || {}
setMsg(`Bereinigung abgeschlossen. Behalten: ${kept}, gelöscht: ${deleted}`)
onDone()
} catch (e: any) {
setMsg('Bereinigung fehlgeschlagen')
} finally {
setBusy(false)
}
}
return (
<div className="space-y-3">
<p className="text-body text-secondary">Nur Administratoren: Löscht alle Benutzer und behält nur 'admin' und die angegebene EMail.</p>
<div className="flex items-center gap-3">
<input
className="input-field w-80"
placeholder="EMail, die erhalten bleiben soll"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button className="btn-danger" onClick={runPurge} disabled={busy}>
Bereinigen
</button>
</div>
{msg && <div className="text-secondary">{msg}</div>}
</div>
)
}

107
admin-panel/tailwind.config.js Normale Datei
Datei anzeigen

@ -0,0 +1,107 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Light Mode Colors
'primary-blue': '#3182CE',
'primary-blue-hover': '#2563EB',
'primary-blue-active': '#1D4ED8',
'primary-blue-dark': '#1E40AF',
'bg-main': '#F8FAFC',
'bg-white': '#FFFFFF',
'bg-gray': '#F0F4F8',
'bg-accent': '#E6F2FF',
'text-primary': '#1A365D',
'text-secondary': '#2D3748',
'text-tertiary': '#4A5568',
'text-quaternary': '#718096',
'text-placeholder': '#A0AEC0',
'border-default': '#E2E8F0',
'border-input': '#CBD5E0',
'divider': '#F1F5F9',
'success': '#059669',
'success-bg': '#D1FAE5',
'warning': '#D97706',
'warning-bg': '#FEF3C7',
'error': '#DC2626',
'error-bg': '#FEE2E2',
'info': '#2563EB',
'info-bg': '#DBEAFE',
// Dark Mode Colors
'dark': {
'primary': '#232D53',
'accent': '#00D4FF',
'accent-hover': '#00B8E6',
'bg': '#000000',
'bg-secondary': '#1A1F3A',
'bg-sidebar': '#0A0A0A',
'bg-hover': '#232D53',
'bg-focus': '#2A3560',
'text-primary': '#FFFFFF',
'text-secondary': 'rgba(255, 255, 255, 0.7)',
'text-tertiary': 'rgba(255, 255, 255, 0.6)',
'border': 'rgba(255, 255, 255, 0.1)',
'success': '#4CAF50',
'warning': '#FFC107',
'error': '#FF4444',
'info': '#2196F3',
}
},
fontFamily: {
'poppins': ['Poppins', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
'sans': ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Arial', 'sans-serif'],
'mono': ['SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', 'monospace'],
},
fontSize: {
'title-lg': '32px',
'title-dialog': '24px',
'title-card': '20px',
'nav': '15px',
'body': '14px',
'small': '13px',
'help': '12px',
},
spacing: {
'container': '40px',
'card': '32px',
'element': '16px',
'inline': '8px',
},
borderRadius: {
'card': '16px',
'button': '24px',
'input': '8px',
'badge': '12px',
},
boxShadow: {
'sm': '0 1px 2px rgba(0, 0, 0, 0.05)',
'md': '0 4px 6px rgba(0, 0, 0, 0.1)',
'lg': '0 10px 15px rgba(0, 0, 0, 0.1)',
'xl': '0 20px 25px rgba(0, 0, 0, 0.1)',
'focus': '0 0 0 3px rgba(49, 130, 206, 0.1)',
'dark-sm': '0 2px 4px rgba(0, 0, 0, 0.3)',
'dark-md': '0 4px 12px rgba(0, 0, 0, 0.4)',
'dark-lg': '0 8px 24px rgba(0, 0, 0, 0.5)',
'dark-glow': '0 0 20px rgba(0, 212, 255, 0.3)',
},
transitionProperty: {
'all': 'all',
},
transitionDuration: {
'default': '300ms',
'fast': '200ms',
},
transitionTimingFunction: {
'default': 'ease',
},
},
},
plugins: [],
}

24
admin-panel/tsconfig.json Normale Datei
Datei anzeigen

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

Datei anzeigen

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

19
admin-panel/vite.config.ts Normale Datei
Datei anzeigen

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
server: {
port: 3006,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

16
backend/.gitignore vendored Normale Datei
Datei anzeigen

@ -0,0 +1,16 @@
node_modules
dist
*.log
.env.local
.env.*.local
uploads/*
!uploads/.gitkeep
logs/*
!logs/.gitkeep
*.db
*.db-wal
*.db-shm
.database.key
*.encrypted.db
*.encrypted.db-wal
*.encrypted.db-shm

72
backend/create-test-user.js Normale Datei
Datei anzeigen

@ -0,0 +1,72 @@
const Database = require('better-sqlite3');
const bcryptjs = require('bcryptjs');
const CryptoJS = require('crypto-js');
const crypto = require('crypto');
const path = require('path');
const FIELD_ENCRYPTION_KEY = process.env.FIELD_ENCRYPTION_KEY || 'dev_field_key_change_in_production_32chars_min!';
function encrypt(text) {
if (!text) return null;
try {
return CryptoJS.AES.encrypt(text, FIELD_ENCRYPTION_KEY).toString();
} catch (error) {
console.error('Encryption error:', error);
return text;
}
}
function hashEmail(email) {
if (!email) return null;
return crypto.createHash('sha256').update(email.toLowerCase()).digest('hex');
}
async function createTestUser() {
const dbPath = path.join(__dirname, 'skillmate.dev.db');
console.log(`Opening database at: ${dbPath}`);
const db = new Database(dbPath);
try {
console.log('\n=== Creating Test User ===\n');
const email = 'hendrik.gebhardt@polizei.nrw.de';
const hashedPassword = await bcryptjs.hash('test123', 10);
const encryptedEmail = encrypt(email);
const emailHash = hashEmail(email);
const userId = 'user-' + Date.now();
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
'hendrik.gebhardt',
encryptedEmail,
emailHash,
hashedPassword,
'user',
1,
new Date().toISOString(),
new Date().toISOString()
);
console.log('✓ Test user created successfully!');
console.log('Email:', email);
console.log('Username: hendrik.gebhardt');
console.log('Password: test123');
console.log('Role: user');
// Show all users
const users = db.prepare('SELECT id, username, role, is_active FROM users').all();
console.log('\nAll users in database:');
users.forEach(user => console.log(' -', user));
} catch (error) {
console.error('Error creating test user:', error);
process.exit(1);
} finally {
db.close();
}
}
createTestUser();

332
backend/full-backend-3005.js Normale Datei
Datei anzeigen

@ -0,0 +1,332 @@
const express = require('express');
const cors = require('cors');
const Database = require('better-sqlite3');
const bcryptjs = require('bcryptjs');
const CryptoJS = require('crypto-js');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3005;
// Environment variables
const FIELD_ENCRYPTION_KEY = process.env.FIELD_ENCRYPTION_KEY || 'dev_field_key_change_in_production_32chars_min!';
// Database setup
const dbPath = path.join(__dirname, 'skillmate.dev.db');
const db = new Database(dbPath);
console.log('Database path:', dbPath);
// Middleware
app.use(cors({
origin: ['http://localhost:5173', 'http://127.0.0.1:5173', 'http://localhost:3006', 'http://127.0.0.1:3006'],
credentials: true
}));
app.use(express.json());
// Encryption/Decryption functions
function encrypt(text) {
if (!text) return null;
return CryptoJS.AES.encrypt(text, FIELD_ENCRYPTION_KEY).toString();
}
function decrypt(encryptedText) {
if (!encryptedText) return null;
try {
const bytes = CryptoJS.AES.decrypt(encryptedText, FIELD_ENCRYPTION_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
} catch (error) {
console.error('Decryption error:', error);
return null;
}
}
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
console.log('Login attempt:', req.body);
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
success: false,
error: { message: 'Email and password are required' }
});
}
try {
// Find user by email (encrypted)
const users = db.prepare('SELECT * FROM users WHERE is_active = 1').all();
let user = null;
// Check encrypted emails
for (const u of users) {
const decryptedEmail = decrypt(u.email);
if (decryptedEmail === username || u.email === username) {
user = u;
break;
}
}
if (!user) {
console.log('User not found:', username);
return res.status(401).json({
success: false,
error: { message: 'Invalid credentials' }
});
}
console.log('User found:', { id: user.id, username: user.username, role: user.role });
// Verify password
const isValidPassword = bcryptjs.compareSync(password, user.password);
console.log('Password valid:', isValidPassword);
if (!isValidPassword) {
return res.status(401).json({
success: false,
error: { message: 'Invalid credentials' }
});
}
// Decrypt sensitive fields
const decryptedUser = {
id: user.id,
username: user.username,
email: decrypt(user.email) || user.email,
role: user.role,
employee_id: user.employee_id,
is_active: user.is_active,
last_login: user.last_login,
created_at: user.created_at,
updated_at: user.updated_at
};
res.json({
success: true,
message: 'Login successful!',
user: decryptedUser
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
error: { message: 'Internal server error' }
});
}
});
// Get all users endpoint (for admin panel)
app.get('/api/users', (req, res) => {
console.log('Get users request');
try {
const users = db.prepare('SELECT id, username, email, role, employee_id, is_active, last_login, created_at, updated_at FROM users').all();
// Decrypt sensitive fields for each user
const decryptedUsers = users.map(user => ({
id: user.id,
username: user.username,
email: decrypt(user.email) || user.email,
role: user.role,
employee_id: user.employee_id,
is_active: user.is_active,
last_login: user.last_login,
created_at: user.created_at,
updated_at: user.updated_at
}));
console.log(`Returning ${decryptedUsers.length} users`);
res.json({
success: true,
users: decryptedUsers
});
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({
success: false,
error: { message: 'Internal server error' }
});
}
});
// Get all employees endpoint
app.get('/api/employees', (req, res) => {
console.log('Get employees request');
try {
const employees = db.prepare('SELECT * FROM employees ORDER BY last_name, first_name').all();
// Decrypt sensitive fields for each employee
const decryptedEmployees = employees.map(emp => ({
...emp,
first_name: decrypt(emp.first_name) || emp.first_name,
last_name: decrypt(emp.last_name) || emp.last_name,
email: decrypt(emp.email) || emp.email,
phone: decrypt(emp.phone) || emp.phone,
mobile: decrypt(emp.mobile) || emp.mobile
}));
console.log(`Returning ${decryptedEmployees.length} employees`);
res.json({
success: true,
employees: decryptedEmployees
});
} catch (error) {
console.error('Get employees error:', error);
res.status(500).json({
success: false,
error: { message: 'Internal server error' }
});
}
});
// Create employee endpoint
app.post('/api/employees', (req, res) => {
console.log('Create employee request:', req.body);
const {
first_name,
last_name,
employee_number,
position,
department,
email,
phone,
mobile,
office,
availability = 'available',
clearance_level,
clearance_valid_until,
clearance_issued_date
} = req.body;
if (!first_name || !last_name || !employee_number || !position || !department || !email || !phone) {
return res.status(400).json({
success: false,
error: { message: 'Required fields missing' }
});
}
try {
const crypto = require('crypto');
const employeeId = crypto.randomUUID();
const now = new Date().toISOString();
// Encrypt sensitive fields
const encryptedEmployee = {
id: employeeId,
first_name: encrypt(first_name),
last_name: encrypt(last_name),
employee_number,
photo: null,
position,
department,
email: encrypt(email),
phone: encrypt(phone),
mobile: mobile ? encrypt(mobile) : null,
office,
availability,
clearance_level,
clearance_valid_until,
clearance_issued_date,
created_at: now,
updated_at: now,
created_by: 'system', // TODO: use actual user ID
updated_by: null
};
const insertStmt = db.prepare(`
INSERT INTO employees (
id, first_name, last_name, employee_number, photo, position, department,
email, phone, mobile, office, availability, clearance_level,
clearance_valid_until, clearance_issued_date, created_at, updated_at,
created_by, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insertStmt.run(
encryptedEmployee.id,
encryptedEmployee.first_name,
encryptedEmployee.last_name,
encryptedEmployee.employee_number,
encryptedEmployee.photo,
encryptedEmployee.position,
encryptedEmployee.department,
encryptedEmployee.email,
encryptedEmployee.phone,
encryptedEmployee.mobile,
encryptedEmployee.office,
encryptedEmployee.availability,
encryptedEmployee.clearance_level,
encryptedEmployee.clearance_valid_until,
encryptedEmployee.clearance_issued_date,
encryptedEmployee.created_at,
encryptedEmployee.updated_at,
encryptedEmployee.created_by,
encryptedEmployee.updated_by
);
console.log('Employee created successfully:', employeeId);
res.status(201).json({
success: true,
message: 'Employee created successfully',
employee: {
id: employeeId,
first_name,
last_name,
employee_number,
position,
department,
email,
phone,
mobile,
office,
availability,
clearance_level,
clearance_valid_until,
clearance_issued_date
}
});
} catch (error) {
console.error('Create employee error:', error);
res.status(500).json({
success: false,
error: { message: 'Internal server error' }
});
}
});
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({ status: 'OK', message: 'Backend server is running' });
});
// Error handling middleware
app.use((error, req, res, next) => {
console.error('Unhandled error:', error);
res.status(500).json({
success: false,
error: { message: 'Internal server error' }
});
});
// Start server
app.listen(PORT, () => {
console.log(`Backend server running on http://localhost:${PORT}`);
console.log('Database ready');
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('Received SIGTERM, closing database...');
db.close();
process.exit(0);
});

5154
backend/package-lock.json generiert Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

51
backend/package.json Normale Datei
Datei anzeigen

@ -0,0 +1,51 @@
{
"name": "@skillmate/backend",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"reset-admin": "node scripts/reset-admin.js",
"migrate-users": "node scripts/migrate-users.js",
"seed-skills": "node scripts/seed-skills-from-frontend.js",
"purge-users": "node scripts/purge-users.js"
},
"dependencies": {
"@skillmate/shared": "file:../shared",
"@types/bcrypt": "^6.0.0",
"@types/multer": "^2.0.0",
"axios": "^1.10.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.2.2",
"better-sqlite3-multiple-ciphers": "^12.2.0",
"cors": "^2.8.5",
"crypto-js": "^4.2.0",
"dotenv": "^16.6.1",
"express": "^4.18.2",
"express-validator": "^7.2.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"sqlite3": "^5.1.6",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8",
"@types/cors": "^2.8.17",
"@types/crypto-js": "^4.2.2",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/nodemailer": "^7.0.1",
"@types/uuid": "^9.0.7",
"nodemon": "^3.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.3.0"
}
}

Datei anzeigen

@ -0,0 +1,71 @@
// Migrate users from legacy unencrypted DB (skillmate.dev.db)
// to encrypted dev DB (skillmate.dev.encrypted.db)
// Usage: from backend dir run: npm run migrate-users
const path = require('path')
const Database = require('better-sqlite3')
const CryptoJS = require('crypto-js')
require('dotenv').config()
function encKey() {
return process.env.FIELD_ENCRYPTION_KEY || 'dev_field_key_change_in_production_32chars_min!'
}
function encrypt(text) {
if (!text) return null
return CryptoJS.AES.encrypt(text, encKey()).toString()
}
function sha256Lower(text) {
return CryptoJS.SHA256((text || '').toLowerCase()).toString()
}
function main() {
const legacyPath = path.join(process.cwd(), 'skillmate.dev.db')
const encPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db')
const legacy = new Database(legacyPath)
const enc = new Database(encPath)
try {
const legacyUsers = legacy.prepare('SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at FROM users').all()
let migrated = 0
const existsByUsername = enc.prepare('SELECT id FROM users WHERE username = ?')
const insert = enc.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, last_login, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const u of legacyUsers) {
const exists = existsByUsername.get(u.username)
if (exists) continue
insert.run(
u.id,
u.username,
encrypt(u.email),
sha256Lower(u.email || ''),
u.password,
u.role,
u.employee_id || null,
u.last_login || null,
u.is_active ?? 1,
u.created_at || new Date().toISOString(),
u.updated_at || new Date().toISOString()
)
migrated++
}
console.log(`✅ Migration abgeschlossen. Übertragene Benutzer: ${migrated}`)
} catch (err) {
console.error('❌ Migration fehlgeschlagen:', err)
process.exitCode = 1
} finally {
legacy.close()
enc.close()
}
}
main()

Datei anzeigen

@ -0,0 +1,88 @@
// Purge users from DB, keeping only 'admin' and a specific email
// Usage (Windows CMD/PowerShell from backend directory):
// npm run purge-users -- --email hendrik.gebhardt@polizei.nrw.de
// If --email is omitted, defaults to 'hendrik.gebhardt@polizei.nrw.de'
const path = require('path')
const fs = require('fs')
const Database = require('better-sqlite3')
const CryptoJS = require('crypto-js')
function getDbPath() {
const envPath = process.env.DATABASE_PATH
if (envPath && envPath.trim()) return envPath
const prod = process.env.NODE_ENV === 'production'
return prod
? path.join(process.cwd(), 'data', 'skillmate.encrypted.db')
: path.join(process.cwd(), 'skillmate.dev.encrypted.db')
}
function hashLower(text) {
return CryptoJS.SHA256(String(text || '').toLowerCase()).toString()
}
function parseEmailArg() {
const idx = process.argv.indexOf('--email')
if (idx !== -1 && process.argv[idx + 1]) return process.argv[idx + 1]
return 'hendrik.gebhardt@polizei.nrw.de'
}
function backupFile(filePath) {
try {
const dir = path.dirname(filePath)
const base = path.basename(filePath)
const ts = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0,19)
const dest = path.join(dir, `${base}.backup_${ts}`)
fs.copyFileSync(filePath, dest)
console.log(`📦 Backup erstellt: ${dest}`)
} catch (e) {
console.warn('⚠️ Konnte kein Backup erstellen:', e.message)
}
}
function main() {
const dbPath = getDbPath()
const keepEmail = parseEmailArg()
const keepHash = hashLower(keepEmail)
console.log(`Datenbank: ${dbPath}`)
console.log(`Behalte Nutzer: 'admin' und ${keepEmail}`)
if (!fs.existsSync(dbPath)) {
console.error('❌ Datenbankdatei nicht gefunden.')
process.exit(1)
}
backupFile(dbPath)
const db = new Database(dbPath)
try {
const all = db.prepare('SELECT id, username, email_hash FROM users').all()
const keep = []
const del = []
for (const u of all) {
if (u.username === 'admin') { keep.push(u); continue }
if (u.email_hash && u.email_hash === keepHash) { keep.push(u); continue }
del.push(u)
}
console.log(`Gefundene Nutzer: ${all.length}`)
console.log(`Behalte: ${keep.length} | Lösche: ${del.length}`)
const tx = db.transaction(() => {
const delStmt = db.prepare('DELETE FROM users WHERE id = ?')
for (const u of del) delStmt.run(u.id)
})
tx()
console.log('✅ Bereinigung abgeschlossen.')
} catch (err) {
console.error('❌ Fehler bei der Bereinigung:', err)
process.exitCode = 1
} finally {
db.close()
}
}
main()

Datei anzeigen

@ -0,0 +1,76 @@
// Reset the admin login to username 'admin' with password 'admin123'
// Usage: from backend directory, run `npm run reset-admin` or `node scripts/reset-admin.js`
const path = require('path')
const Database = require('better-sqlite3')
const bcrypt = require('bcryptjs')
const CryptoJS = require('crypto-js')
require('dotenv').config()
function getDbPath() {
const envPath = process.env.DATABASE_PATH
if (envPath && envPath.trim()) return envPath
const prod = process.env.NODE_ENV === 'production'
return prod
? path.join(process.cwd(), 'data', 'skillmate.encrypted.db')
: path.join(process.cwd(), 'skillmate.dev.encrypted.db')
}
function getFieldKey() {
return (
process.env.FIELD_ENCRYPTION_KEY ||
'dev_field_key_change_in_production_32chars_min!'
)
}
function encrypt(text) {
if (!text) return null
return CryptoJS.AES.encrypt(text, getFieldKey()).toString()
}
function sha256Lower(text) {
return CryptoJS.SHA256(text.toLowerCase()).toString()
}
async function main() {
const dbPath = getDbPath()
console.log(`Using database: ${dbPath}`)
const db = new Database(dbPath)
try {
const admin = db.prepare('SELECT id, username FROM users WHERE username = ?').get('admin')
const newHash = await bcrypt.hash('admin123', 12)
if (admin) {
db.prepare('UPDATE users SET password = ?, is_active = 1, role = ?, updated_at = ? WHERE id = ?')
.run(newHash, 'admin', new Date().toISOString(), admin.id)
console.log('✅ Admin-Passwort zurückgesetzt: admin / admin123')
} else {
const now = new Date().toISOString()
const email = 'admin@skillmate.local'
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'admin-' + Date.now(),
'admin',
encrypt(email),
sha256Lower(email),
newHash,
'admin',
1,
now,
now
)
console.log('✅ Admin-Benutzer erstellt: admin / admin123')
}
} catch (err) {
console.error('❌ Fehler beim Zurücksetzen der Admin-Anmeldeinformationen:', err)
process.exitCode = 1
} finally {
db.close()
}
}
main()

Datei anzeigen

@ -0,0 +1,64 @@
// Seed skills table from frontend's SKILL_HIERARCHY definition
// Usage: from backend dir: npm run seed-skills
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const Database = require('better-sqlite3')
function parseFrontendHierarchy() {
const tsPath = path.join(process.cwd(), '..', 'frontend', 'src', 'data', 'skillCategories.ts')
const src = fs.readFileSync(tsPath, 'utf8')
// Remove interface declarations and LANGUAGE_LEVELS export, keep the array literal
let code = src
.replace(/export interface[\s\S]*?\n\}/g, '')
.replace(/export const LANGUAGE_LEVELS[\s\S]*?\n\n/, '')
.replace(/export const SKILL_HIERARCHY:[^=]*=/, 'module.exports =')
const sandbox = { module: {}, exports: {} }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
return sandbox.module.exports || sandbox.exports
}
function main() {
const dbPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db')
const db = new Database(dbPath)
try {
const hierarchy = parseFrontendHierarchy()
if (!Array.isArray(hierarchy)) {
throw new Error('Parsed hierarchy is not an array')
}
const insert = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, requires_certification, expires_after)
VALUES (?, ?, ?, ?, ?, ?)
`)
let count = 0
for (const cat of hierarchy) {
for (const sub of (cat.subcategories || [])) {
const categoryKey = `${cat.id}.${sub.id}`
for (const sk of (sub.skills || [])) {
const id = `${categoryKey}.${sk.id}`
const name = sk.name
const description = null
const requires = cat.id === 'certifications' || sub.id === 'weapons' ? 1 : 0
const expires = cat.id === 'certifications' ? 36 : null
const res = insert.run(id, name, categoryKey, description, requires, expires)
if (res.changes > 0) count++
}
}
}
console.log(`✅ Seed abgeschlossen. Neue Skills eingefügt: ${count}`)
} catch (err) {
console.error('❌ Seed fehlgeschlagen:', err)
process.exitCode = 1
} finally {
db.close()
}
}
main()

444
backend/src/config/database.ts Normale Datei
Datei anzeigen

@ -0,0 +1,444 @@
import Database from 'better-sqlite3'
import path from 'path'
import { Employee, User, SkillDefinition } from '@skillmate/shared'
import bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { db as secureDb, encryptedDb, initializeSecureDatabase } from './secureDatabase'
export { initializeSecureDatabase } from './secureDatabase'
// Export the secure database instance
export const db = secureDb
export function initializeDatabase() {
// Users table
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
employee_id TEXT,
last_login TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`)
// Profiles table (erweitert für Yellow Pages)
db.exec(`
CREATE TABLE IF NOT EXISTS profiles (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
department TEXT,
location TEXT,
role TEXT,
email TEXT,
phone TEXT,
teams_link TEXT,
job_category TEXT CHECK(job_category IN ('Technik', 'IT & Digitalisierung', 'Verwaltung', 'F&E', 'Kommunikation & HR', 'Produktion', 'Sonstiges')),
job_title TEXT,
job_desc TEXT,
consent_public_profile INTEGER DEFAULT 0,
consent_searchable INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL,
review_due_at TEXT,
search_vector TEXT
)
`)
// Volltext-Index für Suche
db.exec(`
CREATE INDEX IF NOT EXISTS idx_profiles_search
ON profiles(search_vector);
CREATE INDEX IF NOT EXISTS idx_profiles_department
ON profiles(department);
CREATE INDEX IF NOT EXISTS idx_profiles_location
ON profiles(location);
CREATE INDEX IF NOT EXISTS idx_profiles_job_category
ON profiles(job_category);
CREATE INDEX IF NOT EXISTS idx_profiles_review_due
ON profiles(review_due_at);
`)
// Employees table (für Kompatibilität beibehalten)
db.exec(`
CREATE TABLE IF NOT EXISTS employees (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
employee_number TEXT UNIQUE NOT NULL,
photo TEXT,
position TEXT NOT NULL,
department TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT NOT NULL,
mobile TEXT,
office TEXT,
availability TEXT NOT NULL,
clearance_level TEXT,
clearance_valid_until TEXT,
clearance_issued_date TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT
)
`)
// Skills table
db.exec(`
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
requires_certification INTEGER DEFAULT 0,
expires_after INTEGER
)
`)
// Employee skills junction table
db.exec(`
CREATE TABLE IF NOT EXISTS employee_skills (
employee_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
level TEXT,
verified INTEGER DEFAULT 0,
verified_by TEXT,
verified_date TEXT,
PRIMARY KEY (employee_id, skill_id),
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE
)
`)
// Profile Kompetenzen (Arrays als separate Tabellen)
db.exec(`
CREATE TABLE IF NOT EXISTS profile_domains (
profile_id TEXT NOT NULL,
domain TEXT NOT NULL,
PRIMARY KEY (profile_id, domain),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS profile_tools (
profile_id TEXT NOT NULL,
tool TEXT NOT NULL,
PRIMARY KEY (profile_id, tool),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS profile_methods (
profile_id TEXT NOT NULL,
method TEXT NOT NULL,
PRIMARY KEY (profile_id, method),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS profile_industry_knowledge (
profile_id TEXT NOT NULL,
knowledge TEXT NOT NULL,
PRIMARY KEY (profile_id, knowledge),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS profile_regulatory (
profile_id TEXT NOT NULL,
regulation TEXT NOT NULL,
PRIMARY KEY (profile_id, regulation),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS profile_networks (
profile_id TEXT NOT NULL,
network TEXT NOT NULL,
PRIMARY KEY (profile_id, network),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS profile_digital_skills (
profile_id TEXT NOT NULL,
skill TEXT NOT NULL,
PRIMARY KEY (profile_id, skill),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS profile_social_skills (
profile_id TEXT NOT NULL,
skill TEXT NOT NULL,
PRIMARY KEY (profile_id, skill),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
`)
// Profile Sprachen
db.exec(`
CREATE TABLE IF NOT EXISTS profile_languages (
profile_id TEXT NOT NULL,
code TEXT NOT NULL,
level TEXT NOT NULL CHECK(level IN ('basic', 'fluent', 'native', 'business')),
PRIMARY KEY (profile_id, code),
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
`)
// Profile Projekte
db.exec(`
CREATE TABLE IF NOT EXISTS profile_projects (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
title TEXT NOT NULL,
role TEXT,
summary TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS project_links (
project_id TEXT NOT NULL,
link TEXT NOT NULL,
PRIMARY KEY (project_id, link),
FOREIGN KEY (project_id) REFERENCES profile_projects(id) ON DELETE CASCADE
);
`)
// Language skills table (für Kompatibilität)
db.exec(`
CREATE TABLE IF NOT EXISTS language_skills (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
language TEXT NOT NULL,
proficiency TEXT NOT NULL,
certified INTEGER DEFAULT 0,
certificate_type TEXT,
is_native INTEGER DEFAULT 0,
can_interpret INTEGER DEFAULT 0,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
)
`)
// Specializations table
db.exec(`
CREATE TABLE IF NOT EXISTS specializations (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
)
`)
// Sync log table
db.exec(`
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
sync_time TEXT NOT NULL,
success INTEGER NOT NULL,
items_synced INTEGER,
error_message TEXT,
duration INTEGER
)
`)
// Create default admin user if not exists
const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
if (!adminExists) {
const hashedPassword = bcrypt.hashSync('admin123', 10)
const now = new Date().toISOString()
db.prepare(`
INSERT INTO users (id, username, email, password, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
'admin',
'admin@skillmate.local',
hashedPassword,
'admin',
1,
now,
now
)
}
// Workspace Management Tables
// Workspaces (Desks, Meeting Rooms, etc.)
db.exec(`
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('desk', 'meeting_room', 'phone_booth', 'parking', 'locker')),
floor TEXT NOT NULL,
building TEXT,
capacity INTEGER DEFAULT 1,
equipment TEXT, -- JSON array of equipment
position_x INTEGER, -- For floor plan visualization
position_y INTEGER,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`)
// Bookings table
db.exec(`
CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
employee_id TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('confirmed', 'cancelled', 'completed', 'no_show')),
check_in_time TEXT,
check_out_time TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (employee_id) REFERENCES employees(id)
)
`)
// Recurring bookings
db.exec(`
CREATE TABLE IF NOT EXISTS recurring_bookings (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
employee_id TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
time_start TEXT NOT NULL,
time_end TEXT NOT NULL,
days_of_week TEXT NOT NULL, -- JSON array of days [1,2,3,4,5] for Mon-Fri
created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (employee_id) REFERENCES employees(id)
)
`)
// Booking rules and restrictions
db.exec(`
CREATE TABLE IF NOT EXISTS booking_rules (
id TEXT PRIMARY KEY,
workspace_type TEXT,
max_duration_hours INTEGER,
max_advance_days INTEGER,
min_advance_hours INTEGER,
max_bookings_per_user_per_day INTEGER,
max_bookings_per_user_per_week INTEGER,
auto_release_minutes INTEGER, -- Auto-release if not checked in
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`)
// Analytics data
db.exec(`
CREATE TABLE IF NOT EXISTS workspace_analytics (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
date TEXT NOT NULL,
total_bookings INTEGER DEFAULT 0,
total_hours_booked REAL DEFAULT 0,
utilization_rate REAL DEFAULT 0,
no_show_count INTEGER DEFAULT 0,
unique_users INTEGER DEFAULT 0,
peak_hour INTEGER,
FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
UNIQUE(workspace_id, date)
)
`)
// Floor plans
db.exec(`
CREATE TABLE IF NOT EXISTS floor_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
floor TEXT NOT NULL,
building TEXT,
image_url TEXT,
svg_data TEXT, -- SVG data for interactive floor plan
width INTEGER,
height INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`)
// Audit Log für Änderungsverfolgung
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('create', 'update', 'delete')),
user_id TEXT NOT NULL,
changes TEXT, -- JSON mit Änderungen
timestamp TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
`)
// Reminder-System
db.exec(`
CREATE TABLE IF NOT EXISTS reminders (
id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('annual_update', 'overdue', 'custom')),
message TEXT,
sent_at TEXT,
acknowledged_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_reminders_profile ON reminders(profile_id);
CREATE INDEX IF NOT EXISTS idx_reminders_sent ON reminders(sent_at);
`)
// Kontrollierte Vokabulare/Tags
db.exec(`
CREATE TABLE IF NOT EXISTS controlled_vocabulary (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(category, value)
);
CREATE INDEX IF NOT EXISTS idx_vocab_category ON controlled_vocabulary(category);
`)
// Create indexes for better performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_employees_availability ON employees(availability);
CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department);
CREATE INDEX IF NOT EXISTS idx_employee_skills_employee ON employee_skills(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_skills_skill ON employee_skills(skill_id);
CREATE INDEX IF NOT EXISTS idx_language_skills_employee ON language_skills(employee_id);
CREATE INDEX IF NOT EXISTS idx_specializations_employee ON specializations(employee_id);
CREATE INDEX IF NOT EXISTS idx_bookings_workspace ON bookings(workspace_id);
CREATE INDEX IF NOT EXISTS idx_bookings_user ON bookings(user_id);
CREATE INDEX IF NOT EXISTS idx_bookings_start_time ON bookings(start_time);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
CREATE INDEX IF NOT EXISTS idx_workspace_analytics_date ON workspace_analytics(date);
`)
}

Datei anzeigen

@ -0,0 +1,360 @@
import Database from 'better-sqlite3'
import path from 'path'
import { randomBytes } from 'crypto'
import fs from 'fs'
import { Employee, User, SkillDefinition } from '@skillmate/shared'
import bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { FieldEncryption } from '../services/encryption'
// Get or generate database encryption key
const getDatabaseKey = (): string => {
let key = process.env.DATABASE_ENCRYPTION_KEY
if (!key) {
console.warn('⚠️ No DATABASE_ENCRYPTION_KEY found in environment variables.')
console.warn('⚠️ Generating a temporary key for development.')
console.warn('⚠️ For production, set DATABASE_ENCRYPTION_KEY in your .env file!')
// Generate and save a development key
const keyPath = path.join(process.cwd(), '.database.key')
if (fs.existsSync(keyPath)) {
key = fs.readFileSync(keyPath, 'utf8')
} else {
key = randomBytes(32).toString('hex')
fs.writeFileSync(keyPath, key, { mode: 0o600 }) // Restrictive permissions
console.log('💾 Development key saved to .database.key (add to .gitignore!)')
}
}
return key
}
// Database path configuration
const getDbPath = (): string => {
const dbPath = process.env.DATABASE_PATH || (
process.env.NODE_ENV === 'production'
? path.join(process.cwd(), 'data', 'skillmate.encrypted.db')
: path.join(process.cwd(), 'skillmate.dev.encrypted.db')
)
// Ensure data directory exists for production
const dbDir = path.dirname(dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
return dbPath
}
const dbPath = getDbPath()
const dbKey = getDatabaseKey()
// Create database connection with encryption support
export const db = new Database(dbPath)
// Enable better performance and data integrity
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.pragma('busy_timeout = 5000')
// Add encryption helper functions to database
export const encryptedDb = {
...db,
// Prepare statement with automatic encryption/decryption
prepareEncrypted(sql: string) {
return db.prepare(sql)
},
// Insert employee with encrypted fields
insertEmployee(employee: any) {
const encrypted = {
...employee,
email: FieldEncryption.encrypt(employee.email),
phone: FieldEncryption.encrypt(employee.phone),
mobile: FieldEncryption.encrypt(employee.mobile),
clearance_level: FieldEncryption.encrypt(employee.clearance_level),
clearance_valid_until: FieldEncryption.encrypt(employee.clearance_valid_until),
// Add search hashes for encrypted fields
email_hash: employee.email ? FieldEncryption.hash(employee.email) : null,
phone_hash: employee.phone ? FieldEncryption.hash(employee.phone) : null
}
return db.prepare(`
INSERT INTO employees (
id, first_name, last_name, employee_number, photo, position,
department, email, email_hash, phone, phone_hash, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by
) VALUES (
@id, @first_name, @last_name, @employee_number, @photo, @position,
@department, @email, @email_hash, @phone, @phone_hash, @mobile, @office, @availability,
@clearance_level, @clearance_valid_until, @clearance_issued_date,
@created_at, @updated_at, @created_by
)
`).run(encrypted)
},
// Get employee with decrypted fields
getEmployee(id: string) {
const employee = db.prepare(`
SELECT * FROM employees WHERE id = ?
`).get(id) as any
if (!employee) return null
// Decrypt sensitive fields
return {
...employee,
email: FieldEncryption.decrypt(employee.email),
phone: FieldEncryption.decrypt(employee.phone),
mobile: FieldEncryption.decrypt(employee.mobile),
clearance_level: FieldEncryption.decrypt(employee.clearance_level),
clearance_valid_until: FieldEncryption.decrypt(employee.clearance_valid_until)
}
},
// Get all employees with decrypted fields (handle decryption failures)
getAllEmployees() {
const employees = db.prepare(`
SELECT * FROM employees ORDER BY last_name, first_name
`).all() as any[]
return employees.map(emp => {
const safeDecrypt = (field: any) => {
if (!field) return field
try {
return FieldEncryption.decrypt(field) || field
} catch (error) {
// For compatibility with old unencrypted data or different encryption keys
return field
}
}
return {
...emp,
email: safeDecrypt(emp.email),
phone: safeDecrypt(emp.phone),
mobile: safeDecrypt(emp.mobile),
clearance_level: safeDecrypt(emp.clearance_level),
clearance_valid_until: safeDecrypt(emp.clearance_valid_until)
}
})
},
// Search by encrypted field using hash
findByEmail(email: string) {
const emailHash = FieldEncryption.hash(email)
return db.prepare(`
SELECT * FROM employees WHERE email_hash = ?
`).get(emailHash)
}
}
export function initializeSecureDatabase() {
// Create updated employees table with hash fields for searching
db.exec(`
CREATE TABLE IF NOT EXISTS employees (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
employee_number TEXT UNIQUE NOT NULL,
photo TEXT,
position TEXT NOT NULL,
department TEXT NOT NULL,
email TEXT NOT NULL,
email_hash TEXT,
phone TEXT NOT NULL,
phone_hash TEXT,
mobile TEXT,
office TEXT,
availability TEXT NOT NULL,
clearance_level TEXT,
clearance_valid_until TEXT,
clearance_issued_date TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT
)
`)
// Add indexes for hash fields
db.exec(`
CREATE INDEX IF NOT EXISTS idx_employees_email_hash ON employees(email_hash);
CREATE INDEX IF NOT EXISTS idx_employees_phone_hash ON employees(phone_hash);
`)
// Users table with encrypted email
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
email_hash TEXT,
password TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
employee_id TEXT,
last_login TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(email_hash)
)
`)
// Create index for email hash
db.exec(`
CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash);
`)
// Skills table
db.exec(`
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
requires_certification INTEGER DEFAULT 0,
expires_after INTEGER
)
`)
// Employee skills junction table
db.exec(`
CREATE TABLE IF NOT EXISTS employee_skills (
employee_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
level TEXT,
verified INTEGER DEFAULT 0,
verified_by TEXT,
verified_date TEXT,
PRIMARY KEY (employee_id, skill_id),
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE
)
`)
// Language skills table
db.exec(`
CREATE TABLE IF NOT EXISTS language_skills (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
language TEXT NOT NULL,
proficiency TEXT NOT NULL,
certified INTEGER DEFAULT 0,
certificate_type TEXT,
is_native INTEGER DEFAULT 0,
can_interpret INTEGER DEFAULT 0,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
)
`)
// Specializations table
db.exec(`
CREATE TABLE IF NOT EXISTS specializations (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
)
`)
// Audit Log for security tracking
db.exec(`
CREATE TABLE IF NOT EXISTS security_audit_log (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL CHECK(action IN ('create', 'read', 'update', 'delete', 'login', 'logout', 'failed_login')),
user_id TEXT,
changes TEXT,
timestamp TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT,
risk_level TEXT CHECK(risk_level IN ('low', 'medium', 'high', 'critical'))
);
CREATE INDEX IF NOT EXISTS idx_security_audit_entity ON security_audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_security_audit_user ON security_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_security_audit_timestamp ON security_audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_security_audit_risk ON security_audit_log(risk_level);
`)
// System settings table
db.exec(`
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TEXT NOT NULL,
updated_by TEXT
)
`)
// Insert default system settings
const settingsExist = db.prepare('SELECT key FROM system_settings WHERE key = ?').get('email_notifications_enabled')
if (!settingsExist) {
const now = new Date().toISOString()
db.prepare(`
INSERT INTO system_settings (key, value, description, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?)
`).run('email_notifications_enabled', 'false', 'Enable/disable email notifications for new user passwords', now, 'system')
}
// Create indexes for better performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_employees_availability ON employees(availability);
CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department);
CREATE INDEX IF NOT EXISTS idx_employee_skills_employee ON employee_skills(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_skills_skill ON employee_skills(skill_id);
CREATE INDEX IF NOT EXISTS idx_language_skills_employee ON language_skills(employee_id);
CREATE INDEX IF NOT EXISTS idx_specializations_employee ON specializations(employee_id);
`)
// Controlled vocabulary (used to store skill categories/subcategories names)
db.exec(`
CREATE TABLE IF NOT EXISTS controlled_vocabulary (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(category, value)
);
CREATE INDEX IF NOT EXISTS idx_vocab_category ON controlled_vocabulary(category);
CREATE INDEX IF NOT EXISTS idx_vocab_value ON controlled_vocabulary(value);
`)
// Create default admin user if not exists
const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
if (!adminExists) {
const hashedPassword = bcrypt.hashSync('admin123', 12)
const now = new Date().toISOString()
const adminEmail = 'admin@skillmate.local'
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
'admin',
FieldEncryption.encrypt(adminEmail),
FieldEncryption.hash(adminEmail),
hashedPassword,
'admin',
1,
now,
now
)
console.log('🔐 Default admin user created with password: admin123')
console.log('⚠️ Please change this password immediately!')
}
}
// db is already exported above via export const db = new Database(dbPath)

73
backend/src/index.ts Normale Datei
Datei anzeigen

@ -0,0 +1,73 @@
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import dotenv from 'dotenv'
import path from 'path'
import { initializeSecureDatabase } from './config/secureDatabase'
import authRoutes from './routes/auth'
import employeeRoutes from './routes/employeesSecure'
import profileRoutes from './routes/profiles'
import skillRoutes from './routes/skills'
import syncRoutes from './routes/sync'
import uploadRoutes from './routes/upload'
import networkRoutes from './routes/network'
import workspaceRoutes from './routes/workspaces'
import userRoutes from './routes/users'
import userAdminRoutes from './routes/usersAdmin'
import settingsRoutes from './routes/settings'
// import bookingRoutes from './routes/bookings' // Temporär deaktiviert wegen TS-Fehlern
// import analyticsRoutes from './routes/analytics' // Temporär deaktiviert
import { errorHandler } from './middleware/errorHandler'
import { logger } from './utils/logger'
import { syncScheduler } from './services/syncScheduler'
dotenv.config()
const app = express()
const PORT = process.env.PORT || 3004
// Initialize secure database
initializeSecureDatabase()
// Initialize sync scheduler
syncScheduler
// Middleware
app.use(helmet({
// Erlaube Bilder/Downloads aus diesem Server auch für andere Ports (5173/5174)
contentSecurityPolicy: false,
crossOriginResourcePolicy: { policy: 'cross-origin' },
}))
app.use(cors())
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
// Static file serving for uploads
app.use('/uploads', express.static(path.join(__dirname, '../uploads')))
// Routes
app.use('/api/auth', authRoutes)
app.use('/api/employees', employeeRoutes)
app.use('/api/profiles', profileRoutes)
app.use('/api/skills', skillRoutes)
app.use('/api/sync', syncRoutes)
app.use('/api/upload', uploadRoutes)
app.use('/api/network', networkRoutes)
app.use('/api/workspaces', workspaceRoutes)
app.use('/api/users', userRoutes)
app.use('/api/admin/users', userAdminRoutes)
app.use('/api/admin/settings', settingsRoutes)
// app.use('/api/bookings', bookingRoutes) // Temporär deaktiviert
// app.use('/api/analytics', analyticsRoutes) // Temporär deaktiviert
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
// Error handling
app.use(errorHandler)
app.listen(PORT, () => {
logger.info(`Backend server running on port ${PORT}`)
})// restart trigger

Datei anzeigen

@ -0,0 +1,53 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import { User, UserRole } from '@skillmate/shared'
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
export interface AuthRequest extends Request {
user?: User
}
export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({
success: false,
error: { message: 'No token provided' }
})
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as any
req.user = decoded.user
next()
} catch (error) {
return res.status(401).json({
success: false,
error: { message: 'Invalid token' }
})
}
}
export const authenticate = authenticateToken;
export function authorize(...roles: UserRole[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: { message: 'Not authenticated' }
})
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: { message: 'Insufficient permissions' }
})
}
next()
}
}

Datei anzeigen

@ -0,0 +1,38 @@
import { Request, Response, NextFunction } from 'express'
import { logger } from '../utils/logger'
export interface ApiError extends Error {
statusCode?: number
details?: any
}
export function errorHandler(
err: ApiError,
req: Request,
res: Response,
next: NextFunction
) {
logger.error({
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
body: req.body,
params: req.params,
query: req.query
})
const statusCode = err.statusCode || 500
const message = err.message || 'Internal Server Error'
res.status(statusCode).json({
success: false,
error: {
message,
...(process.env.NODE_ENV === 'development' && {
stack: err.stack,
details: err.details
})
}
})
}

Datei anzeigen

@ -0,0 +1,93 @@
import { Request, Response, NextFunction } from 'express'
import { UserRole, ROLE_PERMISSIONS } from '@skillmate/shared'
export interface AuthRequest extends Request {
user?: {
id: string
username: string
email: string
role: UserRole
employeeId?: string
}
}
export function hasPermission(userRole: UserRole, permission: string): boolean {
const rolePermissions = ROLE_PERMISSIONS[userRole] || []
return rolePermissions.includes(permission)
}
export function requirePermission(permission: string) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
if (!hasPermission(req.user.role, permission)) {
return res.status(403).json({
error: 'Forbidden',
message: `Insufficient permissions. Required: ${permission}`
})
}
next()
}
}
export function requireRole(roles: UserRole | UserRole[]) {
const allowedRoles = Array.isArray(roles) ? roles : [roles]
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Forbidden',
message: `Access denied. Required role: ${allowedRoles.join(' or ')}`
})
}
next()
}
}
export function requireAdminPanel() {
return requirePermission('admin:panel:access')
}
export function canEditEmployee(req: AuthRequest, targetEmployeeId: string): boolean {
if (!req.user) return false
// Admins can edit anyone
if (req.user.role === 'admin') return true
// Superusers can edit any employee
if (req.user.role === 'superuser') return true
// Users can only edit their own profile (if linked to employee)
if (req.user.role === 'user' && req.user.employeeId === targetEmployeeId) {
return true
}
return false
}
export function requireEditPermission(getTargetId: (req: AuthRequest) => string) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorized' })
}
const targetId = getTargetId(req)
if (!canEditEmployee(req, targetId)) {
return res.status(403).json({
error: 'Forbidden',
message: 'You can only edit your own profile'
})
}
next()
}
}

Datei anzeigen

@ -0,0 +1,235 @@
import { Router } from 'express'
import { db } from '../config/database'
import { authenticateToken, AuthRequest } from '../middleware/auth'
const router = Router()
// Get workspace utilization analytics
router.get('/workspace-utilization', authenticateToken, (req: AuthRequest, res) => {
if (!['admin', 'superuser'].includes(req.user!.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
try {
const { from_date, to_date, workspace_id, workspace_type } = req.query
let query = `
SELECT
wa.*,
w.name as workspace_name,
w.type as workspace_type,
w.floor,
w.building
FROM workspace_analytics wa
JOIN workspaces w ON wa.workspace_id = w.id
WHERE 1=1
`
const params: any[] = []
if (from_date) {
query += ' AND wa.date >= ?'
params.push(from_date)
}
if (to_date) {
query += ' AND wa.date <= ?'
params.push(to_date)
}
if (workspace_id) {
query += ' AND wa.workspace_id = ?'
params.push(workspace_id)
}
if (workspace_type) {
query += ' AND w.type = ?'
params.push(workspace_type)
}
query += ' ORDER BY wa.date DESC'
const analytics = db.prepare(query).all(...params)
res.json(analytics)
} catch (error) {
console.error('Error fetching analytics:', error)
res.status(500).json({ error: 'Failed to fetch analytics' })
}
})
// Get overall statistics
router.get('/overview', authenticateToken, (req: AuthRequest, res) => {
if (!['admin', 'superuser'].includes(req.user!.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
try {
const { from_date = new Date().toISOString().split('T')[0], to_date } = req.query
// Total workspaces by type
const workspaceStats = db.prepare(`
SELECT type, COUNT(*) as count
FROM workspaces
WHERE is_active = 1
GROUP BY type
`).all()
// Booking statistics
const bookingStats = db.prepare(`
SELECT
COUNT(*) as total_bookings,
COUNT(DISTINCT user_id) as unique_users,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_bookings,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_bookings,
SUM(CASE WHEN status = 'no_show' THEN 1 ELSE 0 END) as no_shows
FROM bookings
WHERE start_time >= ?
${to_date ? 'AND end_time <= ?' : ''}
`).get(from_date, ...(to_date ? [to_date] : []))
// Average utilization by workspace type
const utilizationByType = db.prepare(`
SELECT
w.type,
AVG(wa.utilization_rate) as avg_utilization,
AVG(wa.total_hours_booked) as avg_hours_booked
FROM workspace_analytics wa
JOIN workspaces w ON wa.workspace_id = w.id
WHERE wa.date >= ?
${to_date ? 'AND wa.date <= ?' : ''}
GROUP BY w.type
`).all(from_date, ...(to_date ? [to_date] : []))
// Popular workspaces
const popularWorkspaces = db.prepare(`
SELECT
w.id,
w.name,
w.type,
w.floor,
COUNT(b.id) as booking_count,
AVG(
CAST((julianday(b.end_time) - julianday(b.start_time)) * 24 AS REAL)
) as avg_duration_hours
FROM workspaces w
JOIN bookings b ON w.id = b.workspace_id
WHERE b.start_time >= ?
${to_date ? 'AND b.end_time <= ?' : ''}
AND b.status != 'cancelled'
GROUP BY w.id, w.name, w.type, w.floor
ORDER BY booking_count DESC
LIMIT 10
`).all(from_date, ...(to_date ? [to_date] : []))
// Peak hours analysis
const peakHours = db.prepare(`
SELECT
CAST(strftime('%H', start_time) AS INTEGER) as hour,
COUNT(*) as booking_count
FROM bookings
WHERE start_time >= ?
${to_date ? 'AND end_time <= ?' : ''}
AND status != 'cancelled'
GROUP BY hour
ORDER BY hour
`).all(from_date, ...(to_date ? [to_date] : []))
res.json({
workspace_stats: workspaceStats,
booking_stats: bookingStats,
utilization_by_type: utilizationByType,
popular_workspaces: popularWorkspaces,
peak_hours: peakHours,
date_range: {
from: from_date,
to: to_date || new Date().toISOString().split('T')[0]
}
})
} catch (error) {
console.error('Error fetching overview:', error)
res.status(500).json({ error: 'Failed to fetch overview' })
}
})
// Update analytics data (should be run by a scheduled job)
router.post('/update', authenticateToken, (req: AuthRequest, res) => {
if (req.user!.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' })
}
try {
const date = req.body.date || new Date().toISOString().split('T')[0]
// Calculate analytics for each workspace
const workspaces = db.prepare('SELECT id FROM workspaces WHERE is_active = 1').all()
for (const workspace of workspaces) {
const dayStart = `${date}T00:00:00.000Z`
const dayEnd = `${date}T23:59:59.999Z`
// Get booking statistics for the day
const stats = db.prepare(`
SELECT
COUNT(*) as total_bookings,
COUNT(DISTINCT user_id) as unique_users,
SUM(
CASE
WHEN status = 'no_show' OR (status = 'confirmed' AND check_in_time IS NULL AND end_time < ?)
THEN 1
ELSE 0
END
) as no_show_count,
SUM(
CAST((julianday(end_time) - julianday(start_time)) * 24 AS REAL)
) as total_hours_booked
FROM bookings
WHERE workspace_id = ?
AND start_time >= ? AND start_time <= ?
AND status != 'cancelled'
`).get(new Date().toISOString(), (workspace as any).id, dayStart, dayEnd)
// Calculate peak hour
const peakHour = db.prepare(`
SELECT
CAST(strftime('%H', start_time) AS INTEGER) as hour,
COUNT(*) as count
FROM bookings
WHERE workspace_id = ?
AND start_time >= ? AND start_time <= ?
AND status != 'cancelled'
GROUP BY hour
ORDER BY count DESC
LIMIT 1
`).get((workspace as any).id, dayStart, dayEnd)
// Calculate utilization rate (assuming 10 hour work day)
const workHoursPerDay = 10
const utilizationRate = (stats as any).total_hours_booked / workHoursPerDay
// Insert or update analytics record
db.prepare(`
INSERT OR REPLACE INTO workspace_analytics (
id, workspace_id, date, total_bookings, total_hours_booked,
utilization_rate, no_show_count, unique_users, peak_hour
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`${(workspace as any).id}-${date}`,
(workspace as any).id,
date,
(stats as any).total_bookings || 0,
(stats as any).total_hours_booked || 0,
utilizationRate || 0,
(stats as any).no_show_count || 0,
(stats as any).unique_users || 0,
(peakHour as any)?.hour || null
)
}
res.json({ message: 'Analytics updated successfully' })
} catch (error) {
console.error('Error updating analytics:', error)
res.status(500).json({ error: 'Failed to update analytics' })
}
})
export default router

121
backend/src/routes/auth.ts Normale Datei
Datei anzeigen

@ -0,0 +1,121 @@
import { Router, Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { body, validationResult } from 'express-validator'
import { db } from '../config/secureDatabase'
import { User, LoginRequest, LoginResponse } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { logger } from '../utils/logger'
const router = Router()
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
router.post('/login',
[
body('username').optional().notEmpty().trim(),
body('email').optional().isEmail().normalizeEmail(),
body('password').notEmpty()
],
async (req: Request, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { username, email, password } = req.body
// Determine login identifier (email takes precedence)
const loginIdentifier = email || username
if (!loginIdentifier) {
return res.status(400).json({
success: false,
error: { message: 'Either username or email is required' }
})
}
let userRow: any
// Try to find by email first (if looks like email), then by username
if (loginIdentifier.includes('@')) {
// Login with email
const emailHash = FieldEncryption.hash(loginIdentifier)
userRow = db.prepare(`
SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
WHERE email_hash = ? AND is_active = 1
`).get(emailHash) as any
} else {
// Login with username
userRow = db.prepare(`
SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
WHERE username = ? AND is_active = 1
`).get(loginIdentifier) as any
}
if (!userRow) {
return res.status(401).json({
success: false,
error: { message: 'Invalid credentials' }
})
}
// Check password
const isValidPassword = await bcrypt.compare(password, userRow.password)
if (!isValidPassword) {
return res.status(401).json({
success: false,
error: { message: 'Invalid credentials' }
})
}
// Update last login
const now = new Date().toISOString()
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
// Create user object without password (decrypt email)
const user: User = {
id: userRow.id,
username: userRow.username,
email: FieldEncryption.decrypt(userRow.email) || '',
role: userRow.role,
employeeId: userRow.employee_id,
lastLogin: new Date(now),
isActive: Boolean(userRow.is_active),
createdAt: new Date(userRow.created_at),
updatedAt: new Date(userRow.updated_at)
}
// Generate token
const token = jwt.sign(
{ user },
JWT_SECRET,
{ expiresIn: '24h' }
)
const response: LoginResponse = {
user,
token: {
accessToken: token,
expiresIn: 86400,
tokenType: 'Bearer'
}
}
logger.info(`User ${loginIdentifier} logged in successfully`)
res.json({ success: true, data: response })
} catch (error) {
next(error)
}
}
)
router.post('/logout', (req, res) => {
res.json({ success: true, message: 'Logged out successfully' })
})
export default router

330
backend/src/routes/bookings.ts Normale Datei
Datei anzeigen

@ -0,0 +1,330 @@
import { Router, Response } from 'express'
import { db } from '../config/database'
import { authenticateToken, AuthRequest } from '../middleware/auth'
import { v4 as uuidv4 } from 'uuid'
import { Booking, BookingRequest } from '@skillmate/shared'
const router = Router()
// Get user's bookings
router.get('/my-bookings', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const { status, from_date, to_date } = req.query
let query = `
SELECT b.*, w.name as workspace_name, w.type as workspace_type,
w.floor, w.building, e.first_name, e.last_name, e.photo
FROM bookings b
JOIN workspaces w ON b.workspace_id = w.id
JOIN employees e ON b.employee_id = e.id
WHERE b.user_id = ?
`
const params: any[] = [req.user!.id]
if (status) {
query += ' AND b.status = ?'
params.push(status)
}
if (from_date) {
query += ' AND b.start_time >= ?'
params.push(from_date)
}
if (to_date) {
query += ' AND b.end_time <= ?'
params.push(to_date)
}
query += ' ORDER BY b.start_time DESC'
const bookings = db.prepare(query).all(...params)
res.json(bookings)
} catch (error) {
console.error('Error fetching bookings:', error)
res.status(500).json({ error: 'Failed to fetch bookings' })
}
})
// Get all bookings (admin/superuser)
router.get('/', authenticateToken, (req: AuthRequest, res: Response) => {
if (!['admin', 'superuser'].includes(req.user!.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
try {
const { workspace_id, user_id, status, from_date, to_date } = req.query
let query = `
SELECT b.*, w.name as workspace_name, w.type as workspace_type,
w.floor, w.building, e.first_name, e.last_name, e.photo,
u.username
FROM bookings b
JOIN workspaces w ON b.workspace_id = w.id
JOIN employees e ON b.employee_id = e.id
JOIN users u ON b.user_id = u.id
WHERE 1=1
`
const params: any[] = []
if (workspace_id) {
query += ' AND b.workspace_id = ?'
params.push(workspace_id)
}
if (user_id) {
query += ' AND b.user_id = ?'
params.push(user_id)
}
if (status) {
query += ' AND b.status = ?'
params.push(status)
}
if (from_date) {
query += ' AND b.start_time >= ?'
params.push(from_date)
}
if (to_date) {
query += ' AND b.end_time <= ?'
params.push(to_date)
}
query += ' ORDER BY b.start_time DESC'
const bookings = db.prepare(query).all(...params)
res.json(bookings)
} catch (error) {
console.error('Error fetching bookings:', error)
res.status(500).json({ error: 'Failed to fetch bookings' })
}
})
// Create booking
router.post('/', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const bookingRequest: BookingRequest = req.body
const { workspace_id, start_time, end_time, notes, recurring } = bookingRequest
if (!workspace_id || !start_time || !end_time) {
return res.status(400).json({ error: 'Missing required fields' })
}
// Check if workspace exists and is active
const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ? AND is_active = 1').get(workspace_id)
if (!workspace) {
return res.status(404).json({ error: 'Workspace not found or inactive' })
}
// Check for conflicts
const conflict = db.prepare(`
SELECT COUNT(*) as count FROM bookings
WHERE workspace_id = ? AND status = 'confirmed'
AND (
(start_time <= ? AND end_time > ?)
OR (start_time < ? AND end_time >= ?)
OR (start_time >= ? AND end_time <= ?)
)
`).get(workspace_id, start_time, start_time, end_time, end_time, start_time, end_time)
if ((conflict as any).count > 0) {
return res.status(409).json({ error: 'Time slot already booked' })
}
// Check booking rules
const rules = db.prepare('SELECT * FROM booking_rules WHERE workspace_type = ?').get((workspace as any).type)
if (rules) {
// Validate against rules
const startDate = new Date(start_time)
const endDate = new Date(end_time)
const now = new Date()
// Check max duration
if ((rules as any).max_duration_hours) {
const durationHours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60)
if (durationHours > (rules as any).max_duration_hours) {
return res.status(400).json({
error: `Maximum booking duration is ${(rules as any).max_duration_hours} hours`
})
}
}
// Check advance booking limits
if ((rules as any).max_advance_days) {
const daysInAdvance = (startDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (daysInAdvance > (rules as any).max_advance_days) {
return res.status(400).json({
error: `Cannot book more than ${(rules as any).max_advance_days} days in advance`
})
}
}
if ((rules as any).min_advance_hours) {
const hoursInAdvance = (startDate.getTime() - now.getTime()) / (1000 * 60 * 60)
if (hoursInAdvance < (rules as any).min_advance_hours) {
return res.status(400).json({
error: `Must book at least ${(rules as any).min_advance_hours} hours in advance`
})
}
}
}
const bookingId = uuidv4()
const now = new Date().toISOString()
db.prepare(`
INSERT INTO bookings (
id, workspace_id, user_id, employee_id, start_time, end_time,
status, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
bookingId, workspace_id, req.user!.id, req.user!.employeeId,
start_time, end_time, 'confirmed', notes, now, now
)
// Handle recurring bookings
if (recurring) {
const recurringId = uuidv4()
const startDate = new Date(start_time)
const startTimeOnly = startDate.toTimeString().substring(0, 8)
const endTimeOnly = new Date(end_time).toTimeString().substring(0, 8)
db.prepare(`
INSERT INTO recurring_bookings (
id, workspace_id, user_id, employee_id, start_date, end_date,
time_start, time_end, days_of_week, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
recurringId, workspace_id, req.user!.id, req.user!.employeeId,
start_time, recurring.end_date, startTimeOnly, endTimeOnly,
JSON.stringify(recurring.days_of_week), now
)
// Create individual bookings for the recurring series
// This is a simplified version - in production you'd want a background job
createRecurringBookings(recurringId, bookingRequest, req.user)
}
const newBooking = db.prepare(`
SELECT b.*, w.name as workspace_name, w.type as workspace_type
FROM bookings b
JOIN workspaces w ON b.workspace_id = w.id
WHERE b.id = ?
`).get(bookingId)
res.status(201).json(newBooking)
} catch (error) {
console.error('Error creating booking:', error)
res.status(500).json({ error: 'Failed to create booking' })
}
})
// Check in to booking
router.post('/:id/check-in', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const booking = db.prepare('SELECT * FROM bookings WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user!.id)
if (!booking) {
return res.status(404).json({ error: 'Booking not found' })
}
if ((booking as any).status !== 'confirmed') {
return res.status(400).json({ error: 'Booking is not confirmed' })
}
const now = new Date()
const startTime = new Date((booking as any).start_time)
// Allow check-in 15 minutes before start time
const earliestCheckIn = new Date(startTime.getTime() - 15 * 60 * 1000)
if (now < earliestCheckIn) {
return res.status(400).json({ error: 'Too early to check in' })
}
db.prepare(`
UPDATE bookings
SET check_in_time = ?, updated_at = ?
WHERE id = ?
`).run(now.toISOString(), now.toISOString(), req.params.id)
res.json({ message: 'Checked in successfully' })
} catch (error) {
console.error('Error checking in:', error)
res.status(500).json({ error: 'Failed to check in' })
}
})
// Check out from booking
router.post('/:id/check-out', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const booking = db.prepare('SELECT * FROM bookings WHERE id = ? AND user_id = ?')
.get(req.params.id, req.user!.id)
if (!booking) {
return res.status(404).json({ error: 'Booking not found' })
}
if (!(booking as any).check_in_time) {
return res.status(400).json({ error: 'Not checked in' })
}
const now = new Date().toISOString()
db.prepare(`
UPDATE bookings
SET check_out_time = ?, status = 'completed', updated_at = ?
WHERE id = ?
`).run(now, now, req.params.id)
res.json({ message: 'Checked out successfully' })
} catch (error) {
console.error('Error checking out:', error)
res.status(500).json({ error: 'Failed to check out' })
}
})
// Cancel booking
router.post('/:id/cancel', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id)
if (!booking) {
return res.status(404).json({ error: 'Booking not found' })
}
// Users can only cancel their own bookings, admins can cancel any
if ((booking as any).user_id !== req.user!.id && req.user!.role !== 'admin') {
return res.status(403).json({ error: 'Not authorized to cancel this booking' })
}
if ((booking as any).status !== 'confirmed') {
return res.status(400).json({ error: 'Booking is not confirmed' })
}
const now = new Date().toISOString()
db.prepare(`
UPDATE bookings
SET status = 'cancelled', updated_at = ?
WHERE id = ?
`).run(now, req.params.id)
res.json({ message: 'Booking cancelled successfully' })
} catch (error) {
console.error('Error cancelling booking:', error)
res.status(500).json({ error: 'Failed to cancel booking' })
}
})
// Helper function to create recurring bookings
function createRecurringBookings(recurringId: string, request: BookingRequest, user: any) {
// This is a simplified implementation
// In production, this should be handled by a background job
// to avoid timeout issues with many bookings
}
export default router

Datei anzeigen

@ -0,0 +1,561 @@
import { Router, Response, NextFunction } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import { db } from '../config/database'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
import { Employee, LanguageSkill, Skill, UserRole } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
const router = Router()
// Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
const mapping: Record<string, 'basic' | 'fluent' | 'native' | 'business'> = {
'A1': 'basic',
'A2': 'basic',
'B1': 'business',
'B2': 'business',
'C1': 'fluent',
'C2': 'fluent',
'Muttersprache': 'native',
'native': 'native',
'fluent': 'fluent',
'advanced': 'business',
'intermediate': 'business',
'basic': 'basic'
}
return mapping[proficiency] || 'basic'
}
// Get all employees
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const employees = db.prepare(`
SELECT id, first_name, last_name, employee_number, photo, position,
department, email, phone, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by, updated_by
FROM employees
ORDER BY last_name, first_name
`).all()
const employeesWithDetails = employees.map((emp: any) => {
// Get skills
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
FROM employee_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.employee_id = ?
`).all(emp.id)
// Get languages
const languages = db.prepare(`
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
FROM language_skills
WHERE employee_id = ?
`).all(emp.id)
// Get specializations
const specializations = db.prepare(`
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
employeeNumber: emp.employee_number,
photo: emp.photo,
position: emp.position,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
name: s.name,
category: s.category,
level: s.level,
verified: Boolean(s.verified),
verifiedBy: s.verified_by,
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
})),
languages: languages.map((l: any) => ({
code: l.language, // Map language to code for new interface
level: mapProficiencyToLevel(l.proficiency) // Map old proficiency to new level
})),
clearance: emp.clearance_level ? {
level: emp.clearance_level,
validUntil: new Date(emp.clearance_valid_until),
issuedDate: new Date(emp.clearance_issued_date)
} : undefined,
specializations,
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
}
return employee
})
res.json({ success: true, data: employeesWithDetails })
} catch (error) {
next(error)
}
})
// Get employee by ID
router.get('/:id', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const emp = db.prepare(`
SELECT id, first_name, last_name, employee_number, photo, position,
department, email, phone, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by, updated_by
FROM employees
WHERE id = ?
`).get(id) as any
if (!emp) {
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
// Get skills
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
FROM employee_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.employee_id = ?
`).all(emp.id)
// Get languages
const languages = db.prepare(`
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
FROM language_skills
WHERE employee_id = ?
`).all(emp.id)
// Get specializations
const specializations = db.prepare(`
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
employeeNumber: emp.employee_number,
photo: emp.photo,
position: emp.position,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
name: s.name,
category: s.category,
level: s.level,
verified: Boolean(s.verified),
verifiedBy: s.verified_by,
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
})),
languages: languages.map((l: any) => ({
code: l.language, // Map language to code for new interface
level: mapProficiencyToLevel(l.proficiency) // Map old proficiency to new level
})),
clearance: emp.clearance_level ? {
level: emp.clearance_level,
validUntil: new Date(emp.clearance_valid_until),
issuedDate: new Date(emp.clearance_issued_date)
} : undefined,
specializations,
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
}
res.json({ success: true, data: employee })
} catch (error) {
next(error)
}
})
// Create employee (admin/poweruser only)
router.post('/',
authenticate,
requirePermission('employees:create'),
[
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('email').isEmail(),
body('department').notEmpty().trim()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const employeeId = uuidv4()
const now = new Date().toISOString()
const {
firstName, lastName, employeeNumber, photo, position,
department, email, phone, mobile, office, availability,
clearance, skills, languages, specializations, userRole, createUser
} = req.body
// Insert employee with default values for missing fields
db.prepare(`
INSERT INTO employees (
id, first_name, last_name, employee_number, photo, position,
department, email, phone, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
employeeId,
firstName,
lastName,
employeeNumber || null,
photo || null,
position || 'Mitarbeiter', // Default position
department,
email,
phone || 'Nicht angegeben', // Default phone
mobile || null,
office || null,
availability || 'available', // Default availability
clearance?.level || null,
clearance?.validUntil || null,
clearance?.issuedDate || null,
now, now, req.user!.id
)
// Insert skills
if (skills && skills.length > 0) {
const insertSkill = db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`)
for (const skill of skills) {
insertSkill.run(
employeeId,
skill.id,
skill.level || null,
skill.verified ? 1 : 0,
skill.verifiedBy || null,
skill.verifiedDate || null
)
}
}
// Insert languages
if (languages && languages.length > 0) {
const insertLang = db.prepare(`
INSERT INTO language_skills (
id, employee_id, language, proficiency, certified,
certificate_type, is_native, can_interpret
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const lang of languages) {
insertLang.run(
uuidv4(),
employeeId,
lang.language,
lang.proficiency,
lang.certified ? 1 : 0,
lang.certificateType || null,
lang.isNative ? 1 : 0,
lang.canInterpret ? 1 : 0
)
}
}
// Insert specializations
if (specializations && specializations.length > 0) {
const insertSpec = db.prepare(`
INSERT INTO specializations (id, employee_id, name)
VALUES (?, ?, ?)
`)
for (const spec of specializations) {
insertSpec.run(uuidv4(), employeeId, spec)
}
}
// Queue sync for new employee
const newEmployee = {
id: employeeId,
firstName,
lastName,
employeeNumber: employeeNumber || null,
photo: photo || null,
position: position || 'Mitarbeiter',
department,
email,
phone: phone || 'Nicht angegeben',
mobile: mobile || null,
office: office || null,
availability: availability || 'available',
clearance,
skills: skills || [],
languages: languages || [],
specializations: specializations || [],
createdAt: now,
updatedAt: now,
createdBy: req.user!.id
}
// Create user account if requested
let userId = null
let temporaryPassword = null
if (createUser && userRole) {
try {
userId = uuidv4()
// Generate a secure temporary password
temporaryPassword = `TempPass${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(temporaryPassword, 10)
// Encrypt email for user table storage
const encryptedEmail = FieldEncryption.encrypt(email)
db.prepare(`
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId, email, encryptedEmail, hashedPassword, userRole, employeeId, 1, now, now
)
console.log(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
} catch (userError) {
console.error('Error creating user account:', userError)
// Continue without failing the employee creation
temporaryPassword = null
}
}
await syncService.queueSync('employees', 'create', newEmployee)
res.status(201).json({
success: true,
data: {
id: employeeId,
userId: userId,
temporaryPassword: temporaryPassword
},
message: `Employee created successfully${createUser ? ' with user account' : ''}`
})
} catch (error) {
next(error)
}
}
)
// Update employee (admin/poweruser only)
router.put('/:id',
authenticate,
requireEditPermission(req => req.params.id),
[
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('position').notEmpty().trim(),
body('department').notEmpty().trim(),
body('email').isEmail(),
body('phone').notEmpty().trim(),
body('availability').isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { id } = req.params
const now = new Date().toISOString()
const {
firstName, lastName, position, department, email, phone,
mobile, office, availability, clearance, skills, languages, specializations
} = req.body
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
// Update employee
db.prepare(`
UPDATE employees SET
first_name = ?, last_name = ?, position = ?, department = ?,
email = ?, phone = ?, mobile = ?, office = ?, availability = ?,
clearance_level = ?, clearance_valid_until = ?, clearance_issued_date = ?,
updated_at = ?, updated_by = ?
WHERE id = ?
`).run(
firstName, lastName, position, department,
email, phone, mobile || null, office || null, availability,
clearance?.level || null, clearance?.validUntil || null, clearance?.issuedDate || null,
now, req.user!.id, id
)
// Update skills
if (skills !== undefined) {
// Delete existing skills
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
// Insert new skills
if (skills.length > 0) {
const insertSkill = db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`)
for (const skill of skills) {
insertSkill.run(
id,
skill.id,
skill.level || null,
skill.verified ? 1 : 0,
skill.verifiedBy || null,
skill.verifiedDate || null
)
}
}
}
// Queue sync for updated employee
const updatedEmployee = {
id,
firstName,
lastName,
position,
department,
email,
phone,
mobile: mobile || null,
office: office || null,
availability,
clearance,
skills,
languages,
specializations,
updatedAt: now,
updatedBy: req.user!.id
}
await syncService.queueSync('employees', 'update', updatedEmployee)
res.json({
success: true,
message: 'Employee updated successfully'
})
} catch (error) {
next(error)
}
}
)
// Delete employee (admin only)
router.delete('/:id',
authenticate,
requirePermission('employees:delete'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
// Delete employee and related data
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM employees WHERE id = ?').run(id)
// Queue sync for deleted employee
await syncService.queueSync('employees', 'delete', { id })
res.json({
success: true,
message: 'Employee deleted successfully'
})
} catch (error) {
next(error)
}
}
)
// Search employees by skills
router.post('/search', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const { skills, category } = req.body
let query = `
SELECT DISTINCT e.id, e.first_name, e.last_name, e.employee_number,
e.position, e.department, e.availability
FROM employees e
JOIN employee_skills es ON e.id = es.employee_id
JOIN skills s ON es.skill_id = s.id
WHERE 1=1
`
const params: any[] = []
if (skills && skills.length > 0) {
const placeholders = skills.map(() => '?').join(',')
query += ` AND s.name IN (${placeholders})`
params.push(...skills)
}
if (category) {
query += ` AND s.category = ?`
params.push(category)
}
query += ` ORDER BY e.last_name, e.first_name`
const results = db.prepare(query).all(...params)
res.json({ success: true, data: results })
} catch (error) {
next(error)
}
})
export default router

Datei anzeigen

@ -0,0 +1,813 @@
import { Router, Response, NextFunction } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
import { Employee, LanguageSkill, Skill, UserRole } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
const router = Router()
// Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
const mapping: Record<string, 'basic' | 'fluent' | 'native' | 'business'> = {
'A1': 'basic',
'A2': 'basic',
'B1': 'business',
'B2': 'business',
'C1': 'fluent',
'C2': 'fluent',
'Muttersprache': 'native',
'native': 'native',
'fluent': 'fluent',
'advanced': 'business',
'intermediate': 'business',
'basic': 'basic'
}
return mapping[proficiency] || 'basic'
}
// Log security audit events
function logSecurityAudit(
action: string,
entityType: string,
entityId: string,
userId: string,
req: AuthRequest,
riskLevel: 'low' | 'medium' | 'high' | 'critical' = 'low'
) {
try {
db.prepare(`
INSERT INTO security_audit_log (
id, entity_type, entity_id, action, user_id,
timestamp, ip_address, user_agent, risk_level
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
entityType,
entityId,
action,
userId,
new Date().toISOString(),
req.ip || req.connection.remoteAddress,
req.get('user-agent'),
riskLevel
)
} catch (error) {
logger.error('Failed to log security audit:', error)
}
}
// Get all employees
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const employees = encryptedDb.getAllEmployees()
const employeesWithDetails = employees.map((emp: any) => {
// Get skills
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
FROM employee_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.employee_id = ?
`).all(emp.id)
// Get languages
const languages = db.prepare(`
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
FROM language_skills
WHERE employee_id = ?
`).all(emp.id)
// Get specializations
const specializations = db.prepare(`
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
photo: emp.photo,
position: emp.position,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
name: s.name,
category: s.category,
level: s.level,
verified: Boolean(s.verified),
verifiedBy: s.verified_by,
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
})),
languages: languages.map((l: any) => ({
code: l.language,
level: mapProficiencyToLevel(l.proficiency)
})),
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? {
level: emp.clearance_level,
validUntil: new Date(emp.clearance_valid_until),
issuedDate: new Date(emp.clearance_issued_date)
} : undefined,
specializations,
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
}
return employee
})
// Log read access
logSecurityAudit('read', 'employees', 'all', req.user!.id, req, 'low')
res.json({ success: true, data: employeesWithDetails })
} catch (error) {
logger.error('Error fetching employees:', error)
next(error)
}
})
// Public employees for frontend: only those with a linked user (excluding admin)
router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
try {
// Get allowed employee IDs from users table (exclude admin, only active accounts)
const allowedUserLinks = db.prepare(`
SELECT employee_id FROM users
WHERE employee_id IS NOT NULL AND username <> 'admin' AND is_active = 1
`).all() as any[]
const allowedIds = new Set((allowedUserLinks || []).map((u: any) => u.employee_id))
const employees = encryptedDb.getAllEmployees()
.filter((emp: any) => allowedIds.has(emp.id))
const employeesWithDetails = employees.map((emp: any) => {
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
FROM employee_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.employee_id = ?
`).all(emp.id)
const languages = db.prepare(`
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
FROM language_skills
WHERE employee_id = ?
`).all(emp.id)
const specializations = db.prepare(`
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
photo: emp.photo,
position: emp.position,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
name: s.name,
category: s.category,
level: s.level,
verified: Boolean(s.verified),
verifiedBy: s.verified_by,
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
})),
languages: languages.map((l: any) => ({
code: l.language,
level: mapProficiencyToLevel(l.proficiency)
})),
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? {
level: emp.clearance_level,
validUntil: new Date(emp.clearance_valid_until),
issuedDate: new Date(emp.clearance_issued_date)
} : undefined,
specializations,
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
}
return employee
})
res.json({ success: true, data: employeesWithDetails })
} catch (error) {
logger.error('Error fetching public employees:', error)
next(error)
}
})
// Get employee by ID
router.get('/:id', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const emp = encryptedDb.getEmployee(id)
if (!emp) {
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
// Get skills
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
FROM employee_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.employee_id = ?
`).all(emp.id)
// Get languages
const languages = db.prepare(`
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
FROM language_skills
WHERE employee_id = ?
`).all(emp.id)
// Get specializations
const specializations = db.prepare(`
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
photo: emp.photo,
position: emp.position,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
name: s.name,
category: s.category,
level: s.level,
verified: Boolean(s.verified),
verifiedBy: s.verified_by,
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
})),
languages: languages.map((l: any) => ({
code: l.language,
level: mapProficiencyToLevel(l.proficiency)
})),
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? {
level: emp.clearance_level,
validUntil: new Date(emp.clearance_valid_until),
issuedDate: new Date(emp.clearance_issued_date)
} : undefined,
specializations,
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
}
// Log read access
logSecurityAudit('read', 'employees', id, req.user!.id, req, 'low')
res.json({ success: true, data: employee })
} catch (error) {
logger.error('Error fetching employee:', error)
next(error)
}
})
// Create employee (admin/poweruser only)
router.post('/',
authenticate,
requirePermission('employees:create'),
[
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(),
body('position').optional().trim().escape(), // Optional
body('phone').optional().trim(), // Optional - kann später ergänzt werden
body('employeeNumber').optional().trim() // Optional - wird automatisch generiert wenn leer
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const employeeId = uuidv4()
const now = new Date().toISOString()
const {
firstName, lastName, employeeNumber, photo, position = 'Mitarbeiter',
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
clearance, skills = [], languages = [], specializations = [], userRole, createUser
} = req.body
// Generate employee number if not provided
const finalEmployeeNumber = employeeNumber || `EMP${Date.now()}`
// Check if employee number already exists
const existingEmployee = db.prepare('SELECT id FROM employees WHERE employee_number = ?').get(finalEmployeeNumber)
if (existingEmployee) {
return res.status(409).json({
success: false,
error: { message: 'Employee number already exists' }
})
}
// Insert employee with encrypted fields
encryptedDb.insertEmployee({
id: employeeId,
first_name: firstName,
last_name: lastName,
employee_number: finalEmployeeNumber,
photo: photo || null,
position,
department,
email,
phone,
mobile: mobile || null,
office: office || null,
availability,
clearance_level: clearance?.level || null,
clearance_valid_until: clearance?.validUntil || null,
clearance_issued_date: clearance?.issuedDate || null,
created_at: now,
updated_at: now,
created_by: req.user!.id
})
// Insert skills (only if they exist in skills table)
if (skills && skills.length > 0) {
const insertSkill = db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`)
const checkSkillExists = db.prepare('SELECT id FROM skills WHERE id = ?')
for (const skill of skills) {
// Check if skill exists before inserting
const skillExists = checkSkillExists.get(skill.id)
if (skillExists) {
insertSkill.run(
employeeId,
skill.id,
skill.level || null,
skill.verified ? 1 : 0,
skill.verifiedBy || null,
skill.verifiedDate || null
)
} else {
logger.warn(`Skill with ID ${skill.id} does not exist in skills table, skipping`)
}
}
}
// Insert languages
if (languages && languages.length > 0) {
const insertLang = db.prepare(`
INSERT INTO language_skills (
id, employee_id, language, proficiency, certified,
certificate_type, is_native, can_interpret
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const lang of languages) {
insertLang.run(
uuidv4(),
employeeId,
lang.code || lang.language,
lang.level || lang.proficiency || 'basic',
lang.certified ? 1 : 0,
lang.certificateType || null,
lang.isNative ? 1 : 0,
lang.canInterpret ? 1 : 0
)
}
}
// Insert specializations
if (specializations && specializations.length > 0) {
const insertSpec = db.prepare(`
INSERT INTO specializations (id, employee_id, name)
VALUES (?, ?, ?)
`)
for (const spec of specializations) {
insertSpec.run(uuidv4(), employeeId, spec)
}
}
// Create user account if requested
let userId = null
let tempPassword = null
if (createUser) {
userId = uuidv4()
tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = bcrypt.hashSync(tempPassword, 12)
// Enforce role policy: only admins may assign roles; others default to 'user'
const assignedRole = req.user?.role === 'admin' && userRole ? userRole : 'user'
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
email,
FieldEncryption.encrypt(email),
FieldEncryption.hash(email),
hashedPassword,
assignedRole,
employeeId,
1,
now,
now
)
logger.info(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
}
// Log creation
logSecurityAudit('create', 'employees', employeeId, req.user!.id, req, 'medium')
// Queue sync for new employee
const newEmployee = {
id: employeeId,
firstName,
lastName,
employeeNumber: finalEmployeeNumber,
photo: photo || null,
position,
department,
email,
phone,
mobile: mobile || null,
office: office || null,
availability,
clearance,
skills,
languages,
specializations,
createdAt: now,
updatedAt: now,
createdBy: req.user!.id
}
syncService.queueSync('employees', 'create', newEmployee).catch(err => {
logger.error('Failed to queue sync:', err)
})
return res.status(201).json({
success: true,
data: {
id: employeeId,
userId: userId,
temporaryPassword: tempPassword
},
message: `Employee created successfully${createUser ? ' with user account' : ''}`
})
} catch (error) {
logger.error('Error creating employee:', error)
throw error
}
})
try {
const result = transaction()
// Send email after successful transaction (if user was created)
const { createUser, userRole, email, firstName } = req.body
if (createUser && userRole && result && (result as any).data?.userId && (result as any).data?.temporaryPassword) {
// Check if email notifications are enabled
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
// Send initial password via email if notifications are enabled
if (emailNotificationsEnabled && emailService.isServiceEnabled()) {
const emailSent = await emailService.sendInitialPassword(email, (result as any).data.temporaryPassword, firstName)
if (emailSent) {
logger.info(`Initial password email sent to ${email}`)
} else {
logger.warn(`Failed to send initial password email to ${email}`)
}
}
}
return result
} catch (error: any) {
logger.error('Transaction failed:', error)
return res.status(500).json({
success: false,
error: {
message: 'Failed to create employee',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}
})
}
}
)
// Update employee
router.put('/:id',
authenticate,
requireEditPermission(req => req.params.id),
[
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('position').optional().trim().escape(),
body('department').notEmpty().trim().escape(),
body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),
body('availability').optional().isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { id } = req.params
const now = new Date().toISOString()
const {
firstName, lastName, position = 'Mitarbeiter', department, email, phone = 'Nicht angegeben',
mobile, office, availability = 'available', clearance, skills, languages, specializations,
employeeNumber
} = req.body
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
// Update employee with encrypted fields
db.prepare(`
UPDATE employees SET
first_name = ?, last_name = ?, position = ?, department = ?,
email = ?, email_hash = ?, phone = ?, phone_hash = ?,
mobile = ?, office = ?, availability = ?,
clearance_level = ?, clearance_valid_until = ?, clearance_issued_date = ?,
updated_at = ?, updated_by = ?
WHERE id = ?
`).run(
firstName, lastName, position, department,
FieldEncryption.encrypt(email),
FieldEncryption.hash(email),
FieldEncryption.encrypt(phone || ''),
FieldEncryption.hash(phone || ''),
mobile ? FieldEncryption.encrypt(mobile) : null,
office || null,
availability,
clearance?.level || null,
clearance?.validUntil || null,
clearance?.issuedDate || null,
now,
req.user!.id,
id
)
// Optionally update employee number (NW-Kennung) with uniqueness check
if (employeeNumber && typeof employeeNumber === 'string') {
const existsNumber = db.prepare('SELECT id FROM employees WHERE employee_number = ? AND id <> ?').get(employeeNumber, id)
if (existsNumber) {
return res.status(409).json({ success: false, error: { message: 'Employee number already exists' } })
}
db.prepare('UPDATE employees SET employee_number = ?, updated_at = ?, updated_by = ? WHERE id = ?')
.run(employeeNumber, now, req.user!.id, id)
}
// Update skills
if (skills !== undefined) {
// Delete existing skills
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
// Insert new skills
if (skills.length > 0) {
const insertSkill = db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`)
const checkSkillExists = db.prepare('SELECT id FROM skills WHERE id = ?')
for (const skill of skills) {
const exists = checkSkillExists.get(skill.id)
if (!exists) {
logger.warn(`Skill with ID ${skill.id} does not exist in skills table, skipping`)
continue
}
insertSkill.run(
id,
skill.id,
typeof skill.level === 'number' ? skill.level : (parseInt(skill.level) || null),
skill.verified ? 1 : 0,
skill.verifiedBy || null,
skill.verifiedDate || null
)
}
}
}
// Log update
logSecurityAudit('update', 'employees', id, req.user!.id, req, 'medium')
// Queue sync for updated employee
const updatedEmployee = {
id,
firstName,
lastName,
position,
department,
email,
phone,
mobile: mobile || null,
office: office || null,
availability,
clearance,
skills,
languages,
specializations,
updatedAt: now,
updatedBy: req.user!.id
}
syncService.queueSync('employees', 'update', updatedEmployee).catch(err => {
logger.error('Failed to queue sync:', err)
})
return res.json({
success: true,
message: 'Employee updated successfully'
})
} catch (error) {
logger.error('Error updating employee:', error)
throw error
}
})
try {
return transaction()
} catch (error: any) {
logger.error('Transaction failed:', error)
return res.status(500).json({
success: false,
error: {
message: 'Failed to update employee',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}
})
}
}
)
// Delete employee (admin only)
router.delete('/:id',
authenticate,
requirePermission('employees:delete'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
try {
const { id } = req.params
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
// Delete employee and related data
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(id)
db.prepare('DELETE FROM employees WHERE id = ?').run(id)
// Log deletion
logSecurityAudit('delete', 'employees', id, req.user!.id, req, 'high')
// Queue sync for deleted employee
syncService.queueSync('employees', 'delete', { id }).catch(err => {
logger.error('Failed to queue sync:', err)
})
return res.json({
success: true,
message: 'Employee deleted successfully'
})
} catch (error) {
logger.error('Error deleting employee:', error)
throw error
}
})
try {
return transaction()
} catch (error: any) {
logger.error('Transaction failed:', error)
return res.status(500).json({
success: false,
error: {
message: 'Failed to delete employee',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
}
})
}
}
)
// Search employees by skills
router.post('/search', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const { skills, category } = req.body
let query = `
SELECT DISTINCT e.id, e.first_name, e.last_name, e.employee_number,
e.position, e.department, e.availability,
e.email, e.phone, e.mobile
FROM employees e
JOIN employee_skills es ON e.id = es.employee_id
JOIN skills s ON es.skill_id = s.id
WHERE 1=1
`
const params: any[] = []
if (skills && skills.length > 0) {
const placeholders = skills.map(() => '?').join(',')
query += ` AND s.name IN (${placeholders})`
params.push(...skills)
}
if (category) {
query += ` AND s.category = ?`
params.push(category)
}
query += ` ORDER BY e.last_name, e.first_name`
const results = db.prepare(query).all(...params)
// Decrypt sensitive fields in results safely
const decryptedResults = results.map((emp: any) => {
const safeDecrypt = (v: any) => {
try { return v ? FieldEncryption.decrypt(v) : null } catch { return null }
}
return {
...emp,
email: safeDecrypt(emp.email),
phone: safeDecrypt(emp.phone),
mobile: safeDecrypt(emp.mobile),
}
})
res.json({ success: true, data: decryptedResults })
} catch (error) {
logger.error('Error searching employees:', error)
next(error)
}
})
export default router

338
backend/src/routes/network.ts Normale Datei
Datei anzeigen

@ -0,0 +1,338 @@
import { Router } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/database'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import crypto from 'crypto'
import { syncScheduler } from '../services/syncScheduler'
const router = Router()
// Initialize network nodes table if not exists
db.exec(`
CREATE TABLE IF NOT EXISTS network_nodes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
ip_address TEXT NOT NULL,
port INTEGER NOT NULL,
api_key TEXT NOT NULL UNIQUE,
type TEXT CHECK(type IN ('admin', 'local')) NOT NULL,
is_online INTEGER DEFAULT 0,
last_sync TEXT,
last_ping TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL
)
`)
// Initialize sync settings table
db.exec(`
CREATE TABLE IF NOT EXISTS sync_settings (
id TEXT PRIMARY KEY,
auto_sync_interval TEXT,
conflict_resolution TEXT CHECK(conflict_resolution IN ('admin', 'newest', 'manual')),
sync_employees INTEGER DEFAULT 1,
sync_skills INTEGER DEFAULT 1,
sync_users INTEGER DEFAULT 1,
sync_settings INTEGER DEFAULT 0,
bandwidth_limit INTEGER,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL
)
`)
// Get all network nodes
router.get('/nodes', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const nodes = db.prepare(`
SELECT * FROM network_nodes ORDER BY type DESC, name ASC
`).all()
res.json({
success: true,
data: nodes.map((node: any) => ({
id: node.id,
name: node.name,
location: node.location,
ipAddress: node.ip_address,
port: node.port,
apiKey: node.api_key,
type: node.type,
isOnline: Boolean(node.is_online),
lastSync: node.last_sync ? new Date(node.last_sync) : null,
lastPing: node.last_ping ? new Date(node.last_ping) : null,
createdAt: new Date(node.created_at),
updatedAt: new Date(node.updated_at)
}))
})
} catch (error) {
next(error)
}
})
// Create new network node
router.post('/nodes', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { name, location, ipAddress, port, type } = req.body
const nodeId = uuidv4()
const apiKey = generateApiKey()
const now = new Date().toISOString()
db.prepare(`
INSERT INTO network_nodes (
id, name, location, ip_address, port, api_key, type,
is_online, created_at, updated_at, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
nodeId, name, location || null, ipAddress, port, apiKey, type,
0, now, now, req.user!.id
)
res.status(201).json({
success: true,
data: {
id: nodeId,
apiKey: apiKey
},
message: 'Network node created successfully'
})
} catch (error) {
next(error)
}
})
// Update network node
router.put('/nodes/:id', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { name, location, ipAddress, port } = req.body
const now = new Date().toISOString()
const result = db.prepare(`
UPDATE network_nodes
SET name = ?, location = ?, ip_address = ?, port = ?,
updated_at = ?, updated_by = ?
WHERE id = ?
`).run(
name, location || null, ipAddress, port,
now, req.user!.id, id
)
if (result.changes === 0) {
return res.status(404).json({
success: false,
error: { message: 'Network node not found' }
})
}
res.json({
success: true,
message: 'Network node updated successfully'
})
} catch (error) {
next(error)
}
})
// Delete network node
router.delete('/nodes/:id', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const result = db.prepare('DELETE FROM network_nodes WHERE id = ?').run(id)
if (result.changes === 0) {
return res.status(404).json({
success: false,
error: { message: 'Network node not found' }
})
}
res.json({
success: true,
message: 'Network node deleted successfully'
})
} catch (error) {
next(error)
}
})
// Ping network node
router.post('/nodes/:id/ping', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const node = db.prepare('SELECT * FROM network_nodes WHERE id = ?').get(id) as any
if (!node) {
return res.status(404).json({
success: false,
error: { message: 'Network node not found' }
})
}
// TODO: Implement actual ping logic
// For now, simulate ping
const isOnline = Math.random() > 0.2 // 80% chance of being online
const now = new Date().toISOString()
db.prepare(`
UPDATE network_nodes
SET is_online = ?, last_ping = ?
WHERE id = ?
`).run(isOnline ? 1 : 0, now, id)
res.json({
success: true,
data: {
isOnline,
lastPing: now,
responseTime: isOnline ? Math.floor(Math.random() * 100) + 10 : null
}
})
} catch (error) {
next(error)
}
})
// Get sync settings
router.get('/sync-settings', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
let settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
if (!settings) {
// Create default settings
const now = new Date().toISOString()
db.prepare(`
INSERT INTO sync_settings (
id, auto_sync_interval, conflict_resolution,
sync_employees, sync_skills, sync_users, sync_settings,
bandwidth_limit, updated_at, updated_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
'default', 'disabled', 'admin',
1, 1, 1, 0,
null, now, 'system'
)
settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
}
res.json({
success: true,
data: {
autoSyncInterval: settings.auto_sync_interval,
conflictResolution: settings.conflict_resolution,
syncEmployees: Boolean(settings.sync_employees),
syncSkills: Boolean(settings.sync_skills),
syncUsers: Boolean(settings.sync_users),
syncSettings: Boolean(settings.sync_settings),
bandwidthLimit: settings.bandwidth_limit
}
})
} catch (error) {
next(error)
}
})
// Update sync settings
router.put('/sync-settings', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const {
autoSyncInterval,
conflictResolution,
syncEmployees,
syncSkills,
syncUsers,
syncSettings,
bandwidthLimit
} = req.body
const now = new Date().toISOString()
db.prepare(`
UPDATE sync_settings
SET auto_sync_interval = ?, conflict_resolution = ?,
sync_employees = ?, sync_skills = ?, sync_users = ?, sync_settings = ?,
bandwidth_limit = ?, updated_at = ?, updated_by = ?
WHERE id = ?
`).run(
autoSyncInterval,
conflictResolution,
syncEmployees ? 1 : 0,
syncSkills ? 1 : 0,
syncUsers ? 1 : 0,
syncSettings ? 1 : 0,
bandwidthLimit || null,
now,
req.user!.id,
'default'
)
res.json({
success: true,
message: 'Sync settings updated successfully'
})
} catch (error) {
next(error)
}
})
// Trigger manual sync
router.post('/sync/trigger', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { nodeIds } = req.body
// TODO: Implement actual sync logic
// For now, simulate sync process
const now = new Date().toISOString()
if (nodeIds && nodeIds.length > 0) {
// Update specific nodes
const placeholders = nodeIds.map(() => '?').join(',')
db.prepare(`
UPDATE network_nodes
SET last_sync = ?
WHERE id IN (${placeholders})
`).run(now, ...nodeIds)
} else {
// Update all nodes
db.prepare(`
UPDATE network_nodes
SET last_sync = ?
`).run(now)
}
res.json({
success: true,
message: 'Sync triggered successfully',
data: {
syncedAt: now,
nodeCount: nodeIds?.length || 'all'
}
})
} catch (error) {
next(error)
}
})
// Get sync scheduler status
router.get('/sync-status', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const status = syncScheduler.getStatus()
res.json({
success: true,
data: status
})
} catch (error) {
next(error)
}
})
function generateApiKey(): string {
return crypto.randomBytes(32).toString('hex')
}
export default router

700
backend/src/routes/profiles.ts Normale Datei
Datei anzeigen

@ -0,0 +1,700 @@
import { Router, Response, NextFunction } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { body, validationResult, query } from 'express-validator'
import { db } from '../config/database'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
// Profile Interface direkt definiert, da shared module noch nicht aktualisiert
interface Profile {
id: string
name: string
department?: string
location?: string
role?: string
contacts?: {
email?: string
phone?: string
teams?: string
}
domains?: string[]
tools?: string[]
methods?: string[]
industryKnowledge?: string[]
regulatory?: string[]
languages?: { code: string; level: string }[]
projects?: { title: string; role?: string; summary?: string; links?: string[] }[]
networks?: string[]
digitalSkills?: string[]
socialSkills?: string[]
jobCategory?: string
jobTitle?: string
jobDesc?: string
consentPublicProfile: boolean
consentSearchable: boolean
updatedAt: string
updatedBy: string
reviewDueAt?: string
}
const router = Router()
// Hilfsfunktion f\u00fcr Volltextsuche
function buildSearchVector(profile: any): string {
const parts = [
profile.name,
profile.role,
profile.jobTitle,
profile.jobDesc,
...(profile.domains || []),
...(profile.tools || []),
...(profile.methods || []),
...(profile.industryKnowledge || []),
...(profile.digitalSkills || [])
].filter(Boolean)
return parts.join(' ').toLowerCase()
}
// GET /api/profiles - Suche mit Facetten
router.get('/',
authenticate,
[
query('query').optional().trim(),
query('dept').optional(),
query('loc').optional(),
query('jobCat').optional(),
query('tools').optional(),
query('methods').optional(),
query('lang').optional(),
query('page').optional().isInt({ min: 1 }).toInt(),
query('pageSize').optional().isInt({ min: 1, max: 100 }).toInt(),
query('sort').optional().isIn(['recency', 'relevance'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid query parameters', details: errors.array() }
})
}
const {
query: searchQuery,
dept,
loc,
jobCat,
tools,
methods,
lang,
page = 1,
pageSize = 20,
sort = 'relevance'
} = req.query
// Basis-Query mit Consent-Pr\u00fcfung
let sql = `
SELECT DISTINCT p.*
FROM profiles p
WHERE (p.consent_searchable = 1 OR p.updated_by = ?)
`
const params: any[] = [req.user!.id]
// Volltextsuche
if (searchQuery) {
sql += ` AND p.search_vector LIKE ?`
params.push(`%${searchQuery}%`)
}
// Facettenfilter
if (dept) {
sql += ` AND p.department = ?`
params.push(dept)
}
if (loc) {
sql += ` AND p.location = ?`
params.push(loc)
}
if (jobCat) {
sql += ` AND p.job_category = ?`
params.push(jobCat)
}
// Tool-Filter
if (tools) {
const toolList = tools.toString().split(',')
sql += ` AND EXISTS (
SELECT 1 FROM profile_tools pt
WHERE pt.profile_id = p.id
AND pt.tool IN (${toolList.map(() => '?').join(',')})
)`
params.push(...toolList)
}
// Methoden-Filter
if (methods) {
const methodList = methods.toString().split(',')
sql += ` AND EXISTS (
SELECT 1 FROM profile_methods pm
WHERE pm.profile_id = p.id
AND pm.method IN (${methodList.map(() => '?').join(',')})
)`
params.push(...methodList)
}
// Sprachen-Filter
if (lang) {
const [langCode, level] = lang.toString().split(':')
sql += ` AND EXISTS (
SELECT 1 FROM profile_languages pl
WHERE pl.profile_id = p.id
AND pl.code = ?
${level ? 'AND pl.level = ?' : ''}
)`
params.push(langCode)
if (level) params.push(level)
}
// Sortierung
if (sort === 'recency') {
sql += ` ORDER BY p.updated_at DESC`
} else {
sql += ` ORDER BY p.updated_at DESC` // TODO: Ranking nach Relevanz
}
// Pagination
const offset = ((page as number) - 1) * (pageSize as number)
sql += ` LIMIT ? OFFSET ?`
params.push(pageSize, offset)
const profiles = db.prepare(sql).all(...params)
// Lade zus\u00e4tzliche Daten f\u00fcr jedes Profil
const enrichedProfiles = profiles.map((profile: any) => {
// Lade Arrays
const domains = db.prepare('SELECT domain FROM profile_domains WHERE profile_id = ?').all(profile.id).map((r: any) => r.domain)
const tools = db.prepare('SELECT tool FROM profile_tools WHERE profile_id = ?').all(profile.id).map((r: any) => r.tool)
const methods = db.prepare('SELECT method FROM profile_methods WHERE profile_id = ?').all(profile.id).map((r: any) => r.method)
const industryKnowledge = db.prepare('SELECT knowledge FROM profile_industry_knowledge WHERE profile_id = ?').all(profile.id).map((r: any) => r.knowledge)
const regulatory = db.prepare('SELECT regulation FROM profile_regulatory WHERE profile_id = ?').all(profile.id).map((r: any) => r.regulation)
const networks = db.prepare('SELECT network FROM profile_networks WHERE profile_id = ?').all(profile.id).map((r: any) => r.network)
const digitalSkills = db.prepare('SELECT skill FROM profile_digital_skills WHERE profile_id = ?').all(profile.id).map((r: any) => r.skill)
const socialSkills = db.prepare('SELECT skill FROM profile_social_skills WHERE profile_id = ?').all(profile.id).map((r: any) => r.skill)
// Lade Sprachen
const languages = db.prepare('SELECT code, level FROM profile_languages WHERE profile_id = ?').all(profile.id)
// Lade Projekte
const projects = db.prepare('SELECT id, title, role, summary FROM profile_projects WHERE profile_id = ?').all(profile.id)
for (const project of projects as any[]) {
project.links = db.prepare('SELECT link FROM project_links WHERE project_id = ?').all(project.id).map((r: any) => r.link)
delete project.id
}
return {
...profile,
contacts: {
email: profile.email,
phone: profile.phone,
teams: profile.teams_link
},
domains,
tools,
methods,
industryKnowledge,
regulatory,
networks,
digitalSkills,
socialSkills,
languages,
projects,
consentPublicProfile: Boolean(profile.consent_public_profile),
consentSearchable: Boolean(profile.consent_searchable)
}
})
res.json({
success: true,
data: enrichedProfiles,
meta: {
page,
pageSize,
total: (db.prepare('SELECT COUNT(DISTINCT id) as count FROM profiles WHERE consent_searchable = 1 OR updated_by = ?').get(req.user!.id) as any)?.count || 0
}
})
} catch (error) {
next(error)
}
}
)
// GET /api/profiles/:id - Einzelnes Profil
router.get('/:id',
authenticate,
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const profile = db.prepare(`
SELECT * FROM profiles WHERE id = ?
`).get(id) as any
if (!profile) {
return res.status(404).json({
success: false,
error: { message: 'Profile not found' }
})
}
// Pr\u00fcfe Zugriffsrechte
const isOwner = profile.updated_by === req.user!.id
const isAdmin = req.user!.role === 'admin'
if (!profile.consent_public_profile && !isOwner && !isAdmin) {
return res.status(403).json({
success: false,
error: { message: 'Access denied' }
})
}
// Lade zus\u00e4tzliche Daten
const domains = db.prepare('SELECT domain FROM profile_domains WHERE profile_id = ?').all(id).map((r: any) => r.domain)
const tools = db.prepare('SELECT tool FROM profile_tools WHERE profile_id = ?').all(id).map((r: any) => r.tool)
const methods = db.prepare('SELECT method FROM profile_methods WHERE profile_id = ?').all(id).map((r: any) => r.method)
const industryKnowledge = db.prepare('SELECT knowledge FROM profile_industry_knowledge WHERE profile_id = ?').all(id).map((r: any) => r.knowledge)
const regulatory = db.prepare('SELECT regulation FROM profile_regulatory WHERE profile_id = ?').all(id).map((r: any) => r.regulation)
const networks = db.prepare('SELECT network FROM profile_networks WHERE profile_id = ?').all(id).map((r: any) => r.network)
const digitalSkills = db.prepare('SELECT skill FROM profile_digital_skills WHERE profile_id = ?').all(id).map((r: any) => r.skill)
const socialSkills = db.prepare('SELECT skill FROM profile_social_skills WHERE profile_id = ?').all(id).map((r: any) => r.skill)
const languages = db.prepare('SELECT code, level FROM profile_languages WHERE profile_id = ?').all(id)
const projects = db.prepare('SELECT id, title, role, summary FROM profile_projects WHERE profile_id = ?').all(id)
for (const project of projects as any[]) {
project.links = db.prepare('SELECT link FROM project_links WHERE project_id = ?').all(project.id).map((r: any) => r.link)
delete project.id
}
const enrichedProfile = {
...profile,
contacts: {
email: profile.email,
phone: profile.phone,
teams: profile.teams_link
},
domains,
tools,
methods,
industryKnowledge,
regulatory,
networks,
digitalSkills,
socialSkills,
languages,
projects,
consentPublicProfile: Boolean(profile.consent_public_profile),
consentSearchable: Boolean(profile.consent_searchable)
}
res.json({ success: true, data: enrichedProfile })
} catch (error) {
next(error)
}
}
)
// POST /api/profiles - Neues Profil erstellen
router.post('/',
authenticate,
[
body('name').notEmpty().trim(),
body('consentPublicProfile').isBoolean(),
body('consentSearchable').isBoolean()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const profileId = uuidv4()
const now = new Date().toISOString()
const reviewDueAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
const profileData = {
...req.body,
id: profileId,
created_at: now,
updated_at: now,
updated_by: req.user!.id,
review_due_at: reviewDueAt,
search_vector: buildSearchVector(req.body)
}
// Transaktion starten
const transaction = db.transaction(() => {
// Profil einf\u00fcgen
db.prepare(`
INSERT INTO profiles (
id, name, department, location, role, email, phone, teams_link,
job_category, job_title, job_desc,
consent_public_profile, consent_searchable,
created_at, updated_at, updated_by, review_due_at, search_vector
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
profileId,
profileData.name,
profileData.department || null,
profileData.location || null,
profileData.role || null,
profileData.contacts?.email || null,
profileData.contacts?.phone || null,
profileData.contacts?.teams || null,
profileData.jobCategory || null,
profileData.jobTitle || null,
profileData.jobDesc || null,
profileData.consentPublicProfile ? 1 : 0,
profileData.consentSearchable ? 1 : 0,
now,
now,
req.user!.id,
reviewDueAt,
profileData.search_vector
)
// Arrays einf\u00fcgen
const insertArray = (table: string, field: string, values: string[]) => {
if (values && values.length > 0) {
const stmt = db.prepare(`INSERT INTO ${table} (profile_id, ${field}) VALUES (?, ?)`)
for (const value of values) {
stmt.run(profileId, value)
}
}
}
insertArray('profile_domains', 'domain', profileData.domains)
insertArray('profile_tools', 'tool', profileData.tools)
insertArray('profile_methods', 'method', profileData.methods)
insertArray('profile_industry_knowledge', 'knowledge', profileData.industryKnowledge)
insertArray('profile_regulatory', 'regulation', profileData.regulatory)
insertArray('profile_networks', 'network', profileData.networks)
insertArray('profile_digital_skills', 'skill', profileData.digitalSkills)
insertArray('profile_social_skills', 'skill', profileData.socialSkills)
// Sprachen einf\u00fcgen
if (profileData.languages && profileData.languages.length > 0) {
const stmt = db.prepare(`INSERT INTO profile_languages (profile_id, code, level) VALUES (?, ?, ?)`)
for (const lang of profileData.languages) {
stmt.run(profileId, lang.code, lang.level)
}
}
// Projekte einf\u00fcgen
if (profileData.projects && profileData.projects.length > 0) {
for (const project of profileData.projects) {
const projectId = uuidv4()
db.prepare(`
INSERT INTO profile_projects (id, profile_id, title, role, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(projectId, profileId, project.title, project.role || null, project.summary || null, now)
if (project.links && project.links.length > 0) {
const linkStmt = db.prepare(`INSERT INTO project_links (project_id, link) VALUES (?, ?)`)
for (const link of project.links) {
linkStmt.run(projectId, link)
}
}
}
}
// Audit-Log
db.prepare(`
INSERT INTO audit_log (id, entity_type, entity_id, action, user_id, changes, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
'profile',
profileId,
'create',
req.user!.id,
JSON.stringify(profileData),
now
)
})
transaction()
res.status(201).json({
success: true,
data: { id: profileId },
message: 'Profile created successfully'
})
} catch (error) {
next(error)
}
}
)
// PUT /api/profiles/:id - Profil aktualisieren
router.put('/:id',
authenticate,
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const now = new Date().toISOString()
const reviewDueAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
// Pr\u00fcfe ob Profil existiert und Berechtigungen
const existing = db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Profile not found' }
})
}
const isOwner = existing.updated_by === req.user!.id
const isAdmin = req.user!.role === 'admin'
if (!isOwner && !isAdmin) {
return res.status(403).json({
success: false,
error: { message: 'Access denied' }
})
}
const profileData = {
...req.body,
updated_at: now,
updated_by: req.user!.id,
review_due_at: reviewDueAt,
search_vector: buildSearchVector(req.body)
}
// Transaktion f\u00fcr Update
const transaction = db.transaction(() => {
// Profil aktualisieren
db.prepare(`
UPDATE profiles SET
name = ?, department = ?, location = ?, role = ?,
email = ?, phone = ?, teams_link = ?,
job_category = ?, job_title = ?, job_desc = ?,
consent_public_profile = ?, consent_searchable = ?,
updated_at = ?, updated_by = ?, review_due_at = ?, search_vector = ?
WHERE id = ?
`).run(
profileData.name,
profileData.department || null,
profileData.location || null,
profileData.role || null,
profileData.contacts?.email || null,
profileData.contacts?.phone || null,
profileData.contacts?.teams || null,
profileData.jobCategory || null,
profileData.jobTitle || null,
profileData.jobDesc || null,
profileData.consentPublicProfile ? 1 : 0,
profileData.consentSearchable ? 1 : 0,
now,
req.user!.id,
reviewDueAt,
profileData.search_vector,
id
)
// Arrays aktualisieren (l\u00f6schen und neu einf\u00fcgen)
const updateArray = (table: string, field: string, values: string[]) => {
db.prepare(`DELETE FROM ${table} WHERE profile_id = ?`).run(id)
if (values && values.length > 0) {
const stmt = db.prepare(`INSERT INTO ${table} (profile_id, ${field}) VALUES (?, ?)`)
for (const value of values) {
stmt.run(id, value)
}
}
}
updateArray('profile_domains', 'domain', profileData.domains)
updateArray('profile_tools', 'tool', profileData.tools)
updateArray('profile_methods', 'method', profileData.methods)
updateArray('profile_industry_knowledge', 'knowledge', profileData.industryKnowledge)
updateArray('profile_regulatory', 'regulation', profileData.regulatory)
updateArray('profile_networks', 'network', profileData.networks)
updateArray('profile_digital_skills', 'skill', profileData.digitalSkills)
updateArray('profile_social_skills', 'skill', profileData.socialSkills)
// Sprachen aktualisieren
db.prepare('DELETE FROM profile_languages WHERE profile_id = ?').run(id)
if (profileData.languages && profileData.languages.length > 0) {
const stmt = db.prepare(`INSERT INTO profile_languages (profile_id, code, level) VALUES (?, ?, ?)`)
for (const lang of profileData.languages) {
stmt.run(id, lang.code, lang.level)
}
}
// Projekte aktualisieren
db.prepare('DELETE FROM profile_projects WHERE profile_id = ?').run(id)
if (profileData.projects && profileData.projects.length > 0) {
for (const project of profileData.projects) {
const projectId = uuidv4()
db.prepare(`
INSERT INTO profile_projects (id, profile_id, title, role, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(projectId, id, project.title, project.role || null, project.summary || null, now)
if (project.links && project.links.length > 0) {
const linkStmt = db.prepare(`INSERT INTO project_links (project_id, link) VALUES (?, ?)`)
for (const link of project.links) {
linkStmt.run(projectId, link)
}
}
}
}
// Audit-Log
db.prepare(`
INSERT INTO audit_log (id, entity_type, entity_id, action, user_id, changes, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
'profile',
id,
'update',
req.user!.id,
JSON.stringify({ before: existing, after: profileData }),
now
)
})
transaction()
res.json({
success: true,
message: 'Profile updated successfully'
})
} catch (error) {
next(error)
}
}
)
// GET /api/profiles/facets - Facettenwerte f\u00fcr Filter
router.get('/facets',
authenticate,
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const facets = {
departments: db.prepare('SELECT DISTINCT department FROM profiles WHERE department IS NOT NULL AND consent_searchable = 1 ORDER BY department').all().map((r: any) => r.department),
locations: db.prepare('SELECT DISTINCT location FROM profiles WHERE location IS NOT NULL AND consent_searchable = 1 ORDER BY location').all().map((r: any) => r.location),
jobCategories: db.prepare('SELECT DISTINCT job_category FROM profiles WHERE job_category IS NOT NULL AND consent_searchable = 1 ORDER BY job_category').all().map((r: any) => r.job_category),
tools: db.prepare('SELECT DISTINCT tool FROM profile_tools pt JOIN profiles p ON pt.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY tool').all().map((r: any) => r.tool),
methods: db.prepare('SELECT DISTINCT method FROM profile_methods pm JOIN profiles p ON pm.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY method').all().map((r: any) => r.method),
languages: db.prepare('SELECT DISTINCT code, level FROM profile_languages pl JOIN profiles p ON pl.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY code, level').all()
}
res.json({ success: true, data: facets })
} catch (error) {
next(error)
}
}
)
// GET /api/profiles/reminders/overdue - Veraltete Profile (Admin)
router.get('/reminders/overdue',
authenticate,
authorize('admin'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const now = new Date().toISOString()
const overdueProfiles = db.prepare(`
SELECT id, name, department, updated_at, review_due_at
FROM profiles
WHERE review_due_at < ?
ORDER BY review_due_at ASC
`).all(now)
res.json({ success: true, data: overdueProfiles })
} catch (error) {
next(error)
}
}
)
// POST /api/profiles/export - Export-Funktionalit\u00e4t
router.post('/export',
authenticate,
authorize('admin', 'superuser'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { filter, format = 'json' } = req.body
// Baue Query basierend auf Filter
let sql = `SELECT * FROM profiles WHERE consent_searchable = 1`
const params: any[] = []
if (filter?.department) {
sql += ` AND department = ?`
params.push(filter.department)
}
if (filter?.location) {
sql += ` AND location = ?`
params.push(filter.location)
}
const profiles = db.prepare(sql).all(...params)
if (format === 'csv') {
// CSV-Export
const csv = [
'Name,Department,Location,Role,Email,Phone,Job Category,Job Title',
...profiles.map((p: any) =>
`"${p.name}","${p.department || ''}","${p.location || ''}","${p.role || ''}","${p.email || ''}","${p.phone || ''}","${p.job_category || ''}","${p.job_title || ''}"`
)
].join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', 'attachment; filename=profiles.csv')
res.send(csv)
} else {
// JSON-Export
res.json({ success: true, data: profiles })
}
} catch (error) {
next(error)
}
}
)
// POST /api/profiles/tags/suggest - Autocomplete f\u00fcr Tags
router.post('/tags/suggest',
authenticate,
[
body('category').notEmpty(),
body('query').notEmpty()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { category, query } = req.body
const suggestions = db.prepare(`
SELECT value, description
FROM controlled_vocabulary
WHERE category = ? AND value LIKE ? AND is_active = 1
ORDER BY value
LIMIT 10
`).all(category, `${query}%`)
res.json({ success: true, data: suggestions })
} catch (error) {
next(error)
}
}
)
export default router

Datei anzeigen

@ -0,0 +1,87 @@
import { Router, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import { requirePermission } from '../middleware/roleAuth'
import { logger } from '../utils/logger'
const router = Router()
// Get system settings
router.get('/', authenticate, requirePermission('settings:read'), async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const settings = db.prepare('SELECT key, value, description FROM system_settings').all() as any[]
const settingsMap = settings.reduce((acc, setting) => {
acc[setting.key] = {
value: setting.value === 'true' || setting.value === 'false' ? setting.value === 'true' : setting.value,
description: setting.description
}
return acc
}, {})
res.json({ success: true, data: settingsMap })
} catch (error) {
logger.error('Error fetching settings:', error)
next(error)
}
})
// Update system setting
router.put('/:key',
authenticate,
requirePermission('settings:update'),
[
body('value').notEmpty()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { key } = req.params
const { value } = req.body
const now = new Date().toISOString()
// Convert boolean to string for storage
const stringValue = typeof value === 'boolean' ? value.toString() : value
// Check if setting exists
const existingSetting = db.prepare('SELECT key FROM system_settings WHERE key = ?').get(key)
if (existingSetting) {
// Update existing setting
db.prepare(`
UPDATE system_settings SET value = ?, updated_at = ?, updated_by = ?
WHERE key = ?
`).run(stringValue, now, req.user!.id, key)
logger.info(`System setting ${key} updated to ${stringValue} by user ${req.user!.username}`)
} else {
// Create new setting
db.prepare(`
INSERT INTO system_settings (key, value, updated_at, updated_by)
VALUES (?, ?, ?, ?)
`).run(key, stringValue, now, req.user!.id)
logger.info(`System setting ${key} created with value ${stringValue} by user ${req.user!.username}`)
}
res.json({
success: true,
message: 'Setting updated successfully',
data: { key, value: stringValue }
})
} catch (error) {
logger.error('Error updating setting:', error)
next(error)
}
}
)
export default router

508
backend/src/routes/skills.ts Normale Datei
Datei anzeigen

@ -0,0 +1,508 @@
import { Router } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/database'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { DEFAULT_SKILLS } from '@skillmate/shared'
import { syncService } from '../services/syncService'
const router = Router()
// Get all skills
router.get('/', authenticate, async (req: AuthRequest, res, next) => {
try {
const skills = db.prepare(`
SELECT id, name, category, description, requires_certification, expires_after
FROM skills
ORDER BY category, name
`).all()
res.json({ success: true, data: skills })
} catch (error) {
next(error)
}
})
// Get skills hierarchy (categories -> subcategories -> skills)
router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
try {
// Ensure vocabulary table exists (defensive)
db.exec(`
CREATE TABLE IF NOT EXISTS controlled_vocabulary (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(category, value)
);
`)
let cats = [] as any[]
let subs = [] as any[]
try {
cats = db.prepare(`
SELECT value AS id, COALESCE(description, value) AS name
FROM controlled_vocabulary
WHERE category = 'skill_category'
ORDER BY id
`).all() as any[]
subs = db.prepare(`
SELECT value AS key, COALESCE(description, value) AS name
FROM controlled_vocabulary
WHERE category = 'skill_subcategory'
ORDER BY key
`).all() as any[]
} catch {}
const subByCat: Record<string, { id: string; name: string }[]> = {}
for (const s of subs) {
const [catId, subId] = String(s.key).split('.')
if (!catId || !subId) continue
if (!subByCat[catId]) subByCat[catId] = []
subByCat[catId].push({ id: subId, name: s.name })
}
const skills = db.prepare(`
SELECT id, name, category, description FROM skills
`).all() as any[]
const byKey: Record<string, { id: string; name: string; description?: string | null }[]> = {}
for (const s of skills) {
const key = s.category
if (!key) continue
if (!byKey[key]) byKey[key] = []
byKey[key].push({ id: s.id, name: s.name, description: s.description || null })
}
let hierarchy = cats.map(cat => ({
id: cat.id,
name: cat.name,
subcategories: (subByCat[cat.id] || []).map(sc => ({
id: sc.id,
name: sc.name,
skills: byKey[`${cat.id}.${sc.id}`] || []
}))
}))
// Fallback: if vocabulary is empty, derive from existing skills
if (hierarchy.length === 0) {
const seenCats = new Map<string, { id: string; name: string; subs: Map<string, { id: string; name: string; skills: any[] }> }>()
for (const s of skills) {
const [catId, subId] = String(s.category || '').split('.')
if (!catId || !subId) continue
if (!seenCats.has(catId)) seenCats.set(catId, { id: catId, name: catId, subs: new Map() })
const cat = seenCats.get(catId)!
if (!cat.subs.has(subId)) cat.subs.set(subId, { id: subId, name: subId, skills: [] })
cat.subs.get(subId)!.skills.push({ id: s.id, name: s.name, description: s.description || null })
}
hierarchy = Array.from(seenCats.values()).map(c => ({
id: c.id,
name: c.name,
subcategories: Array.from(c.subs.values())
}))
}
res.json({ success: true, data: hierarchy })
} catch (error) {
next(error)
}
})
// Initialize default skills (admin only)
router.post('/initialize',
authenticate,
authorize('admin'),
async (req: AuthRequest, res, next) => {
try {
const insertSkill = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, requires_certification, expires_after)
VALUES (?, ?, ?, ?, ?, ?)
`)
let count = 0
for (const [category, skills] of Object.entries(DEFAULT_SKILLS)) {
for (const skillName of skills) {
const result = insertSkill.run(
uuidv4(),
skillName,
category,
null,
category === 'certificates' || category === 'weapons' ? 1 : 0,
category === 'certificates' ? 36 : null // 3 years for certificates
)
if (result.changes > 0) count++
}
}
res.json({
success: true,
message: `${count} skills initialized successfully`
})
} catch (error) {
next(error)
}
}
)
// Create custom skill (admin/poweruser only)
router.post('/',
authenticate,
authorize('admin', 'superuser'),
async (req: AuthRequest, res, next) => {
try {
const { id, name, category, description, requiresCertification, expiresAfter } = req.body
// Optional custom id to keep stable
let skillId = id && typeof id === 'string' && id.length > 0 ? id : uuidv4()
const exists = db.prepare('SELECT id FROM skills WHERE id = ?').get(skillId)
if (exists) {
return res.status(409).json({ success: false, error: { message: 'Skill ID already exists' } })
}
// Validate category refers to existing subcategory
if (!category || String(category).indexOf('.') === -1) {
return res.status(400).json({ success: false, error: { message: 'Category must be catId.subId' } })
}
const subExists = db
.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?')
.get('skill_subcategory', category)
if (!subExists) {
return res.status(400).json({ success: false, error: { message: 'Unknown subcategory' } })
}
db.prepare(`
INSERT INTO skills (id, name, category, description, requires_certification, expires_after)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
skillId,
name,
category,
description || null,
requiresCertification ? 1 : 0,
expiresAfter || null
)
// Queue sync for new skill
const newSkill = {
id: skillId,
name,
category,
description: description || null,
requiresCertification: requiresCertification || false,
expiresAfter: expiresAfter || null
}
await syncService.queueSync('skills', 'create', newSkill)
res.status(201).json({
success: true,
data: { id: skillId },
message: 'Skill created successfully'
})
} catch (error) {
next(error)
}
}
)
// Update skill (admin only)
router.put('/:id',
authenticate,
authorize('admin'),
async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const { name, category, description } = req.body
// Check if skill exists
const existing = db.prepare('SELECT id FROM skills WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Skill not found' }
})
}
// Validate new category if provided
if (category) {
if (String(category).indexOf('.') === -1) {
return res.status(400).json({ success: false, error: { message: 'Category must be catId.subId' } })
}
const subExists = db
.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?')
.get('skill_subcategory', category)
if (!subExists) {
return res.status(400).json({ success: false, error: { message: 'Unknown subcategory' } })
}
}
// Update skill (partial)
db.prepare(`
UPDATE skills SET
name = COALESCE(?, name),
category = COALESCE(?, category),
description = COALESCE(?, description)
WHERE id = ?
`).run(name || null, category || null, (description ?? null), id)
// Queue sync for updated skill
const updatedSkill = {
id,
name,
category,
description: description || null
}
await syncService.queueSync('skills', 'update', updatedSkill)
res.json({
success: true,
message: 'Skill updated successfully'
})
} catch (error) {
next(error)
}
}
)
// Delete skill (admin only)
router.delete('/:id',
authenticate,
authorize('admin'),
async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const existing = db.prepare('SELECT id FROM skills WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Skill not found' } })
}
db.prepare('DELETE FROM employee_skills WHERE skill_id = ?').run(id)
db.prepare('DELETE FROM skills WHERE id = ?').run(id)
await syncService.queueSync('skills', 'delete', { id })
res.json({ success: true, message: 'Skill deleted successfully' })
} catch (error) { next(error) }
}
)
// Category management
router.post('/categories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { id, name } = req.body
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
const now = new Date().toISOString()
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_category', id)
if (exists) return res.status(409).json({ success: false, error: { message: 'Category already exists' } })
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(uuidv4(), 'skill_category', id, name, 1, now)
res.status(201).json({ success: true, message: 'Category created' })
} catch (error) { next(error) }
})
router.put('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId } = req.params
const { name } = req.body
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
.run(name, 'skill_category', catId)
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
res.json({ success: true, message: 'Category updated' })
} catch (error) { next(error) }
})
router.delete('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId } = req.params
const tx = db.transaction(() => {
const subs = db.prepare('SELECT value FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').all('skill_subcategory', `${catId}.%`) as any[]
for (const s of subs) {
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(s.value)
db.prepare('DELETE FROM skills WHERE category = ?').run(s.value)
}
db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').run('skill_subcategory', `${catId}.%`)
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_category', catId)
return del.changes
})
const changes = tx()
if (!changes) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
res.json({ success: true, message: 'Category deleted' })
} catch (error) { next(error) }
})
// Subcategory management
router.post('/categories/:catId/subcategories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId } = req.params
const { id, name } = req.body
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
const cat = db.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_category', catId)
if (!cat) return res.status(400).json({ success: false, error: { message: 'Category does not exist' } })
const key = `${catId}.${id}`
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_subcategory', key)
if (exists) return res.status(409).json({ success: false, error: { message: 'Subcategory already exists' } })
const now = new Date().toISOString()
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(uuidv4(), 'skill_subcategory', key, name, 1, now)
res.status(201).json({ success: true, message: 'Subcategory created' })
} catch (error) { next(error) }
})
router.put('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId, subId } = req.params
const { name } = req.body
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
const key = `${catId}.${subId}`
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
.run(name, 'skill_subcategory', key)
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
res.json({ success: true, message: 'Subcategory updated' })
} catch (error) { next(error) }
})
router.delete('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId, subId } = req.params
const key = `${catId}.${subId}`
const tx = db.transaction(() => {
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(key)
db.prepare('DELETE FROM skills WHERE category = ?').run(key)
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_subcategory', key)
return del.changes
})
const changes = tx()
if (!changes) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
res.json({ success: true, message: 'Subcategory deleted' })
} catch (error) { next(error) }
})
// Delete skill (admin only)
router.delete('/:id',
authenticate,
authorize('admin'),
async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
// Check if skill exists
const existing = db.prepare('SELECT id FROM skills WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Skill not found' }
})
}
// Delete skill and related data
db.prepare('DELETE FROM employee_skills WHERE skill_id = ?').run(id)
db.prepare('DELETE FROM skills WHERE id = ?').run(id)
// Queue sync for deleted skill
await syncService.queueSync('skills', 'delete', { id })
res.json({
success: true,
message: 'Skill deleted successfully'
})
} catch (error) {
next(error)
}
}
)
// Category management
router.post('/categories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { id, name } = req.body
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
const now = new Date().toISOString()
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?')
.get('skill_category', id)
if (exists) return res.status(409).json({ success: false, error: { message: 'Category already exists' } })
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(uuidv4(), 'skill_category', id, name, 1, now)
res.status(201).json({ success: true, message: 'Category created' })
} catch (error) { next(error) }
})
router.put('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId } = req.params
const { name } = req.body
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
.run(name, 'skill_category', catId)
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
res.json({ success: true, message: 'Category updated' })
} catch (error) { next(error) }
})
router.delete('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId } = req.params
const tx = db.transaction(() => {
// Delete skills under all subcategories
const subs = db.prepare('SELECT value FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').all('skill_subcategory', `${catId}.%`) as any[]
for (const s of subs) {
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(s.value)
db.prepare('DELETE FROM skills WHERE category = ?').run(s.value)
}
db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').run('skill_subcategory', `${catId}.%`)
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_category', catId)
return del.changes
})
const changes = tx()
if (!changes) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
res.json({ success: true, message: 'Category deleted' })
} catch (error) { next(error) }
})
// Subcategory management
router.post('/categories/:catId/subcategories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId } = req.params
const { id, name } = req.body
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
const cat = db.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_category', catId)
if (!cat) return res.status(400).json({ success: false, error: { message: 'Category does not exist' } })
const key = `${catId}.${id}`
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?')
.get('skill_subcategory', key)
if (exists) return res.status(409).json({ success: false, error: { message: 'Subcategory already exists' } })
const now = new Date().toISOString()
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(uuidv4(), 'skill_subcategory', key, name, 1, now)
res.status(201).json({ success: true, message: 'Subcategory created' })
} catch (error) { next(error) }
})
router.put('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId, subId } = req.params
const { name } = req.body
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
const key = `${catId}.${subId}`
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
.run(name, 'skill_subcategory', key)
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
res.json({ success: true, message: 'Subcategory updated' })
} catch (error) { next(error) }
})
router.delete('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { catId, subId } = req.params
const key = `${catId}.${subId}`
const tx = db.transaction(() => {
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(key)
db.prepare('DELETE FROM skills WHERE category = ?').run(key)
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_subcategory', key)
return del.changes
})
const changes = tx()
if (!changes) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
res.json({ success: true, message: 'Subcategory deleted' })
} catch (error) { next(error) }
})
export default router

123
backend/src/routes/sync.ts Normale Datei
Datei anzeigen

@ -0,0 +1,123 @@
import { Router } from 'express'
import { authenticate, authorize } from '../middleware/auth'
import { syncService } from '../services/syncService'
import { logger } from '../utils/logger'
import type { AuthRequest } from '../middleware/auth'
const router = Router()
// Receive sync data from another node
router.post('/receive', authenticate, async (req: AuthRequest, res, next) => {
try {
const nodeId = req.headers['x-node-id'] as string
if (!nodeId) {
return res.status(400).json({
error: { message: 'Missing node ID in headers' }
})
}
const result = await syncService.receiveSync(req.body)
res.json(result)
} catch (error) {
next(error)
}
})
// Get sync status
router.get('/status', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const status = syncService.getSyncStatus()
res.json({
success: true,
data: status
})
} catch (error) {
next(error)
}
})
// Trigger manual sync
router.post('/trigger', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
// Run sync in background
syncService.triggerSync().catch(error => {
logger.error('Background sync failed:', error)
})
res.json({
success: true,
message: 'Sync triggered successfully'
})
} catch (error) {
next(error)
}
})
// Sync with specific node
router.post('/sync/:nodeId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { nodeId } = req.params
const result = await syncService.syncWithNode(nodeId)
res.json({
success: true,
data: result
})
} catch (error) {
next(error)
}
})
// Get sync conflicts
router.get('/conflicts', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { db } = await import('../config/database')
const conflicts = db.prepare(`
SELECT * FROM sync_conflicts
WHERE resolution_status = 'pending'
ORDER BY created_at DESC
`).all()
res.json({
success: true,
data: conflicts
})
} catch (error) {
next(error)
}
})
// Resolve sync conflict
router.post('/conflicts/:conflictId/resolve', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
try {
const { conflictId } = req.params
const { resolution, data } = req.body
const { db } = await import('../config/database')
// Update conflict status
db.prepare(`
UPDATE sync_conflicts
SET resolution_status = 'resolved',
resolved_by = ?,
resolved_at = ?
WHERE id = ?
`).run(req.user!.id, new Date().toISOString(), conflictId)
// Apply resolution if needed
if (resolution === 'apply' && data) {
await syncService.receiveSync(data)
}
res.json({
success: true,
message: 'Conflict resolved successfully'
})
} catch (error) {
next(error)
}
})
export default router

151
backend/src/routes/upload.ts Normale Datei
Datei anzeigen

@ -0,0 +1,151 @@
import { Router } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import { v4 as uuidv4 } from 'uuid'
import { authenticate, AuthRequest } from '../middleware/auth'
import { db } from '../config/database'
const router = Router()
// Ensure upload directory exists
const uploadDir = path.join(__dirname, '../../uploads/photos')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
// Configure multer for photo uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir)
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname)
const filename = `${uuidv4()}${ext}`
cb(null, filename)
}
})
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB limit
},
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase())
const mimetype = allowedTypes.test(file.mimetype)
if (mimetype && extname) {
return cb(null, true)
} else {
cb(new Error('Only image files are allowed'))
}
}
})
// Upload employee photo
// Only the user themself may change their own photo (no admin/superuser override)
router.post('/employee-photo/:employeeId',
authenticate,
(req: AuthRequest, res, next) => {
if (!req.user || req.user.employeeId !== req.params.employeeId) {
return res.status(403).json({ success: false, error: { message: 'Only the profile owner may change the photo' } })
}
next()
},
upload.single('photo'),
async (req: AuthRequest, res, next) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: { message: 'No file uploaded' }
})
}
const { employeeId } = req.params
// Check if employee exists
const employee = db.prepare('SELECT id, photo FROM employees WHERE id = ?').get(employeeId) as any
if (!employee) {
// Delete uploaded file
fs.unlinkSync(req.file.path)
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
// Delete old photo if exists
if (employee.photo) {
const oldPhotoPath = path.join(uploadDir, path.basename(employee.photo))
if (fs.existsSync(oldPhotoPath)) {
fs.unlinkSync(oldPhotoPath)
}
}
// Update employee photo URL
const photoUrl = `/uploads/photos/${req.file.filename}`
db.prepare('UPDATE employees SET photo = ?, updated_at = ?, updated_by = ? WHERE id = ?')
.run(photoUrl, new Date().toISOString(), req.user!.id, employeeId)
res.json({
success: true,
data: { photoUrl },
message: 'Photo uploaded successfully'
})
} catch (error) {
// Clean up uploaded file on error
if (req.file && fs.existsSync(req.file.path)) {
fs.unlinkSync(req.file.path)
}
next(error)
}
}
)
// Delete employee photo
router.delete('/employee-photo/:employeeId',
authenticate,
(req: AuthRequest, res, next) => {
if (!req.user || req.user.employeeId !== req.params.employeeId) {
return res.status(403).json({ success: false, error: { message: 'Only the profile owner may delete the photo' } })
}
next()
},
async (req: AuthRequest, res, next) => {
try {
const { employeeId } = req.params
// Get employee photo
const employee = db.prepare('SELECT id, photo FROM employees WHERE id = ?').get(employeeId) as any
if (!employee) {
return res.status(404).json({
success: false,
error: { message: 'Employee not found' }
})
}
if (employee.photo) {
const photoPath = path.join(uploadDir, path.basename(employee.photo))
if (fs.existsSync(photoPath)) {
fs.unlinkSync(photoPath)
}
}
// Clear photo from database
db.prepare('UPDATE employees SET photo = NULL, updated_at = ?, updated_by = ? WHERE id = ?')
.run(new Date().toISOString(), req.user!.id, employeeId)
res.json({
success: true,
message: 'Photo deleted successfully'
})
} catch (error) {
next(error)
}
}
)
export default router

311
backend/src/routes/users.ts Normale Datei
Datei anzeigen

@ -0,0 +1,311 @@
import { Router, Response, NextFunction } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcryptjs'
import { db } from '../config/database'
import { authenticate, AuthRequest } from '../middleware/auth'
import { requirePermission, requireAdminPanel } from '../middleware/roleAuth'
import { User, UserRole } from '@skillmate/shared'
const router = Router()
// Get all users (admin only)
router.get('/', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
try {
const users = db.prepare(`
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
ORDER BY created_at DESC
`).all()
const formattedUsers: User[] = users.map((user: any) => ({
id: user.id,
username: user.username,
email: user.email,
role: user.role as UserRole,
employeeId: user.employee_id || undefined,
lastLogin: user.last_login ? new Date(user.last_login) : undefined,
isActive: Boolean(user.is_active),
createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_at)
}))
res.json({
success: true,
data: formattedUsers
})
} catch (error) {
next(error)
}
})
// Get user by ID
router.get('/:id', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
try {
const { id } = req.params
const user = db.prepare(`
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
WHERE id = ?
`).get(id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const userRecord = user as any
const formattedUser: User = {
id: userRecord.id,
username: userRecord.username,
email: userRecord.email,
role: userRecord.role as UserRole,
employeeId: userRecord.employee_id || undefined,
lastLogin: userRecord.last_login ? new Date(userRecord.last_login) : undefined,
isActive: Boolean(userRecord.is_active),
createdAt: new Date(userRecord.created_at),
updatedAt: new Date(userRecord.updated_at)
}
res.json({
success: true,
data: formattedUser
})
} catch (error) {
next(error)
}
})
// Create new user (admin only)
router.post('/',
authenticate,
requirePermission('users:create'),
[
body('username').notEmpty().trim().isLength({ min: 3 }),
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 6 }),
body('role').isIn(['admin', 'superuser', 'user']),
body('employeeId').optional().isString()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array()
})
}
const { username, email, password, role, employeeId } = req.body
// Check if username already exists
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
if (existingUser) {
return res.status(409).json({ error: 'Username already exists' })
}
// Check if email already exists
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email)
if (existingEmail) {
return res.status(409).json({ error: 'Email already exists' })
}
// Hash password
const hashedPassword = bcrypt.hashSync(password, 10)
const now = new Date().toISOString()
const userId = uuidv4()
// Enforce role policy: only admins can set role other than 'user'
const assignedRole = (req.user?.role === 'admin') ? role : 'user'
// Insert user
db.prepare(`
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
username,
email,
hashedPassword,
assignedRole,
employeeId || null,
1,
now,
now
)
const newUser: User = {
id: userId,
username,
email,
role: assignedRole as UserRole,
employeeId: employeeId || undefined,
isActive: true,
createdAt: new Date(now),
updatedAt: new Date(now)
}
res.status(201).json({
success: true,
data: newUser
})
} catch (error) {
next(error)
}
}
)
// Update user (admin only)
router.put('/:id',
authenticate,
requirePermission('users:update'),
[
body('username').optional().trim().isLength({ min: 3 }),
body('email').optional().isEmail().normalizeEmail(),
body('password').optional().isLength({ min: 6 }),
body('role').optional().isIn(['admin', 'superuser', 'user']),
body('employeeId').optional().isString(),
body('isActive').optional().isBoolean()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array()
})
}
const { id } = req.params
const { username, email, password, role, employeeId, isActive } = req.body
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(id)
if (!existingUser) {
return res.status(404).json({ error: 'User not found' })
}
// Prepare update fields
const updates = []
const values = []
if (username) {
// Check if new username is already taken (by another user)
const usernameCheck = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, id)
if (usernameCheck) {
return res.status(409).json({ error: 'Username already exists' })
}
updates.push('username = ?')
values.push(username)
}
if (email) {
// Check if new email is already taken (by another user)
const emailCheck = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id)
if (emailCheck) {
return res.status(409).json({ error: 'Email already exists' })
}
updates.push('email = ?')
values.push(email)
}
if (password) {
const hashedPassword = bcrypt.hashSync(password, 10)
updates.push('password = ?')
values.push(hashedPassword)
}
if (role) {
updates.push('role = ?')
values.push(role)
}
if (employeeId !== undefined) {
updates.push('employee_id = ?')
values.push(employeeId || null)
}
if (isActive !== undefined) {
updates.push('is_active = ?')
values.push(isActive ? 1 : 0)
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' })
}
updates.push('updated_at = ?')
values.push(new Date().toISOString())
values.push(id)
db.prepare(`
UPDATE users
SET ${updates.join(', ')}
WHERE id = ?
`).run(...values)
// Return updated user
const updatedUser = db.prepare(`
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
WHERE id = ?
`).get(id)
const updatedRecord = updatedUser as any
const formattedUser: User = {
id: updatedRecord.id,
username: updatedRecord.username,
email: updatedRecord.email,
role: updatedRecord.role as UserRole,
employeeId: updatedRecord.employee_id || undefined,
lastLogin: updatedRecord.last_login ? new Date(updatedRecord.last_login) : undefined,
isActive: Boolean(updatedRecord.is_active),
createdAt: new Date(updatedRecord.created_at),
updatedAt: new Date(updatedRecord.updated_at)
}
res.json({
success: true,
data: formattedUser
})
} catch (error) {
next(error)
}
}
)
// Delete user (admin only)
router.delete('/:id',
authenticate,
requirePermission('users:delete'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
// Prevent deleting self
if (req.user?.id === id) {
return res.status(400).json({ error: 'Cannot delete your own account' })
}
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(id)
if (!existingUser) {
return res.status(404).json({ error: 'User not found' })
}
// Delete user
db.prepare('DELETE FROM users WHERE id = ?').run(id)
res.json({
success: true,
message: 'User deleted successfully'
})
} catch (error) {
next(error)
}
}
)
export default router

Datei anzeigen

@ -0,0 +1,510 @@
import { Router, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import { v4 as uuidv4 } from 'uuid'
import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { requirePermission } from '../middleware/roleAuth'
import { User, UserRole } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
const router = Router()
// Get all users (admin only)
router.get('/', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
try {
const users = db.prepare(`
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
FROM users
ORDER BY username
`).all() as any[]
// Decrypt email addresses (handle decryption failures)
const decryptedUsers = users.map(user => {
let decryptedEmail = user.email
// Check if email looks encrypted (encrypted strings are typically longer and contain special characters)
if (user.email && user.email.includes('U2FsdGVkX1')) {
try {
decryptedEmail = FieldEncryption.decrypt(user.email) || user.email
} catch (error) {
logger.warn(`Failed to decrypt email for user ${user.username}, using raw value`)
// For compatibility with old unencrypted data or different encryption keys
decryptedEmail = user.email
}
}
return {
...user,
email: decryptedEmail,
isActive: Boolean(user.is_active),
lastLogin: user.last_login ? new Date(user.last_login) : null,
createdAt: new Date(user.created_at),
updatedAt: new Date(user.updated_at),
employeeId: user.employee_id
}
})
res.json({ success: true, data: decryptedUsers })
} catch (error) {
logger.error('Error fetching users:', error)
next(error)
}
})
// Update user role (admin only)
router.put('/:id/role',
authenticate,
requirePermission('users:update'),
[
body('role').isIn(['admin', 'superuser', 'user'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { id } = req.params
const { role } = req.body
// Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
if (!existingUser) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
})
}
// Prevent changing admin user role
if (existingUser.username === 'admin' && role !== 'admin') {
return res.status(403).json({
success: false,
error: { message: 'Cannot change admin user role' }
})
}
// Update role
db.prepare(`
UPDATE users SET role = ?, updated_at = ?
WHERE id = ?
`).run(role, new Date().toISOString(), id)
logger.info(`User role updated: ${existingUser.username} -> ${role}`)
res.json({ success: true, message: 'Role updated successfully' })
} catch (error) {
logger.error('Error updating user role:', error)
next(error)
}
}
)
// Bulk create users from employees
router.post('/bulk-create-from-employees',
authenticate,
requirePermission('users:create'),
[
body('employeeIds').isArray({ min: 1 }),
body('role').isIn(['admin', 'superuser', 'user'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { employeeIds, role } = req.body as { employeeIds: string[]; role: UserRole }
const results: any[] = []
for (const employeeId of employeeIds) {
try {
const emp = encryptedDb.getEmployee(employeeId) as any
if (!emp) {
results.push({ employeeId, status: 'skipped', reason: 'employee_not_found' })
continue
}
const existsByEmployee = db.prepare('SELECT id FROM users WHERE employee_id = ?').get(employeeId)
if (existsByEmployee) {
results.push({ employeeId, status: 'skipped', reason: 'user_exists' })
continue
}
const baseUsername = (emp.email ? String(emp.email).split('@')[0] : `${emp.first_name}.${emp.last_name}`)
.toLowerCase().replace(/\s+/g, '')
let finalUsername = baseUsername
let i = 1
while (db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)) {
finalUsername = `${baseUsername}${i++}`
}
const email: string | null = emp.email || null
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(tempPassword, 12)
const now = new Date().toISOString()
const userId = uuidv4()
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
email ? FieldEncryption.encrypt(email) : null,
email ? FieldEncryption.hash(email) : null,
hashedPassword,
role,
employeeId,
1,
now,
now
)
results.push({ employeeId, status: 'created', username: finalUsername, temporaryPassword: tempPassword })
} catch (err: any) {
logger.error('Bulk create error for employee ' + employeeId, err)
results.push({ employeeId, status: 'error', reason: err?.message || 'unknown_error' })
}
}
return res.status(201).json({ success: true, data: { results } })
} catch (error) {
logger.error('Error in bulk create from employees:', error)
next(error)
}
}
)
// Update user status (admin only)
router.put('/:id/status',
authenticate,
requirePermission('users:update'),
[
body('isActive').isBoolean()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { id } = req.params
const { isActive } = req.body
// Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
if (!existingUser) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
})
}
// Prevent deactivating admin user
if (existingUser.username === 'admin' && !isActive) {
return res.status(403).json({
success: false,
error: { message: 'Cannot deactivate admin user' }
})
}
// Update status
db.prepare(`
UPDATE users SET is_active = ?, updated_at = ?
WHERE id = ?
`).run(isActive ? 1 : 0, new Date().toISOString(), id)
logger.info(`User status updated: ${existingUser.username} -> ${isActive ? 'active' : 'inactive'}`)
res.json({ success: true, message: 'Status updated successfully' })
} catch (error) {
logger.error('Error updating user status:', error)
next(error)
}
}
)
// Reset user password (admin only)
router.post('/:id/reset-password',
authenticate,
requirePermission('users:update'),
[
body('newPassword').optional().isLength({ min: 8 })
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { id } = req.params
const { newPassword } = req.body
// Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
if (!existingUser) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
})
}
// Generate password
const password = newPassword || `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(password, 12)
// Update password
db.prepare(`
UPDATE users SET password = ?, updated_at = ?
WHERE id = ?
`).run(hashedPassword, new Date().toISOString(), id)
logger.info(`Password reset for user: ${existingUser.username}`)
res.json({
success: true,
message: 'Password reset successfully',
data: { temporaryPassword: newPassword ? undefined : password }
})
} catch (error) {
logger.error('Error resetting password:', error)
next(error)
}
}
)
// Delete user (admin only)
router.delete('/:id',
authenticate,
requirePermission('users:delete'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
// Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
if (!existingUser) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
})
}
// Prevent deleting admin user
if (existingUser.username === 'admin') {
return res.status(403).json({
success: false,
error: { message: 'Cannot delete admin user' }
})
}
// Delete user
db.prepare('DELETE FROM users WHERE id = ?').run(id)
logger.info(`User deleted: ${existingUser.username}`)
res.json({ success: true, message: 'User deleted successfully' })
} catch (error) {
logger.error('Error deleting user:', error)
next(error)
}
}
)
export default router
// Create user account from existing employee (admin only)
router.post('/create-from-employee',
authenticate,
requirePermission('users:create'),
[
body('employeeId').notEmpty().isString(),
body('username').optional().isString().isLength({ min: 3 }),
body('role').isIn(['admin', 'superuser', 'user'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { employeeId, username, role } = req.body
// Check employee exists
const employee = encryptedDb.getEmployee(employeeId) as any
if (!employee) {
return res.status(404).json({ success: false, error: { message: 'Employee not found' } })
}
// Check if a user is already linked to this employee
const existingByEmployee = db.prepare('SELECT id FROM users WHERE employee_id = ?').get(employeeId)
if (existingByEmployee) {
return res.status(409).json({ success: false, error: { message: 'User already exists for this employee' } })
}
// Determine email and username
const email: string | null = employee.email || null
const finalUsername = username || (email ? String(email).split('@')[0] : `${employee.first_name}.${employee.last_name}`)
// Check username uniqueness
const usernameExists = db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)
if (usernameExists) {
return res.status(409).json({ success: false, error: { message: 'Username already exists' } })
}
// Check email uniqueness if available
if (email) {
const emailHash = FieldEncryption.hash(email)
const emailExists = db.prepare('SELECT id FROM users WHERE email_hash = ?').get(emailHash)
if (emailExists) {
return res.status(409).json({ success: false, error: { message: 'Email already used by another account' } })
}
}
// Generate temp password
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(tempPassword, 12)
const now = new Date().toISOString()
const userId = uuidv4()
// Insert user with encrypted email
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
email ? FieldEncryption.encrypt(email) : null,
email ? FieldEncryption.hash(email) : null,
hashedPassword,
role,
employeeId,
1,
now,
now
)
logger.info(`User created from employee ${employeeId}: ${finalUsername} (${role})`)
return res.status(201).json({ success: true, data: { id: userId, username: finalUsername, temporaryPassword: tempPassword } })
} catch (error) {
logger.error('Error creating user from employee:', error)
next(error)
}
}
)
// Purge users: keep only admin and one specified email (admin only)
router.post('/purge',
authenticate,
authorize('admin'),
[
body('email').isEmail()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { email } = req.body as { email: string }
const emailHash = FieldEncryption.hash(email)
const total = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c || 0
const keep = db.prepare('SELECT id, username FROM users WHERE username = ? OR email_hash = ?').all('admin', emailHash) as any[]
const delStmt = db.prepare('DELETE FROM users WHERE username <> ? AND (email_hash IS NULL OR email_hash <> ?)')
const info = delStmt.run('admin', emailHash)
logger.warn(`User purge executed by ${req.user?.username}. Kept ${keep.length}, deleted ${info.changes}.`)
return res.json({ success: true, data: { total, kept: keep.length, deleted: info.changes } })
} catch (error) {
logger.error('Error purging users:', error)
next(error)
}
}
)
// Send temporary password via email to user's email
router.post('/:id/send-temp-password',
authenticate,
requirePermission('users:update'),
[
body('password').notEmpty().isString().isLength({ min: 6 })
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const { id } = req.params
const { password } = req.body as { password: string }
// Load user
const user = db.prepare('SELECT id, username, email, employee_id FROM users WHERE id = ?').get(id) as any
if (!user) {
return res.status(404).json({ success: false, error: { message: 'User not found' } })
}
// Decrypt email
const email = user.email ? FieldEncryption.decrypt(user.email) : null
if (!email) {
return res.status(400).json({ success: false, error: { message: 'User has no email address' } })
}
// Optional: get first name for nicer email copy
let firstName: string | undefined = undefined
if (user.employee_id) {
const emp = encryptedDb.getEmployee(user.employee_id)
if (emp && emp.first_name) firstName = emp.first_name
}
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
if (!emailNotificationsEnabled || !emailService.isServiceEnabled()) {
return res.status(503).json({ success: false, error: { message: 'Email service not enabled' } })
}
const sent = await emailService.sendInitialPassword(email, password, firstName)
if (!sent) {
return res.status(500).json({ success: false, error: { message: 'Failed to send email' } })
}
logger.info(`Temporary password email sent to ${email} for user ${user.username}`)
return res.json({ success: true, message: 'Temporary password email sent' })
} catch (error) {
logger.error('Error sending temporary password email:', error)
next(error)
}
}
)

Datei anzeigen

@ -0,0 +1,299 @@
import { Router, Request, Response } from 'express'
import { db } from '../config/database'
import { authenticateToken, AuthRequest } from '../middleware/auth'
import { v4 as uuidv4 } from 'uuid'
import { Workspace, WorkspaceFilter } from '@skillmate/shared'
const router = Router()
// Get all workspaces with optional filters
router.get('/', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const filters: WorkspaceFilter = req.query
let query = 'SELECT * FROM workspaces WHERE is_active = 1'
const params: any[] = []
if (filters.type) {
query += ' AND type = ?'
params.push(filters.type)
}
if (filters.floor) {
query += ' AND floor = ?'
params.push(filters.floor)
}
if (filters.building) {
query += ' AND building = ?'
params.push(filters.building)
}
if (filters.min_capacity) {
query += ' AND capacity >= ?'
params.push(filters.min_capacity)
}
query += ' ORDER BY floor, name'
const workspaces = db.prepare(query).all(...params)
// Parse equipment JSON
const parsedWorkspaces = workspaces.map((ws: any) => ({
...ws,
equipment: ws.equipment ? JSON.parse(ws.equipment) : [],
is_active: Boolean(ws.is_active)
}))
res.json(parsedWorkspaces)
} catch (error) {
console.error('Error fetching workspaces:', error)
res.status(500).json({ error: 'Failed to fetch workspaces' })
}
})
// Get available workspaces for a time range
router.post('/availability', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const { start_time, end_time, type, capacity } = req.body
if (!start_time || !end_time) {
return res.status(400).json({ error: 'Start and end time required' })
}
let query = `
SELECT w.* FROM workspaces w
WHERE w.is_active = 1
AND w.id NOT IN (
SELECT DISTINCT workspace_id FROM bookings
WHERE status = 'confirmed'
AND (
(start_time <= ? AND end_time > ?)
OR (start_time < ? AND end_time >= ?)
OR (start_time >= ? AND end_time <= ?)
)
)
`
const params = [start_time, start_time, end_time, end_time, start_time, end_time]
if (type) {
query += ' AND w.type = ?'
params.push(type)
}
if (capacity) {
query += ' AND w.capacity >= ?'
params.push(capacity)
}
query += ' ORDER BY w.floor, w.name'
const availableWorkspaces = db.prepare(query).all(...params)
// Parse equipment JSON
const parsedWorkspaces = availableWorkspaces.map((ws: any) => ({
...ws,
equipment: ws.equipment ? JSON.parse(ws.equipment) : [],
is_active: Boolean(ws.is_active)
}))
res.json(parsedWorkspaces)
} catch (error) {
console.error('Error checking availability:', error)
res.status(500).json({ error: 'Failed to check availability' })
}
})
// Get workspace by ID
router.get('/:id', authenticateToken, (req: AuthRequest, res: Response) => {
try {
const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id)
if (!workspace) {
return res.status(404).json({ error: 'Workspace not found' })
}
// Parse equipment JSON
const parsedWorkspace = {
...(workspace as any),
equipment: (workspace as any).equipment ? JSON.parse((workspace as any).equipment) : [],
is_active: Boolean((workspace as any).is_active)
}
res.json(parsedWorkspace)
} catch (error) {
console.error('Error fetching workspace:', error)
res.status(500).json({ error: 'Failed to fetch workspace' })
}
})
// Create new workspace (admin only)
router.post('/', authenticateToken, (req: AuthRequest, res: Response) => {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' })
}
try {
const {
name,
type,
floor,
building,
capacity = 1,
equipment = [],
position_x,
position_y
} = req.body
if (!name || !type || !floor) {
return res.status(400).json({ error: 'Name, type, and floor are required' })
}
const id = uuidv4()
const now = new Date().toISOString()
db.prepare(`
INSERT INTO workspaces (
id, name, type, floor, building, capacity, equipment,
position_x, position_y, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, name, type, floor, building, capacity,
JSON.stringify(equipment), position_x, position_y,
1, now, now
)
const newWorkspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id)
res.status(201).json({
...(newWorkspace as any),
equipment: JSON.parse((newWorkspace as any).equipment || '[]'),
is_active: Boolean((newWorkspace as any).is_active)
})
} catch (error) {
console.error('Error creating workspace:', error)
res.status(500).json({ error: 'Failed to create workspace' })
}
})
// Update workspace (admin only)
router.put('/:id', authenticateToken, (req: AuthRequest, res: Response) => {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' })
}
try {
const {
name,
type,
floor,
building,
capacity,
equipment,
position_x,
position_y,
is_active
} = req.body
const existing = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id)
if (!existing) {
return res.status(404).json({ error: 'Workspace not found' })
}
const updates = []
const params = []
if (name !== undefined) {
updates.push('name = ?')
params.push(name)
}
if (type !== undefined) {
updates.push('type = ?')
params.push(type)
}
if (floor !== undefined) {
updates.push('floor = ?')
params.push(floor)
}
if (building !== undefined) {
updates.push('building = ?')
params.push(building)
}
if (capacity !== undefined) {
updates.push('capacity = ?')
params.push(capacity)
}
if (equipment !== undefined) {
updates.push('equipment = ?')
params.push(JSON.stringify(equipment))
}
if (position_x !== undefined) {
updates.push('position_x = ?')
params.push(position_x)
}
if (position_y !== undefined) {
updates.push('position_y = ?')
params.push(position_y)
}
if (is_active !== undefined) {
updates.push('is_active = ?')
params.push(is_active ? 1 : 0)
}
updates.push('updated_at = ?')
params.push(new Date().toISOString())
params.push(req.params.id)
db.prepare(`
UPDATE workspaces
SET ${updates.join(', ')}
WHERE id = ?
`).run(...params)
const updated = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id)
res.json({
...(updated as any),
equipment: JSON.parse((updated as any).equipment || '[]'),
is_active: Boolean((updated as any).is_active)
})
} catch (error) {
console.error('Error updating workspace:', error)
res.status(500).json({ error: 'Failed to update workspace' })
}
})
// Delete workspace (admin only)
router.delete('/:id', authenticateToken, (req: AuthRequest, res: Response) => {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' })
}
try {
// Check if workspace has active bookings
const activeBookings = db.prepare(`
SELECT COUNT(*) as count FROM bookings
WHERE workspace_id = ? AND status = 'confirmed'
AND end_time > ?
`).get(req.params.id, new Date().toISOString())
if ((activeBookings as any).count > 0) {
return res.status(400).json({
error: 'Cannot delete workspace with active bookings'
})
}
// Soft delete by setting is_active = 0
db.prepare(`
UPDATE workspaces SET is_active = 0, updated_at = ?
WHERE id = ?
`).run(new Date().toISOString(), req.params.id)
res.json({ message: 'Workspace deleted successfully' })
} catch (error) {
console.error('Error deleting workspace:', error)
res.status(500).json({ error: 'Failed to delete workspace' })
}
})
export default router

Datei anzeigen

@ -0,0 +1,138 @@
import nodemailer from 'nodemailer'
import { logger } from '../utils/logger'
interface EmailConfig {
host: string
port: number
secure: boolean
auth: {
user: string
pass: string
}
}
interface SendEmailOptions {
to: string
subject: string
text?: string
html?: string
}
class EmailService {
private transporter: nodemailer.Transporter | null = null
private isEnabled: boolean = false
constructor() {
this.initializeTransporter()
}
private initializeTransporter() {
const emailHost = process.env.EMAIL_HOST
const emailPort = parseInt(process.env.EMAIL_PORT || '587')
const emailUser = process.env.EMAIL_USER
const emailPass = process.env.EMAIL_PASS
const emailSecure = process.env.EMAIL_SECURE === 'true'
if (emailHost && emailUser && emailPass) {
const config: EmailConfig = {
host: emailHost,
port: emailPort,
secure: emailSecure,
auth: {
user: emailUser,
pass: emailPass
}
}
this.transporter = nodemailer.createTransport(config)
this.isEnabled = true
logger.info('Email service initialized successfully')
} else {
logger.warn('Email service not configured. Set EMAIL_HOST, EMAIL_USER, EMAIL_PASS environment variables.')
this.isEnabled = false
}
}
async sendEmail(options: SendEmailOptions): Promise<boolean> {
if (!this.isEnabled || !this.transporter) {
logger.warn('Email service not enabled. Cannot send email.')
return false
}
try {
const info = await this.transporter.sendMail({
from: process.env.EMAIL_FROM || process.env.EMAIL_USER,
to: options.to,
subject: options.subject,
text: options.text,
html: options.html
})
logger.info(`Email sent successfully to ${options.to}`, { messageId: info.messageId })
return true
} catch (error) {
logger.error('Failed to send email:', error)
return false
}
}
async sendInitialPassword(email: string, password: string, firstName?: string): Promise<boolean> {
const subject = 'SkillMate - Ihr Zugangs-Passwort'
const text = `Hallo${firstName ? ` ${firstName}` : ''},
Ihr SkillMate-Konto wurde erstellt. Hier sind Ihre Anmeldedaten:
E-Mail: ${email}
Passwort: ${password}
Bitte loggen Sie sich ein und ändern Sie Ihr Passwort bei der ersten Anmeldung.
Login-URL: ${process.env.FRONTEND_URL || 'http://localhost:5173/login'}
Mit freundlichen Grüßen,
Ihr SkillMate-Team`
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #2563eb;">SkillMate - Ihr Zugangs-Passwort</h2>
<p>Hallo${firstName ? ` ${firstName}` : ''},</p>
<p>Ihr SkillMate-Konto wurde erstellt. Hier sind Ihre Anmeldedaten:</p>
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Passwort:</strong> <code style="background-color: #e5e7eb; padding: 2px 4px; border-radius: 4px;">${password}</code></p>
</div>
<p>Bitte loggen Sie sich ein und ändern Sie Ihr Passwort bei der ersten Anmeldung.</p>
<p>
<a href="${process.env.FRONTEND_URL || 'http://localhost:5173/login'}"
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Jetzt einloggen
</a>
</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
<p style="color: #6b7280; font-size: 14px;">
Mit freundlichen Grüßen,<br>
Ihr SkillMate-Team
</p>
</div>`
return this.sendEmail({
to: email,
subject,
text,
html
})
}
isServiceEnabled(): boolean {
return this.isEnabled
}
}
export const emailService = new EmailService()

Datei anzeigen

@ -0,0 +1,110 @@
import CryptoJS from 'crypto-js'
import { randomBytes } from 'crypto'
import dotenv from 'dotenv'
// Load environment variables
dotenv.config()
// Generate a secure encryption key from environment or create a new one
const getEncryptionKey = (): string => {
let key = process.env.FIELD_ENCRYPTION_KEY
if (!key) {
// Use the default development key
key = 'dev_field_key_change_in_production_32chars_min!'
console.warn('⚠️ No FIELD_ENCRYPTION_KEY found. Using default development key.')
console.warn('⚠️ Set FIELD_ENCRYPTION_KEY in your .env file for production!')
}
return key
}
const ENCRYPTION_KEY = getEncryptionKey()
export class FieldEncryption {
/**
* Encrypt sensitive field data
*/
static encrypt(text: string | null | undefined): string | null {
if (!text) return null
try {
const encrypted = CryptoJS.AES.encrypt(text, ENCRYPTION_KEY).toString()
return encrypted
} catch (error) {
console.error('Encryption error:', error)
throw new Error('Failed to encrypt data')
}
}
/**
* Decrypt sensitive field data
*/
static decrypt(encryptedText: string | null | undefined): string | null {
if (!encryptedText) return null
try {
const decrypted = CryptoJS.AES.decrypt(encryptedText, ENCRYPTION_KEY)
return decrypted.toString(CryptoJS.enc.Utf8)
} catch (error) {
console.error('Decryption error:', error)
throw new Error('Failed to decrypt data')
}
}
/**
* Encrypt multiple fields in an object
*/
static encryptFields<T extends Record<string, any>>(
obj: T,
fields: (keyof T)[]
): T {
const encrypted = { ...obj }
for (const field of fields) {
if (encrypted[field]) {
encrypted[field] = this.encrypt(encrypted[field] as string) as any
}
}
return encrypted
}
/**
* Decrypt multiple fields in an object
*/
static decryptFields<T extends Record<string, any>>(
obj: T,
fields: (keyof T)[]
): T {
const decrypted = { ...obj }
for (const field of fields) {
if (decrypted[field]) {
decrypted[field] = this.decrypt(decrypted[field] as string) as any
}
}
return decrypted
}
/**
* Hash data for searching (one-way)
*/
static hash(text: string): string {
return CryptoJS.SHA256(text.toLowerCase()).toString()
}
}
// List of sensitive fields that should be encrypted
export const SENSITIVE_EMPLOYEE_FIELDS = [
'email',
'phone',
'mobile',
'clearance_level',
'clearance_valid_until'
]
export const SENSITIVE_USER_FIELDS = [
'email'
]

Datei anzeigen

@ -0,0 +1,138 @@
import { db } from '../config/database'
import { v4 as uuidv4 } from 'uuid'
import { logger } from '../utils/logger'
import * as cron from 'node-cron'
class ReminderService {
private reminderJob: cron.ScheduledTask | null = null
constructor() {
this.initializeScheduler()
}
private initializeScheduler() {
// T\u00e4glich um 9:00 Uhr pr\u00fcfen
this.reminderJob = cron.schedule('0 9 * * *', () => {
this.checkAndSendReminders()
})
logger.info('Reminder scheduler initialized')
}
async checkAndSendReminders() {
try {
const now = new Date()
const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString()
// Finde Profile die aktualisiert werden m\u00fcssen
const overdueProfiles = db.prepare(`
SELECT id, name, email, updated_at, review_due_at
FROM profiles
WHERE review_due_at <= ? OR updated_at <= ?
`).all(now.toISOString(), oneYearAgo)
for (const profile of overdueProfiles) {
// Pr\u00fcfe ob bereits ein Reminder existiert
const existingReminder = db.prepare(`
SELECT id FROM reminders
WHERE profile_id = ? AND type = 'annual_update'
AND sent_at IS NULL
`).get((profile as any).id)
if (!existingReminder) {
// Erstelle neuen Reminder
db.prepare(`
INSERT INTO reminders (id, profile_id, type, message, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(
uuidv4(),
(profile as any).id,
'annual_update',
`Ihr Profil wurde seit ${(profile as any).updated_at} nicht mehr aktualisiert. Bitte \u00fcberpr\u00fcfen Sie Ihre Angaben.`,
now.toISOString()
)
// TODO: Email-Versand implementieren
logger.info(`Reminder created for profile ${(profile as any).id}`)
}
}
// Markiere Profile als veraltet
db.prepare(`
UPDATE profiles
SET review_due_at = ?
WHERE updated_at <= ? AND review_due_at IS NULL
`).run(
new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(), // +30 Tage Grace Period
oneYearAgo
)
logger.info(`Reminder check completed. ${overdueProfiles.length} profiles need update.`)
} catch (error) {
logger.error('Error in reminder service:', error)
}
}
async sendReminder(profileId: string, type: string, message: string) {
try {
const profile = db.prepare(`
SELECT email, name FROM profiles WHERE id = ?
`).get(profileId) as any
if (!profile) {
logger.error(`Profile ${profileId} not found for reminder`)
return
}
// TODO: Email-Service integrieren
// await emailService.send({
// to: (profile as any).email,
// subject: 'SkillMate Profil-Aktualisierung',
// body: message
// })
// Markiere als gesendet
db.prepare(`
UPDATE reminders
SET sent_at = ?
WHERE profile_id = ? AND type = ? AND sent_at IS NULL
`).run(new Date().toISOString(), profileId, type)
logger.info(`Reminder sent to ${(profile as any).email}`)
} catch (error) {
logger.error(`Error sending reminder to profile ${profileId}:`, error)
}
}
async acknowledgeReminder(reminderId: string, userId: string) {
db.prepare(`
UPDATE reminders
SET acknowledged_at = ?
WHERE id = ?
`).run(new Date().toISOString(), reminderId)
// Aktualisiere review_due_at f\u00fcr das Profil
const reminder = db.prepare(`
SELECT profile_id FROM reminders WHERE id = ?
`).get(reminderId) as any
if (reminder) {
const nextReviewDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
db.prepare(`
UPDATE profiles
SET review_due_at = ?, updated_at = ?, updated_by = ?
WHERE id = ?
`).run(nextReviewDate, new Date().toISOString(), userId, reminder.profile_id)
}
}
stop() {
if (this.reminderJob) {
this.reminderJob.stop()
logger.info('Reminder scheduler stopped')
}
}
}
export const reminderService = new ReminderService()

Datei anzeigen

@ -0,0 +1,129 @@
import { syncService } from './syncService'
import { db } from '../config/database'
import { logger } from '../utils/logger'
interface SyncInterval {
value: string
milliseconds: number
}
const SYNC_INTERVALS: Record<string, SyncInterval> = {
'5min': { value: '5min', milliseconds: 5 * 60 * 1000 },
'15min': { value: '15min', milliseconds: 15 * 60 * 1000 },
'30min': { value: '30min', milliseconds: 30 * 60 * 1000 },
'1hour': { value: '1hour', milliseconds: 60 * 60 * 1000 },
'daily': { value: 'daily', milliseconds: 24 * 60 * 60 * 1000 }
}
export class SyncScheduler {
private static instance: SyncScheduler
private intervalId: NodeJS.Timeout | null = null
private currentInterval: string = 'disabled'
private constructor() {
this.initialize()
}
static getInstance(): SyncScheduler {
if (!SyncScheduler.instance) {
SyncScheduler.instance = new SyncScheduler()
}
return SyncScheduler.instance
}
private initialize() {
// Check current sync settings on startup
this.checkAndUpdateInterval()
// Check for interval changes every minute
setInterval(() => {
this.checkAndUpdateInterval()
}, 60000)
}
private checkAndUpdateInterval() {
try {
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any
const newInterval = settings?.auto_sync_interval || 'disabled'
if (newInterval !== this.currentInterval) {
logger.info(`Sync interval changed from ${this.currentInterval} to ${newInterval}`)
this.currentInterval = newInterval
this.updateSchedule()
}
} catch (error) {
logger.error('Failed to check sync interval:', error)
}
}
private updateSchedule() {
// Clear existing interval
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null
}
// Set new interval if not disabled
if (this.currentInterval !== 'disabled' && SYNC_INTERVALS[this.currentInterval]) {
const { milliseconds } = SYNC_INTERVALS[this.currentInterval]
logger.info(`Starting automatic sync with interval: ${this.currentInterval}`)
// Run sync immediately
this.runSync()
// Then schedule regular syncs
this.intervalId = setInterval(() => {
this.runSync()
}, milliseconds)
} else {
logger.info('Automatic sync disabled')
}
}
private async runSync() {
try {
logger.info('Running scheduled sync...')
// Check if we're the admin node
const nodeType = process.env.NODE_TYPE || 'local'
if (nodeType === 'admin') {
// Admin pushes to all local nodes
await syncService.triggerSync()
} else {
// Local nodes sync with admin
const adminNode = db.prepare(`
SELECT id FROM network_nodes
WHERE type = 'admin' AND is_online = 1
LIMIT 1
`).get() as any
if (adminNode) {
await syncService.syncWithNode(adminNode.id)
} else {
logger.warn('No admin node available for sync')
}
}
logger.info('Scheduled sync completed')
} catch (error) {
logger.error('Scheduled sync failed:', error)
}
}
// Manual trigger for testing
async triggerManualSync() {
await this.runSync()
}
// Get current status
getStatus() {
return {
enabled: this.currentInterval !== 'disabled',
interval: this.currentInterval,
nextRun: this.intervalId ? new Date(Date.now() + (SYNC_INTERVALS[this.currentInterval]?.milliseconds || 0)) : null
}
}
}
export const syncScheduler = SyncScheduler.getInstance()

Datei anzeigen

@ -0,0 +1,573 @@
import { db } from '../config/database'
import axios from 'axios'
import crypto from 'crypto'
import { logger } from '../utils/logger'
interface SyncPayload {
type: 'employees' | 'skills' | 'users' | 'settings'
action: 'create' | 'update' | 'delete'
data: any
timestamp: string
nodeId: string
checksum: string
}
interface SyncResult {
success: boolean
syncedItems: number
conflicts: any[]
errors: any[]
}
export class SyncService {
private static instance: SyncService
private syncQueue: SyncPayload[] = []
private isSyncing = false
private constructor() {
// Initialize sync tables
this.initializeSyncTables()
}
static getInstance(): SyncService {
if (!SyncService.instance) {
SyncService.instance = new SyncService()
}
return SyncService.instance
}
private initializeSyncTables() {
// Sync log table to track all sync operations
db.exec(`
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
sync_type TEXT NOT NULL,
sync_action TEXT NOT NULL,
entity_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
payload TEXT NOT NULL,
checksum TEXT NOT NULL,
status TEXT CHECK(status IN ('pending', 'completed', 'failed', 'conflict')) NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
)
`)
// Conflict resolution table
db.exec(`
CREATE TABLE IF NOT EXISTS sync_conflicts (
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
local_data TEXT NOT NULL,
remote_data TEXT NOT NULL,
conflict_type TEXT NOT NULL,
resolution_status TEXT CHECK(resolution_status IN ('pending', 'resolved', 'ignored')) DEFAULT 'pending',
resolved_by TEXT,
resolved_at TEXT,
created_at TEXT NOT NULL
)
`)
// Sync metadata table
db.exec(`
CREATE TABLE IF NOT EXISTS sync_metadata (
node_id TEXT PRIMARY KEY,
last_sync_at TEXT,
last_successful_sync TEXT,
sync_version INTEGER DEFAULT 1,
total_synced_items INTEGER DEFAULT 0,
total_conflicts INTEGER DEFAULT 0,
total_errors INTEGER DEFAULT 0
)
`)
}
// Generate checksum for data integrity
private generateChecksum(data: any): string {
const hash = crypto.createHash('sha256')
hash.update(JSON.stringify(data))
return hash.digest('hex')
}
// Add item to sync queue
async queueSync(type: SyncPayload['type'], action: SyncPayload['action'], data: any) {
const nodeId = this.getNodeId()
const payload: SyncPayload = {
type,
action,
data,
timestamp: new Date().toISOString(),
nodeId,
checksum: this.generateChecksum(data)
}
this.syncQueue.push(payload)
// Log to sync_log
db.prepare(`
INSERT INTO sync_log (
id, node_id, sync_type, sync_action, entity_id, entity_type,
payload, checksum, status, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
crypto.randomUUID(),
nodeId,
type,
action,
data.id || 'unknown',
type,
JSON.stringify(payload),
payload.checksum,
'pending',
payload.timestamp
)
// Trigger sync if auto-sync is enabled
const syncSettings = this.getSyncSettings()
if (syncSettings.autoSyncInterval !== 'disabled') {
this.triggerSync()
}
}
// Sync with remote nodes
async syncWithNode(targetNodeId: string): Promise<SyncResult> {
const result: SyncResult = {
success: false,
syncedItems: 0,
conflicts: [],
errors: []
}
try {
const targetNode = this.getNodeInfo(targetNodeId)
if (!targetNode) {
throw new Error('Target node not found')
}
// Get pending sync items
const pendingItems = db.prepare(`
SELECT * FROM sync_log
WHERE status = 'pending'
ORDER BY created_at ASC
`).all() as any[]
for (const item of pendingItems) {
try {
const payload = JSON.parse(item.payload)
// Send to target node
const response = await axios.post(
`http://${targetNode.ip_address}:${targetNode.port}/api/sync/receive`,
payload,
{
headers: {
'Authorization': `Bearer ${targetNode.api_key}`,
'X-Node-Id': this.getNodeId()
},
timeout: 30000
}
)
if (response.data.success) {
// Mark as completed
db.prepare(`
UPDATE sync_log
SET status = 'completed', completed_at = ?
WHERE id = ?
`).run(new Date().toISOString(), item.id)
result.syncedItems++
} else if (response.data.conflict) {
// Handle conflict
this.handleConflict(item, response.data.conflictData)
result.conflicts.push({
itemId: item.id,
conflict: response.data.conflictData
})
}
} catch (error: any) {
logger.error(`Sync error for item ${item.id}:`, error)
// Mark as failed
db.prepare(`
UPDATE sync_log
SET status = 'failed', error_message = ?
WHERE id = ?
`).run(error.message, item.id)
result.errors.push({
itemId: item.id,
error: error.message
})
}
}
// Update sync metadata
this.updateSyncMetadata(targetNodeId, result)
result.success = result.errors.length === 0
} catch (error: any) {
logger.error('Sync failed:', error)
result.errors.push({ error: error.message })
}
return result
}
// Receive sync from remote node
async receiveSync(payload: SyncPayload): Promise<any> {
try {
// Verify checksum
const calculatedChecksum = this.generateChecksum(payload.data)
if (calculatedChecksum !== payload.checksum) {
throw new Error('Checksum mismatch - data integrity error')
}
// Check for conflicts
const conflict = await this.checkForConflicts(payload)
if (conflict) {
return {
success: false,
conflict: true,
conflictData: conflict
}
}
// Apply changes based on type and action
await this.applyChanges(payload)
return { success: true }
} catch (error: any) {
logger.error('Error receiving sync:', error)
return {
success: false,
error: error.message
}
}
}
// Check for conflicts
private async checkForConflicts(payload: SyncPayload): Promise<any> {
const { type, action, data } = payload
if (action === 'create') {
// Check if entity already exists
let exists = false
switch (type) {
case 'employees':
exists = !!db.prepare('SELECT id FROM employees WHERE id = ?').get(data.id)
break
case 'skills':
exists = !!db.prepare('SELECT id FROM skills WHERE id = ?').get(data.id)
break
case 'users':
exists = !!db.prepare('SELECT id FROM users WHERE id = ?').get(data.id)
break
}
if (exists) {
return {
type: 'already_exists',
entityId: data.id,
entityType: type
}
}
} else if (action === 'update') {
// Check if local version is newer
let localEntity: any = null
switch (type) {
case 'employees':
localEntity = db.prepare('SELECT * FROM employees WHERE id = ?').get(data.id)
break
case 'skills':
localEntity = db.prepare('SELECT * FROM skills WHERE id = ?').get(data.id)
break
case 'users':
localEntity = db.prepare('SELECT * FROM users WHERE id = ?').get(data.id)
break
}
if (localEntity && new Date(localEntity.updated_at) > new Date(data.updatedAt)) {
return {
type: 'newer_version',
entityId: data.id,
entityType: type,
localVersion: localEntity,
remoteVersion: data
}
}
}
return null
}
// Apply changes to local database
private async applyChanges(payload: SyncPayload) {
const { type, action, data } = payload
switch (type) {
case 'employees':
await this.syncEmployee(action, data)
break
case 'skills':
await this.syncSkill(action, data)
break
case 'users':
await this.syncUser(action, data)
break
case 'settings':
await this.syncSettings(action, data)
break
}
}
// Sync employee data
private async syncEmployee(action: string, data: any) {
switch (action) {
case 'create':
db.prepare(`
INSERT INTO employees (
id, first_name, last_name, employee_number, photo, position,
department, email, phone, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
created_at, updated_at, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.id, data.firstName, data.lastName, data.employeeNumber,
data.photo, data.position, data.department, data.email,
data.phone, data.mobile, data.office, data.availability,
data.clearance?.level, data.clearance?.validUntil,
data.clearance?.issuedDate, data.createdAt, data.updatedAt,
data.createdBy
)
// Sync skills
if (data.skills) {
for (const skill of data.skills) {
db.prepare(`
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
data.id, skill.id, skill.level,
skill.verified ? 1 : 0, skill.verifiedBy, skill.verifiedDate
)
}
}
break
case 'update':
db.prepare(`
UPDATE employees SET
first_name = ?, last_name = ?, position = ?, department = ?,
email = ?, phone = ?, mobile = ?, office = ?, availability = ?,
updated_at = ?, updated_by = ?
WHERE id = ?
`).run(
data.firstName, data.lastName, data.position, data.department,
data.email, data.phone, data.mobile, data.office, data.availability,
data.updatedAt, data.updatedBy, data.id
)
break
case 'delete':
db.prepare('DELETE FROM employees WHERE id = ?').run(data.id)
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(data.id)
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(data.id)
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(data.id)
break
}
}
// Sync skill data
private async syncSkill(action: string, data: any) {
switch (action) {
case 'create':
db.prepare(`
INSERT INTO skills (id, name, category, description, requires_certification, expires_after)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
data.id, data.name, data.category, data.description,
data.requiresCertification ? 1 : 0, data.expiresAfter
)
break
case 'update':
db.prepare(`
UPDATE skills SET name = ?, category = ?, description = ?
WHERE id = ?
`).run(data.name, data.category, data.description, data.id)
break
case 'delete':
db.prepare('DELETE FROM skills WHERE id = ?').run(data.id)
break
}
}
// Sync user data
private async syncUser(action: string, data: any) {
// Implementation for user sync
logger.info(`Syncing user: ${action}`, data)
}
// Sync settings
private async syncSettings(action: string, data: any) {
// Implementation for settings sync
logger.info(`Syncing settings: ${action}`, data)
}
// Handle conflicts
private handleConflict(syncItem: any, conflictData: any) {
const conflictId = crypto.randomUUID()
db.prepare(`
INSERT INTO sync_conflicts (
id, entity_id, entity_type, local_data, remote_data,
conflict_type, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
conflictId,
conflictData.entityId,
conflictData.entityType,
JSON.stringify(conflictData.localVersion || {}),
JSON.stringify(conflictData.remoteVersion || {}),
conflictData.type,
new Date().toISOString()
)
// Apply conflict resolution strategy
const settings = this.getSyncSettings()
if (settings.conflictResolution === 'admin') {
// Admin wins - apply remote changes if from admin node
const remoteNode = this.getNodeInfo(syncItem.node_id)
if (remoteNode?.type === 'admin') {
this.applyChanges(JSON.parse(syncItem.payload))
}
} else if (settings.conflictResolution === 'newest') {
// Newest wins - compare timestamps
const localTime = new Date(conflictData.localVersion?.updatedAt || 0)
const remoteTime = new Date(conflictData.remoteVersion?.updatedAt || 0)
if (remoteTime > localTime) {
this.applyChanges(JSON.parse(syncItem.payload))
}
}
// Manual resolution - do nothing, let admin resolve
}
// Get sync settings
private getSyncSettings(): any {
const settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
return settings || {
autoSyncInterval: 'disabled',
conflictResolution: 'admin'
}
}
// Get node information
private getNodeInfo(nodeId: string): any {
return db.prepare('SELECT * FROM network_nodes WHERE id = ?').get(nodeId)
}
// Get current node ID
private getNodeId(): string {
// Get from environment or generate
return process.env.NODE_ID || 'local-node'
}
// Update sync metadata
private updateSyncMetadata(nodeId: string, result: SyncResult) {
const existing = db.prepare('SELECT * FROM sync_metadata WHERE node_id = ?').get(nodeId) as any
if (existing) {
db.prepare(`
UPDATE sync_metadata SET
last_sync_at = ?,
last_successful_sync = ?,
total_synced_items = total_synced_items + ?,
total_conflicts = total_conflicts + ?,
total_errors = total_errors + ?
WHERE node_id = ?
`).run(
new Date().toISOString(),
result.success ? new Date().toISOString() : existing.last_successful_sync,
result.syncedItems,
result.conflicts.length,
result.errors.length,
nodeId
)
} else {
db.prepare(`
INSERT INTO sync_metadata (
node_id, last_sync_at, last_successful_sync,
total_synced_items, total_conflicts, total_errors
) VALUES (?, ?, ?, ?, ?, ?)
`).run(
nodeId,
new Date().toISOString(),
result.success ? new Date().toISOString() : null,
result.syncedItems,
result.conflicts.length,
result.errors.length
)
}
}
// Trigger sync process
async triggerSync() {
if (this.isSyncing) {
logger.info('Sync already in progress')
return
}
this.isSyncing = true
try {
// Get all active nodes
const nodes = db.prepare(`
SELECT * FROM network_nodes
WHERE is_online = 1 AND id != ?
`).all(this.getNodeId()) as any[]
for (const node of nodes) {
logger.info(`Syncing with node: ${node.name}`)
await this.syncWithNode(node.id)
}
} catch (error) {
logger.error('Sync trigger failed:', error)
} finally {
this.isSyncing = false
}
}
// Get sync status
getSyncStatus(): any {
const pendingCount = db.prepare(
'SELECT COUNT(*) as count FROM sync_log WHERE status = ?'
).get('pending') as any
const recentSync = db.prepare(`
SELECT * FROM sync_log
ORDER BY created_at DESC
LIMIT 10
`).all()
const conflicts = db.prepare(`
SELECT COUNT(*) as count FROM sync_conflicts
WHERE resolution_status = ?
`).get('pending') as any
return {
pendingItems: pendingCount.count,
recentSync,
pendingConflicts: conflicts.count,
isSyncing: this.isSyncing
}
}
}
export const syncService = SyncService.getInstance()

33
backend/src/utils/logger.ts Normale Datei
Datei anzeigen

@ -0,0 +1,33 @@
import winston from 'winston'
import path from 'path'
const logDir = process.env.LOG_PATH || path.join(process.cwd(), 'logs')
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: { service: 'skillmate-backend' },
transports: [
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error'
}),
new winston.transports.File({
filename: path.join(logDir, 'combined.log')
})
]
})
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}))
}

19
backend/tsconfig.json Normale Datei
Datei anzeigen

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

60
debug-console.cmd Normale Datei
Datei anzeigen

@ -0,0 +1,60 @@
@echo off
title SkillMate Debug Console - Alle Services
color 0E
echo.
echo ================================================
echo SkillMate Debug Console - Alle Services
echo Backend mit vollstaendigem Debug-Log
echo + Frontend + Admin Panel
echo ================================================
echo.
echo Starting all SkillMate services...
echo Zeit: %DATE% %TIME%
echo.
REM Start Frontend
echo Starting Frontend on port 5173...
start "Frontend" cmd /k "cd frontend && npm run dev"
REM Wait a bit
timeout /t 2 /nobreak > nul
REM Start Admin Panel
echo Starting Admin Panel on port 3006...
start "Admin Panel" cmd /k "cd admin-panel && npm run dev"
REM Wait a bit more
timeout /t 3 /nobreak > nul
REM Open browsers after services start
echo Opening browsers...
timeout /t 5 /nobreak > nul
start http://localhost:3006
timeout /t 2 /nobreak > nul
start http://localhost:5173
echo.
echo ================================================
echo Backend Debug Log startet jetzt...
echo Alle API-Requests werden hier angezeigt
echo ================================================
echo.
cd backend
set PORT=3005
set FIELD_ENCRYPTION_KEY=dev_field_key_change_in_production_32chars_min!
set NODE_ENV=development
set DEBUG=*
echo Backend laeuft auf http://localhost:3005
echo Frontend laeuft auf http://localhost:5173
echo Admin Panel laeuft auf http://localhost:3006
echo.
echo === DEBUG LOG ===
echo.
node full-backend-3005.js
echo.
echo Backend wurde beendet. Druecken Sie eine Taste zum Schliessen...
pause

Datei anzeigen

@ -0,0 +1,91 @@
{
"appId": "de.skillmate.app",
"productName": "SkillMate",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"dist/**/*",
"electron/**/*",
"node_modules/**/*",
"!**/*.ts",
"!**/*.map",
"!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}",
"!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}",
"!**/node_modules/*.d.ts",
"!**/node_modules/.bin"
],
"extraResources": [
{
"from": "../backend/dist",
"to": "backend",
"filter": ["**/*"]
},
{
"from": "../backend/node_modules",
"to": "backend/node_modules",
"filter": ["**/*"]
}
],
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64"]
}
],
"icon": "build/icon.ico",
"publisherName": "SkillMate Development",
"certificateSubjectName": "SkillMate Development",
"requestedExecutionLevel": "asInvoker"
},
"msi": {
"oneClick": false,
"perMachine": true,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"displayLanguageSelector": false,
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"uninstallDisplayName": "SkillMate",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "SkillMate",
"runAfterFinish": true,
"menuCategory": true,
"description": "Mitarbeiter-Skills-Management für Sicherheitsbehörden",
"branding": "SkillMate - Professionelle Mitarbeiterverwaltung",
"vendor": "SkillMate Development",
"installerHeaderIcon": "build/icon.ico",
"ui": {
"chooseDirectory": true,
"images": {
"background": "build/installer-background.jpg",
"banner": "build/installer-banner.jpg"
}
}
},
"nsis": {
"oneClick": false,
"perMachine": true,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"displayLanguageSelector": false,
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"uninstallDisplayName": "SkillMate",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "SkillMate",
"menuCategory": true,
"description": "Mitarbeiter-Skills-Management für Sicherheitsbehörden",
"language": "1031",
"multiLanguageInstaller": false,
"installerHeader": "build/installer-header.bmp",
"installerSidebar": "build/installer-sidebar.bmp"
},
"publish": null
}

210
frontend/electron/main.js Normale Datei
Datei anzeigen

@ -0,0 +1,210 @@
const { app, BrowserWindow, ipcMain, Menu, Tray } = require('electron')
const path = require('path')
const { spawn } = require('child_process')
const isDev = process.env.NODE_ENV === 'development' || (!app.isPackaged && !process.env.NODE_ENV)
let mainWindow
let tray
let backendProcess
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1200,
minHeight: 700,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
icon: path.join(__dirname, '../public/icon.png'),
titleBarStyle: 'hiddenInset',
frame: process.platform !== 'win32',
backgroundColor: '#F8FAFC'
})
if (isDev) {
mainWindow.loadURL('http://localhost:5173')
mainWindow.webContents.openDevTools()
} else {
// In production, load the built files
const indexPath = path.join(__dirname, '../dist/index.html')
console.log('Loading:', indexPath)
mainWindow.loadFile(indexPath)
// DevTools nur öffnen wenn explizit gewünscht
// mainWindow.webContents.openDevTools()
// Log any errors
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Failed to load:', errorCode, errorDescription)
})
mainWindow.webContents.on('console-message', (event, level, message) => {
console.log('Console:', message)
})
// Warte bis Seite geladen ist
mainWindow.webContents.on('did-finish-load', () => {
console.log('Page loaded successfully')
})
}
mainWindow.on('closed', () => {
mainWindow = null
})
// Custom window controls for Windows
if (process.platform === 'win32') {
mainWindow.on('maximize', () => {
mainWindow.webContents.send('window-maximized')
})
mainWindow.on('unmaximize', () => {
mainWindow.webContents.send('window-unmaximized')
})
}
}
function createTray() {
// Tray-Funktion vorerst deaktiviert, da Icon fehlt
// TODO: Tray-Icon hinzufügen
return
tray = new Tray(path.join(__dirname, '../public/tray-icon.png'))
const contextMenu = Menu.buildFromTemplate([
{
label: 'SkillMate öffnen',
click: () => {
if (mainWindow) {
mainWindow.show()
} else {
createWindow()
}
}
},
{ type: 'separator' },
{
label: 'Beenden',
click: () => {
app.quit()
}
}
])
tray.setToolTip('SkillMate')
tray.setContextMenu(contextMenu)
tray.on('click', () => {
if (mainWindow) {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
} else {
createWindow()
}
})
}
function startBackend() {
// Backend nur im Development-Modus automatisch starten
// In Production wird es extern gestartet
return
if (!isDev) {
// In production, backend is bundled with the app
const backendPath = app.isPackaged
? path.join(process.resourcesPath, 'backend', 'index.js')
: path.join(__dirname, '../../backend/dist/index.js')
console.log('Starting backend from:', backendPath)
// Check if backend exists
const fs = require('fs')
if (!fs.existsSync(backendPath)) {
console.error('Backend not found at:', backendPath)
// Try alternative path
const altPath = path.join(__dirname, '../backend/index.js')
console.log('Trying alternative path:', altPath)
if (fs.existsSync(altPath)) {
backendPath = altPath
}
}
backendProcess = spawn('node', [backendPath], {
env: {
...process.env,
NODE_ENV: 'production',
PORT: '3001',
DATABASE_PATH: path.join(app.getPath('userData'), 'skillmate.db'),
LOG_PATH: path.join(app.getPath('userData'), 'logs')
},
stdio: ['pipe', 'pipe', 'pipe']
})
backendProcess.stdout.on('data', (data) => {
console.log(`Backend: ${data}`)
})
backendProcess.stderr.on('data', (data) => {
console.error(`Backend Error: ${data}`)
})
backendProcess.on('error', (error) => {
console.error('Failed to start backend:', error)
})
backendProcess.on('exit', (code) => {
console.log(`Backend exited with code ${code}`)
})
}
}
app.whenReady().then(() => {
createWindow()
createTray()
startBackend()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('before-quit', () => {
if (backendProcess) {
backendProcess.kill()
}
})
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
// IPC handlers
ipcMain.handle('app:minimize', () => {
mainWindow.minimize()
})
ipcMain.handle('app:maximize', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize()
} else {
mainWindow.maximize()
}
})
ipcMain.handle('app:close', () => {
mainWindow.close()
})
ipcMain.handle('app:getVersion', () => {
return app.getVersion()
})
ipcMain.handle('app:getPath', (event, name) => {
return app.getPath(name)
})

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen