Initial commit
Dieser Commit ist enthalten in:
44
.claude/settings.local.json
Normale Datei
44
.claude/settings.local.json
Normale Datei
@ -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": []
|
||||
}
|
||||
}
|
||||
172
.cleanup-backup/PROJEKT-STATUS.txt
Normale Datei
172
.cleanup-backup/PROJEKT-STATUS.txt
Normale Datei
@ -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
|
||||
================================================================================
|
||||
61
.cleanup-backup/README-REFACTORED.md
Normale Datei
61
.cleanup-backup/README-REFACTORED.md
Normale Datei
@ -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!
|
||||
58
.cleanup-backup/debug-start.py
Normale Datei
58
.cleanup-backup/debug-start.py
Normale Datei
@ -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()
|
||||
62
.cleanup-backup/fix-admin-password.js
Normale Datei
62
.cleanup-backup/fix-admin-password.js
Normale Datei
@ -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();
|
||||
71
.cleanup-backup/fix-encryption.js
Normale Datei
71
.cleanup-backup/fix-encryption.js
Normale Datei
@ -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();
|
||||
}
|
||||
13
.cleanup-backup/fix-sqlite.cmd
Normale Datei
13
.cleanup-backup/fix-sqlite.cmd
Normale Datei
@ -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
|
||||
62
.cleanup-backup/fix-user-table.js
Normale Datei
62
.cleanup-backup/fix-user-table.js
Normale Datei
@ -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();
|
||||
141
.cleanup-backup/main-windows.py
Normale Datei
141
.cleanup-backup/main-windows.py
Normale Datei
@ -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()
|
||||
74
.cleanup-backup/reset-admin.js
Normale Datei
74
.cleanup-backup/reset-admin.js
Normale Datei
@ -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
67
.cleanup-backup/test-auth.js
Normale Datei
@ -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();
|
||||
10
.cleanup-backup/test-backend.cmd
Normale Datei
10
.cleanup-backup/test-backend.cmd
Normale Datei
@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
echo Testing Backend...
|
||||
|
||||
cd backend
|
||||
|
||||
echo.
|
||||
echo Running npm run dev...
|
||||
npm run dev
|
||||
|
||||
pause
|
||||
87
.cleanup-backup/test-create-employee.js
Normale Datei
87
.cleanup-backup/test-create-employee.js
Normale Datei
@ -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();
|
||||
88
.cleanup-backup/test-employee-creation.js
Normale Datei
88
.cleanup-backup/test-employee-creation.js
Normale Datei
@ -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();
|
||||
68
.cleanup-backup/test-login-direct.js
Normale Datei
68
.cleanup-backup/test-login-direct.js
Normale Datei
@ -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\\"}"');
|
||||
});
|
||||
72
.cleanup-backup/test-minimal-employee.js
Normale Datei
72
.cleanup-backup/test-minimal-employee.js
Normale Datei
@ -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();
|
||||
89
.cleanup-backup/test-user-admin.js
Normale Datei
89
.cleanup-backup/test-user-admin.js
Normale Datei
@ -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
69
.gitignore
vendored
Normale Datei
@ -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
177
ANWENDUNGSBESCHREIBUNG.txt
Normale Datei
@ -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
277
CLAUDE_PROJECT_README.md
Normale Datei
@ -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
139
EXE-ERSTELLEN.md
Normale Datei
@ -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
267
INSTALLATION.md
Normale Datei
@ -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
29
LICENSE.txt
Normale Datei
@ -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
136
README.md
Normale Datei
@ -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.
|
||||
689
VOLLSTAENDIGE_DOKUMENTATION.txt
Normale Datei
689
VOLLSTAENDIGE_DOKUMENTATION.txt
Normale Datei
@ -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
13
admin-panel/index.html
Normale Datei
@ -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
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
31
admin-panel/package.json
Normale Datei
@ -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"
|
||||
}
|
||||
}
|
||||
6
admin-panel/postcss.config.js
Normale Datei
6
admin-panel/postcss.config.js
Normale Datei
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
49
admin-panel/src/App.tsx
Normale Datei
49
admin-panel/src/App.tsx
Normale Datei
@ -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
|
||||
7
admin-panel/src/components/HomeIcon.tsx
Normale Datei
7
admin-panel/src/components/HomeIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
85
admin-panel/src/components/Layout.tsx
Normale Datei
85
admin-panel/src/components/Layout.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/MoonIcon.tsx
Normale Datei
7
admin-panel/src/components/MoonIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/SearchIcon.tsx
Normale Datei
7
admin-panel/src/components/SearchIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
8
admin-panel/src/components/SettingsIcon.tsx
Normale Datei
8
admin-panel/src/components/SettingsIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/SunIcon.tsx
Normale Datei
7
admin-panel/src/components/SunIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
130
admin-panel/src/components/SyncStatus.tsx
Normale Datei
130
admin-panel/src/components/SyncStatus.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
7
admin-panel/src/components/UsersIcon.tsx
Normale Datei
7
admin-panel/src/components/UsersIcon.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
110
admin-panel/src/components/icons.tsx
Normale Datei
110
admin-panel/src/components/icons.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
6
admin-panel/src/components/index.ts
Normale Datei
6
admin-panel/src/components/index.ts
Normale Datei
@ -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
198
admin-panel/src/index.css
Normale Datei
@ -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
10
admin-panel/src/main.tsx
Normale Datei
@ -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>,
|
||||
)
|
||||
39
admin-panel/src/services/api.ts
Normale Datei
39
admin-panel/src/services/api.ts
Normale Datei
@ -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
|
||||
66
admin-panel/src/services/networkApi.ts
Normale Datei
66
admin-panel/src/services/networkApi.ts
Normale Datei
@ -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
|
||||
}
|
||||
}
|
||||
26
admin-panel/src/stores/authStore.ts
Normale Datei
26
admin-panel/src/stores/authStore.ts
Normale Datei
@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
21
admin-panel/src/stores/themeStore.ts
Normale Datei
21
admin-panel/src/stores/themeStore.ts
Normale Datei
@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
203
admin-panel/src/styles/index.css
Normale Datei
203
admin-panel/src/styles/index.css
Normale Datei
@ -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;
|
||||
}
|
||||
}
|
||||
281
admin-panel/src/views/CreateEmployee.tsx
Normale Datei
281
admin-panel/src/views/CreateEmployee.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
160
admin-panel/src/views/Dashboard.tsx
Normale Datei
160
admin-panel/src/views/Dashboard.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
157
admin-panel/src/views/EmailSettings.tsx
Normale Datei
157
admin-panel/src/views/EmailSettings.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
251
admin-panel/src/views/EmployeeForm.tsx
Normale Datei
251
admin-panel/src/views/EmployeeForm.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
398
admin-panel/src/views/EmployeeFormComplete.tsx
Normale Datei
398
admin-panel/src/views/EmployeeFormComplete.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
119
admin-panel/src/views/EmployeeManagement.tsx
Normale Datei
119
admin-panel/src/views/EmployeeManagement.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
99
admin-panel/src/views/Login.tsx
Normale Datei
99
admin-panel/src/views/Login.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
283
admin-panel/src/views/SkillManagement.tsx
Normale Datei
283
admin-panel/src/views/SkillManagement.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
517
admin-panel/src/views/SyncSettings.tsx
Normale Datei
517
admin-panel/src/views/SyncSettings.tsx
Normale Datei
@ -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>
|
||||
)
|
||||
}
|
||||
658
admin-panel/src/views/UserManagement.tsx
Normale Datei
658
admin-panel/src/views/UserManagement.tsx
Normale Datei
@ -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 E‑Mail.</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
className="input-field w-80"
|
||||
placeholder="E‑Mail, 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
107
admin-panel/tailwind.config.js
Normale Datei
@ -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
24
admin-panel/tsconfig.json
Normale Datei
@ -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" }]
|
||||
}
|
||||
10
admin-panel/tsconfig.node.json
Normale Datei
10
admin-panel/tsconfig.node.json
Normale Datei
@ -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
19
admin-panel/vite.config.ts
Normale Datei
@ -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
16
backend/.gitignore
vendored
Normale Datei
@ -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
72
backend/create-test-user.js
Normale Datei
@ -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
332
backend/full-backend-3005.js
Normale Datei
@ -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
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
51
backend/package.json
Normale Datei
@ -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"
|
||||
}
|
||||
}
|
||||
71
backend/scripts/migrate-users.js
Normale Datei
71
backend/scripts/migrate-users.js
Normale Datei
@ -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()
|
||||
|
||||
88
backend/scripts/purge-users.js
Normale Datei
88
backend/scripts/purge-users.js
Normale Datei
@ -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()
|
||||
|
||||
76
backend/scripts/reset-admin.js
Normale Datei
76
backend/scripts/reset-admin.js
Normale Datei
@ -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()
|
||||
|
||||
64
backend/scripts/seed-skills-from-frontend.js
Normale Datei
64
backend/scripts/seed-skills-from-frontend.js
Normale Datei
@ -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
444
backend/src/config/database.ts
Normale Datei
@ -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);
|
||||
`)
|
||||
}
|
||||
360
backend/src/config/secureDatabase.ts
Normale Datei
360
backend/src/config/secureDatabase.ts
Normale Datei
@ -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
73
backend/src/index.ts
Normale Datei
@ -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
|
||||
53
backend/src/middleware/auth.ts
Normale Datei
53
backend/src/middleware/auth.ts
Normale Datei
@ -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()
|
||||
}
|
||||
}
|
||||
38
backend/src/middleware/errorHandler.ts
Normale Datei
38
backend/src/middleware/errorHandler.ts
Normale Datei
@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
93
backend/src/middleware/roleAuth.ts
Normale Datei
93
backend/src/middleware/roleAuth.ts
Normale Datei
@ -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()
|
||||
}
|
||||
}
|
||||
235
backend/src/routes/analytics.ts
Normale Datei
235
backend/src/routes/analytics.ts
Normale Datei
@ -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
121
backend/src/routes/auth.ts
Normale Datei
@ -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
330
backend/src/routes/bookings.ts
Normale Datei
@ -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
|
||||
561
backend/src/routes/employees.ts
Normale Datei
561
backend/src/routes/employees.ts
Normale Datei
@ -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
|
||||
813
backend/src/routes/employeesSecure.ts
Normale Datei
813
backend/src/routes/employeesSecure.ts
Normale Datei
@ -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
338
backend/src/routes/network.ts
Normale Datei
@ -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
700
backend/src/routes/profiles.ts
Normale Datei
@ -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
|
||||
87
backend/src/routes/settings.ts
Normale Datei
87
backend/src/routes/settings.ts
Normale Datei
@ -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
508
backend/src/routes/skills.ts
Normale Datei
@ -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
123
backend/src/routes/sync.ts
Normale Datei
@ -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
151
backend/src/routes/upload.ts
Normale Datei
@ -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
311
backend/src/routes/users.ts
Normale Datei
@ -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
|
||||
510
backend/src/routes/usersAdmin.ts
Normale Datei
510
backend/src/routes/usersAdmin.ts
Normale Datei
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
299
backend/src/routes/workspaces.ts
Normale Datei
299
backend/src/routes/workspaces.ts
Normale Datei
@ -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
|
||||
138
backend/src/services/emailService.ts
Normale Datei
138
backend/src/services/emailService.ts
Normale Datei
@ -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()
|
||||
110
backend/src/services/encryption.ts
Normale Datei
110
backend/src/services/encryption.ts
Normale Datei
@ -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'
|
||||
]
|
||||
138
backend/src/services/reminderService.ts
Normale Datei
138
backend/src/services/reminderService.ts
Normale Datei
@ -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()
|
||||
129
backend/src/services/syncScheduler.ts
Normale Datei
129
backend/src/services/syncScheduler.ts
Normale Datei
@ -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()
|
||||
573
backend/src/services/syncService.ts
Normale Datei
573
backend/src/services/syncService.ts
Normale Datei
@ -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
33
backend/src/utils/logger.ts
Normale Datei
@ -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
19
backend/tsconfig.json
Normale Datei
@ -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
60
debug-console.cmd
Normale Datei
@ -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
|
||||
91
frontend/electron-builder.json
Normale Datei
91
frontend/electron-builder.json
Normale Datei
@ -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
210
frontend/electron/main.js
Normale Datei
@ -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
In neuem Issue referenzieren
Einen Benutzer sperren