commit 6b9b6d4f20f300c036fa76269b06422705ace369 Author: Claude Project Manager Date: Sat Sep 20 21:31:04 2025 +0200 Initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ea57242 --- /dev/null +++ b/.claude/settings.local.json @@ -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": [] + } +} \ No newline at end of file diff --git a/.cleanup-backup/PROJEKT-STATUS.txt b/.cleanup-backup/PROJEKT-STATUS.txt new file mode 100644 index 0000000..b7ca7c5 --- /dev/null +++ b/.cleanup-backup/PROJEKT-STATUS.txt @@ -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 +================================================================================ \ No newline at end of file diff --git a/.cleanup-backup/README-REFACTORED.md b/.cleanup-backup/README-REFACTORED.md new file mode 100644 index 0000000..c1edb9c --- /dev/null +++ b/.cleanup-backup/README-REFACTORED.md @@ -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! \ No newline at end of file diff --git a/.cleanup-backup/debug-start.py b/.cleanup-backup/debug-start.py new file mode 100644 index 0000000..579955b --- /dev/null +++ b/.cleanup-backup/debug-start.py @@ -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() \ No newline at end of file diff --git a/.cleanup-backup/fix-admin-password.js b/.cleanup-backup/fix-admin-password.js new file mode 100644 index 0000000..547bb93 --- /dev/null +++ b/.cleanup-backup/fix-admin-password.js @@ -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(); \ No newline at end of file diff --git a/.cleanup-backup/fix-encryption.js b/.cleanup-backup/fix-encryption.js new file mode 100644 index 0000000..ceab309 --- /dev/null +++ b/.cleanup-backup/fix-encryption.js @@ -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(); +} \ No newline at end of file diff --git a/.cleanup-backup/fix-sqlite.cmd b/.cleanup-backup/fix-sqlite.cmd new file mode 100644 index 0000000..e57563f --- /dev/null +++ b/.cleanup-backup/fix-sqlite.cmd @@ -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 \ No newline at end of file diff --git a/.cleanup-backup/fix-user-table.js b/.cleanup-backup/fix-user-table.js new file mode 100644 index 0000000..6635341 --- /dev/null +++ b/.cleanup-backup/fix-user-table.js @@ -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(); \ No newline at end of file diff --git a/.cleanup-backup/main-windows.py b/.cleanup-backup/main-windows.py new file mode 100644 index 0000000..4c7da8b --- /dev/null +++ b/.cleanup-backup/main-windows.py @@ -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() \ No newline at end of file diff --git a/.cleanup-backup/reset-admin.js b/.cleanup-backup/reset-admin.js new file mode 100644 index 0000000..7d993f5 --- /dev/null +++ b/.cleanup-backup/reset-admin.js @@ -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(); \ No newline at end of file diff --git a/.cleanup-backup/test-auth.js b/.cleanup-backup/test-auth.js new file mode 100644 index 0000000..fa24d50 --- /dev/null +++ b/.cleanup-backup/test-auth.js @@ -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(); \ No newline at end of file diff --git a/.cleanup-backup/test-backend.cmd b/.cleanup-backup/test-backend.cmd new file mode 100644 index 0000000..b9d8fb6 --- /dev/null +++ b/.cleanup-backup/test-backend.cmd @@ -0,0 +1,10 @@ +@echo off +echo Testing Backend... + +cd backend + +echo. +echo Running npm run dev... +npm run dev + +pause \ No newline at end of file diff --git a/.cleanup-backup/test-create-employee.js b/.cleanup-backup/test-create-employee.js new file mode 100644 index 0000000..1b6d55c --- /dev/null +++ b/.cleanup-backup/test-create-employee.js @@ -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(); \ No newline at end of file diff --git a/.cleanup-backup/test-employee-creation.js b/.cleanup-backup/test-employee-creation.js new file mode 100644 index 0000000..eb75c8c --- /dev/null +++ b/.cleanup-backup/test-employee-creation.js @@ -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(); \ No newline at end of file diff --git a/.cleanup-backup/test-login-direct.js b/.cleanup-backup/test-login-direct.js new file mode 100644 index 0000000..5168150 --- /dev/null +++ b/.cleanup-backup/test-login-direct.js @@ -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\\"}"'); +}); \ No newline at end of file diff --git a/.cleanup-backup/test-minimal-employee.js b/.cleanup-backup/test-minimal-employee.js new file mode 100644 index 0000000..01303e9 --- /dev/null +++ b/.cleanup-backup/test-minimal-employee.js @@ -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(); \ No newline at end of file diff --git a/.cleanup-backup/test-user-admin.js b/.cleanup-backup/test-user-admin.js new file mode 100644 index 0000000..d5156a2 --- /dev/null +++ b/.cleanup-backup/test-user-admin.js @@ -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(); \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..636f9fe --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/ANWENDUNGSBESCHREIBUNG.txt b/ANWENDUNGSBESCHREIBUNG.txt new file mode 100644 index 0000000..5cce393 --- /dev/null +++ b/ANWENDUNGSBESCHREIBUNG.txt @@ -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 ` 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. + diff --git a/CLAUDE_PROJECT_README.md b/CLAUDE_PROJECT_README.md new file mode 100644 index 0000000..cc06f2e --- /dev/null +++ b/CLAUDE_PROJECT_README.md @@ -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 diff --git a/EXE-ERSTELLEN.md b/EXE-ERSTELLEN.md new file mode 100644 index 0000000..5bb7854 --- /dev/null +++ b/EXE-ERSTELLEN.md @@ -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! \ No newline at end of file diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000..e9f0202 --- /dev/null +++ b/INSTALLATION.md @@ -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. \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e1815ef --- /dev/null +++ b/LICENSE.txt @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7cc319 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/VOLLSTAENDIGE_DOKUMENTATION.txt b/VOLLSTAENDIGE_DOKUMENTATION.txt new file mode 100644 index 0000000..730f560 --- /dev/null +++ b/VOLLSTAENDIGE_DOKUMENTATION.txt @@ -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) +------------------------------- + + + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + +LAYOUT-KOMPONENTE +----------------- +
+ + - Logo + - NavigationItems[] + - Dashboard + - Mitarbeiter + - Suche + - Analytics + - Einstellungen + - UserInfo + + +
+
+ - Breadcrumbs + - SearchBar + - NotificationBell + - UserMenu + - ThemeToggle +
+ +
+ {/* Routed Content */} +
+
+
+ +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) => void + checkAuth: () => Promise +} + +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 +================================================================================ \ No newline at end of file diff --git a/admin-panel/index.html b/admin-panel/index.html new file mode 100644 index 0000000..64c2508 --- /dev/null +++ b/admin-panel/index.html @@ -0,0 +1,13 @@ + + + + + + + SkillMate Admin Panel + + +
+ + + \ No newline at end of file diff --git a/admin-panel/package-lock.json b/admin-panel/package-lock.json new file mode 100644 index 0000000..81547c8 --- /dev/null +++ b/admin-panel/package-lock.json @@ -0,0 +1,3456 @@ +{ + "name": "@skillmate/admin-panel", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@skillmate/admin-panel", + "version": "1.0.0", + "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" + } + }, + "../shared": { + "name": "@skillmate/shared", + "version": "1.0.0", + "license": "UNLICENSED" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz", + "integrity": "sha512-2o/FgACbji4tW1dzXOqAV15Eu7DdgbKsF2QKcxfG4xbh5iwU7yr5RRP5/U+0asQliSYv5M4o7BevlGIoSL0LXg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.0.tgz", + "integrity": "sha512-PSZ0SvMOjEAxwZeTx32eI/j5xSYtDCRxGu5k9zvzoY77xUNssZM+WV6HYBLROpY5CkXsbQjvz40fBb7WPwDqtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.0.tgz", + "integrity": "sha512-BA4yPIPssPB2aRAWzmqzQ3y2/KotkLyZukVB7j3psK/U3nVJdceo6qr9pLM2xN6iRP/wKfxEbOb1yrlZH6sYZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.0.tgz", + "integrity": "sha512-Pr2o0lvTwsiG4HCr43Zy9xXrHspyMvsvEw4FwKYqhli4FuLE5FjcZzuQ4cfPe0iUFCvSQG6lACI0xj74FDZKRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.0.tgz", + "integrity": "sha512-lYE8LkE5h4a/+6VnnLiL14zWMPnx6wNbDG23GcYFpRW1V9hYWHAw9lBZ6ZUIrOaoK7NliF1sdwYGiVmziUF4vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.0.tgz", + "integrity": "sha512-PVQWZK9sbzpvqC9Q0GlehNNSVHR+4m7+wET+7FgSnKG3ci5nAMgGmr9mGBXzAuE5SvguCKJ6mHL6vq1JaJ/gvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.0.tgz", + "integrity": "sha512-hLrmRl53prCcD+YXTfNvXd776HTxNh8wPAMllusQ+amcQmtgo3V5i/nkhPN6FakW+QVLoUUr2AsbtIRPFU3xIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.0.tgz", + "integrity": "sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.0.tgz", + "integrity": "sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.0.tgz", + "integrity": "sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.0.tgz", + "integrity": "sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.0.tgz", + "integrity": "sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.0.tgz", + "integrity": "sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.0.tgz", + "integrity": "sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.0.tgz", + "integrity": "sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.0.tgz", + "integrity": "sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.0.tgz", + "integrity": "sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.0.tgz", + "integrity": "sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.0.tgz", + "integrity": "sha512-+CXwwG66g0/FpWOnP/v1HnrGVSOygK/osUbu3wPRy8ECXjoYKjRAyfxYpDQOfghC5qPJYLPH0oN4MCOjwgdMug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.0.tgz", + "integrity": "sha512-SRf1cytG7wqcHVLrBc9VtPK4pU5wxiB/lNIkNmW2ApKXIg+RpqwHfsaEK+e7eH4A1BpI6BX/aBWXxZCIrJg3uA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@skillmate/shared": { + "resolved": "../shared", + "link": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.183", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.183.tgz", + "integrity": "sha512-vCrDBYjQCAEefWGjlK3EpoSKfKbT10pR4XXPdn65q7snuNOZnthoVpBfZPykmDapOKfoD+MMIPG8ZjKyyc9oHA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hook-form": { + "version": "7.60.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.60.0.tgz", + "integrity": "sha512-SBrYOvMbDB7cV8ZfNpaiLcgjH/a1c7aK0lK+aNigpf4xWLO8q+o4tcvVurv3c4EOyzn/3dCsYt4GKD42VvJ/+A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.0.tgz", + "integrity": "sha512-WLjEcJRIo7i3WDDgOIJqVI2d+lAC3EwvOGy+Xfq6hs+GQuAA4Di/H72xmXkOhrIWFg2PFYSKZYfH0f4vfKXN4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.45.0", + "@rollup/rollup-android-arm64": "4.45.0", + "@rollup/rollup-darwin-arm64": "4.45.0", + "@rollup/rollup-darwin-x64": "4.45.0", + "@rollup/rollup-freebsd-arm64": "4.45.0", + "@rollup/rollup-freebsd-x64": "4.45.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.0", + "@rollup/rollup-linux-arm-musleabihf": "4.45.0", + "@rollup/rollup-linux-arm64-gnu": "4.45.0", + "@rollup/rollup-linux-arm64-musl": "4.45.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.0", + "@rollup/rollup-linux-riscv64-gnu": "4.45.0", + "@rollup/rollup-linux-riscv64-musl": "4.45.0", + "@rollup/rollup-linux-s390x-gnu": "4.45.0", + "@rollup/rollup-linux-x64-gnu": "4.45.0", + "@rollup/rollup-linux-x64-musl": "4.45.0", + "@rollup/rollup-win32-arm64-msvc": "4.45.0", + "@rollup/rollup-win32-ia32-msvc": "4.45.0", + "@rollup/rollup-win32-x64-msvc": "4.45.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/admin-panel/package.json b/admin-panel/package.json new file mode 100644 index 0000000..115fb9a --- /dev/null +++ b/admin-panel/package.json @@ -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" + } +} diff --git a/admin-panel/postcss.config.js b/admin-panel/postcss.config.js new file mode 100644 index 0000000..96bb01e --- /dev/null +++ b/admin-panel/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/admin-panel/src/App.tsx b/admin-panel/src/App.tsx new file mode 100644 index 0000000..1c0e83a --- /dev/null +++ b/admin-panel/src/App.tsx @@ -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 ( + + + } /> + } /> + + + ) + } + + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +export default App \ No newline at end of file diff --git a/admin-panel/src/components/HomeIcon.tsx b/admin-panel/src/components/HomeIcon.tsx new file mode 100644 index 0000000..b025668 --- /dev/null +++ b/admin-panel/src/components/HomeIcon.tsx @@ -0,0 +1,7 @@ +export default function HomeIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/admin-panel/src/components/Layout.tsx b/admin-panel/src/components/Layout.tsx new file mode 100644 index 0000000..ec21538 --- /dev/null +++ b/admin-panel/src/components/Layout.tsx @@ -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 ( +
+
+
+

+ SkillMate Admin +

+
+ + + +
+
+
+

{user?.username}

+

{user?.role}

+
+
+ +
+
+ +
+
+

+ Admin Panel +

+
+ +
+ {children} +
+
+
+ ) +} diff --git a/admin-panel/src/components/MoonIcon.tsx b/admin-panel/src/components/MoonIcon.tsx new file mode 100644 index 0000000..580a26a --- /dev/null +++ b/admin-panel/src/components/MoonIcon.tsx @@ -0,0 +1,7 @@ +export default function MoonIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/admin-panel/src/components/SearchIcon.tsx b/admin-panel/src/components/SearchIcon.tsx new file mode 100644 index 0000000..df5884e --- /dev/null +++ b/admin-panel/src/components/SearchIcon.tsx @@ -0,0 +1,7 @@ +export default function SearchIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/admin-panel/src/components/SettingsIcon.tsx b/admin-panel/src/components/SettingsIcon.tsx new file mode 100644 index 0000000..af347df --- /dev/null +++ b/admin-panel/src/components/SettingsIcon.tsx @@ -0,0 +1,8 @@ +export default function SettingsIcon({ className }: { className?: string }) { + return ( + + + + + ) +} \ No newline at end of file diff --git a/admin-panel/src/components/SunIcon.tsx b/admin-panel/src/components/SunIcon.tsx new file mode 100644 index 0000000..ce6de54 --- /dev/null +++ b/admin-panel/src/components/SunIcon.tsx @@ -0,0 +1,7 @@ +export default function SunIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/admin-panel/src/components/SyncStatus.tsx b/admin-panel/src/components/SyncStatus.tsx new file mode 100644 index 0000000..151d51c --- /dev/null +++ b/admin-panel/src/components/SyncStatus.tsx @@ -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(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 ( +
+
+
+
+
+
+ ) + } + + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + if (!status) return null + + return ( +
+
+

Sync-Status

+ +
+ +
+
+
Ausstehend
+
{status.pendingItems}
+
+ +
+
Konflikte
+
{status.pendingConflicts}
+
+ +
+
Status
+
+ {status.isSyncing ? ( + + + Läuft... + + ) : ( + + + Bereit + + )} +
+
+
+ + {status.recentSync && status.recentSync.length > 0 && ( +
+

Letzte Sync-Vorgänge

+
+ {status.recentSync.slice(0, 3).map((sync, index) => ( +
+ + {sync.sync_type} - {sync.sync_action} + + + {sync.status === 'completed' ? 'Erfolgreich' : + sync.status === 'failed' ? 'Fehlgeschlagen' : + sync.status === 'conflict' ? 'Konflikt' : + 'Ausstehend'} + +
+ ))} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/admin-panel/src/components/UsersIcon.tsx b/admin-panel/src/components/UsersIcon.tsx new file mode 100644 index 0000000..98cc12a --- /dev/null +++ b/admin-panel/src/components/UsersIcon.tsx @@ -0,0 +1,7 @@ +export default function UsersIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/admin-panel/src/components/icons.tsx b/admin-panel/src/components/icons.tsx new file mode 100644 index 0000000..a0c7d24 --- /dev/null +++ b/admin-panel/src/components/icons.tsx @@ -0,0 +1,110 @@ +export const DashboardIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const UsersIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const SkillsIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const UserManagementIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const SyncIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const LogoutIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const HomeIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const SettingsIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + + +) + +interface IconProps { + className?: string +} + +export function PencilIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function TrashIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function ShieldIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function KeyIcon({ className }: IconProps) { + return ( + + + + ) +} + +export function UserIcon({ className }: IconProps) { + return ( + + + + ) +} + +export const MailIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const ToggleLeftIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +export const ToggleRightIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) \ No newline at end of file diff --git a/admin-panel/src/components/index.ts b/admin-panel/src/components/index.ts new file mode 100644 index 0000000..01c695f --- /dev/null +++ b/admin-panel/src/components/index.ts @@ -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' \ No newline at end of file diff --git a/admin-panel/src/index.css b/admin-panel/src/index.css new file mode 100644 index 0000000..2089437 --- /dev/null +++ b/admin-panel/src/index.css @@ -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; + } +} \ No newline at end of file diff --git a/admin-panel/src/main.tsx b/admin-panel/src/main.tsx new file mode 100644 index 0000000..94785c9 --- /dev/null +++ b/admin-panel/src/main.tsx @@ -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( + + + , +) \ No newline at end of file diff --git a/admin-panel/src/services/api.ts b/admin-panel/src/services/api.ts new file mode 100644 index 0000000..36aac79 --- /dev/null +++ b/admin-panel/src/services/api.ts @@ -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 \ No newline at end of file diff --git a/admin-panel/src/services/networkApi.ts b/admin-panel/src/services/networkApi.ts new file mode 100644 index 0000000..48a3891 --- /dev/null +++ b/admin-panel/src/services/networkApi.ts @@ -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 => { + const response = await api.get('/network/nodes') + return response.data.data + }, + + createNode: async (node: Partial): Promise<{ id: string; apiKey: string }> => { + const response = await api.post('/network/nodes', node) + return response.data.data + }, + + updateNode: async (id: string, updates: Partial): Promise => { + await api.put(`/network/nodes/${id}`, updates) + }, + + deleteNode: async (id: string): Promise => { + 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 => { + const response = await api.get('/network/sync-settings') + return response.data.data + }, + + updateSyncSettings: async (settings: Partial): Promise => { + 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 + } +} \ No newline at end of file diff --git a/admin-panel/src/stores/authStore.ts b/admin-panel/src/stores/authStore.ts new file mode 100644 index 0000000..750b452 --- /dev/null +++ b/admin-panel/src/stores/authStore.ts @@ -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()( + 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', + } + ) +) \ No newline at end of file diff --git a/admin-panel/src/stores/themeStore.ts b/admin-panel/src/stores/themeStore.ts new file mode 100644 index 0000000..edc57a9 --- /dev/null +++ b/admin-panel/src/stores/themeStore.ts @@ -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()( + persist( + (set) => ({ + isDarkMode: false, + toggleTheme: () => set((state) => ({ isDarkMode: !state.isDarkMode })), + setTheme: (isDark) => set({ isDarkMode: isDark }), + }), + { + name: 'theme-storage', + } + ) +) \ No newline at end of file diff --git a/admin-panel/src/styles/index.css b/admin-panel/src/styles/index.css new file mode 100644 index 0000000..4726df1 --- /dev/null +++ b/admin-panel/src/styles/index.css @@ -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; + } +} diff --git a/admin-panel/src/views/CreateEmployee.tsx b/admin-panel/src/views/CreateEmployee.tsx new file mode 100644 index 0000000..ddfa4e4 --- /dev/null +++ b/admin-panel/src/views/CreateEmployee.tsx @@ -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({ + 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 ( +
+
+ +

+ Neuen Mitarbeiter & Benutzer anlegen +

+

+ Erstellen Sie einen neuen Mitarbeiter-Datensatz und optional ein Benutzerkonto +

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} + {createdUser.password && ( +
+

🔑 Temporäres Passwort:

+ + {createdUser.password} + +

+ ⚠️ Bitte notieren Sie dieses Passwort und geben Sie es sicher an den Mitarbeiter weiter. + Das Passwort muss beim ersten Login geändert werden. +

+
+ +
+
+ )} +
+ )} + +
+

+ Mitarbeiter-Grunddaten +

+ +
+
+ + + {errors.firstName && ( +

{errors.firstName.message}

+ )} +
+ +
+ + + {errors.lastName && ( +

{errors.lastName.message}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.department && ( +

{errors.department.message}

+ )} +
+
+
+ +
+

+ Benutzerkonto +

+ +
+
+ +

+ Erstellt ein Benutzerkonto, mit dem sich der Mitarbeiter im System anmelden kann. + Ein sicheres temporäres Passwort wird automatisch generiert. +

+
+ + {watchCreateUser && ( +
+ + +

+ {getRoleDescription(watch('userRole'))} +

+
+ )} +
+
+ +
+

+ 📋 Was passiert als Nächstes? +

+
    +
  • • Der Mitarbeiter wird mit Grunddaten angelegt (Position: "Mitarbeiter", Telefon: "Nicht angegeben")
  • +
  • • {watchCreateUser ? 'Ein Benutzerkonto wird erstellt und ein temporäres Passwort generiert' : 'Kein Benutzerkonto wird erstellt'}
  • +
  • • Der Mitarbeiter kann später im Frontend seine Profildaten vervollständigen
  • +
  • • Alle Daten werden verschlüsselt in der Datenbank gespeichert
  • +
+
+ +
+ + +
+
+
+ ) +} diff --git a/admin-panel/src/views/Dashboard.tsx b/admin-panel/src/views/Dashboard.tsx new file mode 100644 index 0000000..6f9e81b --- /dev/null +++ b/admin-panel/src/views/Dashboard.tsx @@ -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({ + 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 ( +
+

Daten werden geladen...

+
+ ) + } + + 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 ( +
+

+ Dashboard +

+ +
+ {statsCards.map((stat, index) => ( +
+
+ + {stat.value} + +
+

+ {stat.title} +

+
+ ))} +
+ +
+
+

+ Letzte Synchronisation +

+ {stats.lastSync ? ( +
+

+ Zeitpunkt:{' '} + {new Date(stats.lastSync.timestamp).toLocaleString('de-DE')} +

+

+ Status:{' '} + + {stats.lastSync.success ? 'Erfolgreich' : 'Fehlgeschlagen'} + +

+

+ Synchronisierte Elemente:{' '} + {stats.lastSync.itemsSynced} +

+
+ ) : ( +

+ Noch keine Synchronisation durchgeführt +

+ )} +
+ +
+

+ Systemstatus +

+
+

+ Backend:{' '} + Online +

+

+ Datenbank:{' '} + Verbunden +

+

+ Version: 1.0.0 +

+
+
+
+ + +
+ ) +} diff --git a/admin-panel/src/views/EmailSettings.tsx b/admin-panel/src/views/EmailSettings.tsx new file mode 100644 index 0000000..3b8a5f0 --- /dev/null +++ b/admin-panel/src/views/EmailSettings.tsx @@ -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({}) + 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 ( +
+
Lade Einstellungen...
+
+ ) + } + + return ( +
+
+

+ E-Mail-Einstellungen +

+

+ Konfigurieren Sie die automatischen E-Mail-Benachrichtigungen des Systems +

+
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+
+ +

+ Benachrichtigungseinstellungen +

+
+ +
+ {Object.entries(settings).map(([key, setting]) => ( +
+
+

+ {key === 'email_notifications_enabled' ? 'Passwort-E-Mails versenden' : key} +

+

+ {setting.description || 'Automatische E-Mail-Benachrichtigungen für neue Benutzerpasswörter'} +

+
+ +
+ +
+
+ ))} +
+ +
+

+ 💡 Hinweise zur E-Mail-Konfiguration +

+
    +
  • • E-Mail-Versand erfordert die Konfiguration von SMTP-Einstellungen in den Umgebungsvariablen
  • +
  • • Umgebungsvariablen: EMAIL_HOST, EMAIL_PORT, EMAIL_USER, EMAIL_PASS, EMAIL_FROM
  • +
  • • Die Standardeinstellung ist "Aus" aus Datenschutzgründen
  • +
  • • Passwörter werden nur einmal per E-Mail versendet und können nicht erneut abgerufen werden
  • +
+
+
+
+ ) +} \ No newline at end of file diff --git a/admin-panel/src/views/EmployeeForm.tsx b/admin-panel/src/views/EmployeeForm.tsx new file mode 100644 index 0000000..54066f0 --- /dev/null +++ b/admin-panel/src/views/EmployeeForm.tsx @@ -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() + + 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 ( +
+
+ +

+ {isEdit ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+

+ Mitarbeiterdaten +

+ +
+
+ + + {errors.firstName && ( +

{errors.firstName.message}

+ )} +
+ +
+ + + {errors.lastName && ( +

{errors.lastName.message}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ + + {errors.department && ( +

{errors.department.message}

+ )} +
+
+
+ +
+

+ Benutzerkonto erstellen (optional) +

+ +
+
+ +

+ Wenn aktiviert, wird automatisch ein Benutzerkonto mit der E-Mail-Adresse erstellt +

+
+ +
+ +
+ {(['admin', 'superuser', 'user'] as const).map((role) => ( + + ))} +
+
+
+
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/admin-panel/src/views/EmployeeFormComplete.tsx b/admin-panel/src/views/EmployeeFormComplete.tsx new file mode 100644 index 0000000..e415613 --- /dev/null +++ b/admin-panel/src/views/EmployeeFormComplete.tsx @@ -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({ + 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 ( +
+
+ +

+ {isEdit ? 'Mitarbeiter bearbeiten' : 'Neuer Mitarbeiter'} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} + {createdUser.password && ( +
+ Bitte notieren Sie das temporäre Passwort: +
+ {createdUser.password} +
+ )} +
+ )} + +
+

+ Persönliche Daten +

+ +
+
+ + + {errors.firstName && ( +

{errors.firstName.message}

+ )} +
+ +
+ + + {errors.lastName && ( +

{errors.lastName.message}

+ )} +
+ +
+ + + {errors.employeeNumber && ( +

{errors.employeeNumber.message}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+
+
+ +
+

+ Kontaktdaten & Position +

+ +
+
+ + + {errors.phone && ( +

{errors.phone.message}

+ )} +
+ +
+ + +
+ +
+ + + {errors.position && ( +

{errors.position.message}

+ )} +
+ +
+ + + {errors.department && ( +

{errors.department.message}

+ )} +
+ +
+ + +
+ +
+ + + {errors.availability && ( +

{errors.availability.message}

+ )} +
+
+
+ +
+

+ Benutzerkonto erstellen (optional) +

+ +
+
+ +

+ Ein temporäres Passwort wird generiert und muss beim ersten Login geändert werden. +

+
+ + {watchCreateUser && ( +
+ + +

+ {getRoleDescription(watch('userRole'))} +

+
+ )} +
+
+ +
+ + +
+
+
+ ) +} \ No newline at end of file diff --git a/admin-panel/src/views/EmployeeManagement.tsx b/admin-panel/src/views/EmployeeManagement.tsx new file mode 100644 index 0000000..5281b8d --- /dev/null +++ b/admin-panel/src/views/EmployeeManagement.tsx @@ -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([]) + 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 ( +
+

Mitarbeiter werden geladen...

+
+ ) + } + + return ( +
+
+

+ Mitarbeiterverwaltung +

+ +
+ +
+ setSearchTerm(e.target.value)} + className="input-field w-full" + /> +
+ +
+
+ + + + + + + + + + + + + {filteredEmployees.map((employee) => ( + + + + + + + + + ))} + +
Personalnr.NamePositionAbteilungStatusAktionen
{employee.employeeNumber} + {employee.firstName} {employee.lastName} + {employee.position}{employee.department} + + {employee.availability} + + + + +
+
+ + {filteredEmployees.length === 0 && ( +
+

Keine Mitarbeiter gefunden

+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/admin-panel/src/views/Login.tsx b/admin-panel/src/views/Login.tsx new file mode 100644 index 0000000..1860326 --- /dev/null +++ b/admin-panel/src/views/Login.tsx @@ -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() + + 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 ( +
+
+
+
+

+ SkillMate Admin +

+

+ Melden Sie sich an, um fortzufahren +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + + {errors.username && ( +

{errors.username.message}

+ )} +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+ + +
+ +
+

+ Standard-Login: admin / admin123 +

+
+
+
+
+ ) +} diff --git a/admin-panel/src/views/SkillManagement.tsx b/admin-panel/src/views/SkillManagement.tsx new file mode 100644 index 0000000..c937a89 --- /dev/null +++ b/admin-panel/src/views/SkillManagement.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + const [openMain, setOpenMain] = useState>({}) + const [openSub, setOpenSub] = useState>({}) + + // Inline create/edit states + const [addingCat, setAddingCat] = useState(false) + const [newCat, setNewCat] = useState({ name: '' }) + + const [addingSub, setAddingSub] = useState>({}) + const [newSub, setNewSub] = useState>({}) + + const [addingSkill, setAddingSkill] = useState>({}) // key: catId.subId + const [newSkill, setNewSkill] = useState>({}) + + const [editingCat, setEditingCat] = useState(null) + const [editCatName, setEditCatName] = useState('') + + const [editingSub, setEditingSub] = useState(null) // key: catId.subId + const [editSubName, setEditSubName] = useState('') + + const [editingSkill, setEditingSkill] = useState(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 ( +
+
+
+

Skill-Verwaltung

+

Kategorien und Unterkategorien wie im Frontend (ohne Niveaus)

+
+
+ {!addingCat ? ( + + ) : ( +
+ setNewCat({ ...newCat, name: e.target.value })} /> + + +
+ )} +
+
+ + + + {error &&
{error}
} + + {loading ? ( +
Lade Skills...
+ ) : ( + hierarchy.map((cat) => ( +
+
+
+ + {editingCat === cat.id ? ( +
+ setEditCatName(e.target.value)} /> + + +
+ ) : ( +

{cat.name}

+ )} +
+
+ + + +
+
+ + {addingSub[cat.id] && ( +
+ setNewSub(prev => ({ ...prev, [cat.id]: { ...(prev[cat.id] || { name: '' }), name: e.target.value } }))} /> + + +
+ )} + + {openMain[cat.id] && ( +
+ {cat.subcategories.map((sub) => ( +
+
+
+ + {editingSub === keyFor(cat.id, sub.id) ? ( +
+ setEditSubName(e.target.value)} /> + + +
+ ) : ( +
{sub.name}
+ )} +
+
+ + + +
+
+ + {addingSkill[keyFor(cat.id, sub.id)] && ( +
+ setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), name: e.target.value } }))} /> + setNewSkill(prev => ({ ...prev, [keyFor(cat.id, sub.id)]: { ...(prev[keyFor(cat.id, sub.id)] || { name: '' }), description: e.target.value } }))} /> + + +
+ )} + + {openSub[keyFor(cat.id, sub.id)] && ( +
+ {sub.skills.map((sk) => ( +
+ {editingSkill === sk.id ? ( +
+ setEditSkillData(prev => ({ ...prev, name: e.target.value }))} /> + setEditSkillData(prev => ({ ...prev, description: e.target.value }))} /> +
+ ) : ( +
+
{sk.name}
+ {sk.description &&
{sk.description}
} +
+ )} +
+ {editingSkill === sk.id ? ( + <> + + + + ) : ( + <> + + + + + )} +
+
+ ))} + {sub.skills.length === 0 && ( +
Keine Skills in dieser Unterkategorie
+ )} +
+ )} +
+ ))} + {cat.subcategories.length === 0 && ( +
Keine Unterkategorien
+ )} +
+ )} +
+ )) + )} +
+ ) +} diff --git a/admin-panel/src/views/SyncSettings.tsx b/admin-panel/src/views/SyncSettings.tsx new file mode 100644 index 0000000..297132a --- /dev/null +++ b/admin-panel/src/views/SyncSettings.tsx @@ -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([]) + const [editingNode, setEditingNode] = useState(null) + const [newNode, setNewNode] = useState>({ + 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({ + 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) => { + 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 ( +
+
+

+ Netzwerk & Synchronisation +

+
+ + +
+
+ + {/* Network Overview */} +
+
+
+ + {nodes.length} +
+

Gesamte Knoten

+

Im Netzwerk

+
+ +
+
+ + + {nodes.filter(n => n.isOnline).length} + +
+

Online

+

Aktive Verbindungen

+
+ +
+
+ + + {nodes[0]?.lastSync ? new Date(nodes[0].lastSync).toLocaleString('de-DE') : 'Nie'} + +
+

Letzte Sync

+

Admin-Knoten

+
+
+ + {/* New Node Form */} + {showNewNodeForm && ( +
+

+ Neuen Knoten hinzufügen +

+
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + +
+
+ +
+ + +
+
+ )} + + {/* Nodes List */} +
+
+

+ Netzwerkknoten +

+
+
+ + + + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + + + ))} + +
+ Status + + Name + + Standort + + IP-Adresse + + Typ + + Letzte Sync + + Aktionen +
+
+
+ {editingNode === node.id ? ( + handleUpdateNode(node.id, { name: e.target.value })} + className="px-2 py-1 border border-gray-300 rounded" + /> + ) : ( + {node.name} + )} + + {node.location} + + {node.ipAddress}:{node.port} + + + {node.type === 'admin' ? 'Admin' : 'Lokal'} + + + {node.lastSync ? new Date(node.lastSync).toLocaleString('de-DE') : 'Nie'} + +
+ {editingNode === node.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+ + {/* Sync Configuration */} +
+

+ Synchronisationseinstellungen +

+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ + + + +
+
+ +
+ +
+ 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" + /> + KB/s +
+
+
+ +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/admin-panel/src/views/UserManagement.tsx b/admin-panel/src/views/UserManagement.tsx new file mode 100644 index 0000000..122bdcb --- /dev/null +++ b/admin-panel/src/views/UserManagement.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [editingUser, setEditingUser] = useState(null) + const [editRole, setEditRole] = useState('user') + const [resetPasswordUser, setResetPasswordUser] = useState(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([]) + + // Import state + type ImportRow = { firstName: string; lastName: string; email: string; department: string } + const [dragActive, setDragActive] = useState(false) + const [parsedRows, setParsedRows] = useState([]) + 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>({}) + + // 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) => { + e.preventDefault(); + setDragActive(true) + } + const onDragLeave = () => setDragActive(false) + const onDrop = (e: DragEvent) => { + 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 ( +
+
Lade Benutzer...
+
+ ) + } + + return ( +
+
+
+

+ Benutzerverwaltung +

+

+ Verwalten Sie Benutzerkonten, Rollen und Zugriffsrechte +

+
+ +
+ + {currentUser?.role === 'admin' && ( +
+

Administrative Aktionen

+ +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+
+ + + + + + + + + + + + + {users.map((user) => ( + + + + + + + + + ))} + +
+ Benutzer + + Mitarbeiter + + Rolle + + Status + + Letzter Login + + Aktionen +
+
+
+ {user.username === 'admin' ? 'A' : user.username.charAt(0).toUpperCase()} +
+ + {user.username === 'admin' ? 'admin' : user.username} + +
+
+ {user.employeeName ? ( + {user.employeeName} + ) : ( + Nicht verknüpft + )} + + {editingUser === user.id ? ( +
+ + + +
+ ) : ( + + {getRoleLabel(user.role)} + + )} +
+ + + {user.lastLogin ? new Date(user.lastLogin).toLocaleString('de-DE') : 'Nie'} + +
+ + + {resetPasswordUser === user.id ? ( +
+ setNewPassword(e.target.value)} + className="input-field py-1 text-sm w-40" + /> + + +
+ ) : ( + + )} + + +
+ {tempPasswords[user.id] && ( +
+
+
+ Temporäres Passwort: {tempPasswords[user.id].password} +
+
+ + +
+
+
+ )} +
+ + {users.length === 0 && ( +
+ Keine Benutzer gefunden +
+ )} +
+
+ +
+

+ Hinweise zur Benutzerverwaltung +

+
    +
  • Administrator: Vollzugriff auf alle Funktionen und Einstellungen
  • +
  • Poweruser: Kann Mitarbeiter und Skills verwalten, aber keine Systemeinstellungen ändern
  • +
  • Benutzer: Kann nur eigenes Profil bearbeiten und Daten einsehen
  • +
  • • Neue Benutzer können über den Import oder die Mitarbeiterverwaltung angelegt werden
  • +
  • • Der Admin-Benutzer kann nicht gelöscht werden
  • +
+
+ +
+

+ Import neue Nutzer (CSV oder JSON) +

+
+

Datei hierher ziehen oder auswählen

+ e.target.files && e.target.files[0] && handleFile(e.target.files[0])} + className="hidden" + id="user-import-input" + /> + + {parseError &&
{parseError}
} +
+ +
+

Eingabekonventionen:

+
    +
  • CSV mit Kopfzeile: firstName;lastName;email;department (Komma oder Semikolon)
  • +
  • JSON: Array von Objekten mit Schlüsseln firstName, lastName, email, department
  • +
  • E-Mail muss valide sein; Rolle wird initial immer „user“
  • +
  • Es wird stets ein temporäres Passwort erzeugt; Anzeige nach Import
  • +
+
+ + {parsedRows.length > 0 && ( +
+

Vorschau ({parsedRows.length} Einträge)

+
+ + + + + + + + + + + {parsedRows.map((r, idx) => ( + + + + + + + ))} + +
VornameNachnameE-MailAbteilung
{r.firstName}{r.lastName}{r.email}{r.department}
+
+
+ +
+
+ )} + + {importResults.length > 0 && ( +
+

Import-Ergebnis

+
+ + + + + + + + + + + + {importResults.map((r, idx) => ( + + + + + + + + ))} + +
ZeileE-MailStatusTemporäres PasswortAktionen
{r.index + 1}{r.email || '—'}{r.status === 'created' ? 'Erstellt' : `Fehler: ${r.error}`} + {r.temporaryPassword ? ( + {r.temporaryPassword} + ) : ( + '—' + )} + + {r.userId && r.temporaryPassword && ( +
+ + +
+ )} +
+
+
+ )} +
+
+ ) +} + +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 ( +
+

Nur Administratoren: Löscht alle Benutzer und behält nur 'admin' und die angegebene E‑Mail.

+
+ setEmail(e.target.value)} + /> + +
+ {msg &&
{msg}
} +
+ ) +} diff --git a/admin-panel/tailwind.config.js b/admin-panel/tailwind.config.js new file mode 100644 index 0000000..8828f72 --- /dev/null +++ b/admin-panel/tailwind.config.js @@ -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: [], +} \ No newline at end of file diff --git a/admin-panel/tsconfig.json b/admin-panel/tsconfig.json new file mode 100644 index 0000000..b52b47c --- /dev/null +++ b/admin-panel/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/admin-panel/tsconfig.node.json b/admin-panel/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/admin-panel/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/admin-panel/vite.config.ts b/admin-panel/vite.config.ts new file mode 100644 index 0000000..2439ec4 --- /dev/null +++ b/admin-panel/vite.config.ts @@ -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, + }, +}) \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..c67108b --- /dev/null +++ b/backend/.gitignore @@ -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 \ No newline at end of file diff --git a/backend/create-test-user.js b/backend/create-test-user.js new file mode 100644 index 0000000..3a255e7 --- /dev/null +++ b/backend/create-test-user.js @@ -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(); \ No newline at end of file diff --git a/backend/full-backend-3005.js b/backend/full-backend-3005.js new file mode 100644 index 0000000..d2b1b28 --- /dev/null +++ b/backend/full-backend-3005.js @@ -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); +}); \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..5325484 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,5154 @@ +{ + "name": "@skillmate/backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@skillmate/backend", + "version": "1.0.0", + "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" + } + }, + "../shared": { + "name": "@skillmate/shared", + "version": "1.0.0", + "license": "UNLICENSED" + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-sesv2": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.879.0.tgz", + "integrity": "sha512-OQIAq8+Ii7Q/whIGDd3q9XPNzvRfNCROc3Cv/a9Pk8pWOdDq4cq8PC+uybc9OPbYlbE5opuFDN9+GGoMRTUGoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.879.0", + "@aws-sdk/credential-provider-node": "3.879.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.879.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/signature-v4-multi-region": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.879.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.0", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.19", + "@smithy/middleware-retry": "^4.1.20", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.27", + "@smithy/util-defaults-mode-node": "^4.0.27", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.879.0.tgz", + "integrity": "sha512-+Pc3OYFpRYpKLKRreovPM63FPPud1/SF9vemwIJfz6KwsBCJdvg7vYD1xLSIp5DVZLeetgf4reCyAA5ImBfZuw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.879.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.879.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.879.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.0", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.19", + "@smithy/middleware-retry": "^4.1.20", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.27", + "@smithy/util-defaults-mode-node": "^4.0.27", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.879.0.tgz", + "integrity": "sha512-AhNmLCrx980LsK+SfPXGh7YqTyZxsK0Qmy18mWmkfY0TSq7WLaSDB5zdQbgbnQCACCHy8DUYXbi4KsjlIhv3PA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@aws-sdk/xml-builder": "3.873.0", + "@smithy/core": "^3.9.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/signature-v4": "^5.1.3", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.879.0.tgz", + "integrity": "sha512-JgG7A8SSbr5IiCYL8kk39Y9chdSB5GPwBorDW8V8mr19G9L+qd6ohED4fAocoNFaDnYJ5wGAHhCfSJjzcsPBVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.879.0.tgz", + "integrity": "sha512-2hM5ByLpyK+qORUexjtYyDZsgxVCCUiJQZRMGkNXFEGz6zTpbjfTIWoh3zRgWHEBiqyPIyfEy50eIF69WshcuA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/property-provider": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "@smithy/util-stream": "^4.2.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.879.0.tgz", + "integrity": "sha512-07M8zfb73KmMBqVO5/V3Ea9kqDspMX0fO0kaI1bsjWI6ngnMye8jCE0/sIhmkVAI0aU709VA0g+Bzlopnw9EoQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/credential-provider-env": "3.879.0", + "@aws-sdk/credential-provider-http": "3.879.0", + "@aws-sdk/credential-provider-process": "3.879.0", + "@aws-sdk/credential-provider-sso": "3.879.0", + "@aws-sdk/credential-provider-web-identity": "3.879.0", + "@aws-sdk/nested-clients": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.879.0.tgz", + "integrity": "sha512-FYaAqJbnSTrVL2iZkNDj2hj5087yMv2RN2GA8DJhe7iOJjzhzRojrtlfpWeJg6IhK0sBKDH+YXbdeexCzUJvtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.879.0", + "@aws-sdk/credential-provider-http": "3.879.0", + "@aws-sdk/credential-provider-ini": "3.879.0", + "@aws-sdk/credential-provider-process": "3.879.0", + "@aws-sdk/credential-provider-sso": "3.879.0", + "@aws-sdk/credential-provider-web-identity": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.879.0.tgz", + "integrity": "sha512-7r360x1VyEt35Sm1JFOzww2WpnfJNBbvvnzoyLt7WRfK0S/AfsuWhu5ltJ80QvJ0R3AiSNbG+q/btG2IHhDYPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.879.0.tgz", + "integrity": "sha512-gd27B0NsgtKlaPNARj4IX7F7US5NuU691rGm0EUSkDsM7TctvJULighKoHzPxDQlrDbVI11PW4WtKS/Zg5zPlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.879.0", + "@aws-sdk/core": "3.879.0", + "@aws-sdk/token-providers": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.879.0.tgz", + "integrity": "sha512-Jy4uPFfGzHk1Mxy+/Wr43vuw9yXsE2yiF4e4598vc3aJfO0YtA2nSfbKD3PNKRORwXbeKqWPfph9SCKQpWoxEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/nested-clients": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", + "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.876.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", + "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", + "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.879.0.tgz", + "integrity": "sha512-ZTpLr2AbZcCsEzu18YCtB8Tp8tjAWHT0ccfwy3HiL6g9ncuSMW+7BVi1hDYmBidFwpPbnnIMtM0db3pDMR6/WA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-arn-parser": "3.873.0", + "@smithy/core": "^3.9.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/protocol-http": "^5.1.3", + "@smithy/signature-v4": "^5.1.3", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-stream": "^4.2.4", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.879.0.tgz", + "integrity": "sha512-DDSV8228lQxeMAFKnigkd0fHzzn5aauZMYC3CSj6e5/qE7+9OwpkUcjHfb7HZ9KWG6L2/70aKZXHqiJ4xKhOZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@smithy/core": "^3.9.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.879.0.tgz", + "integrity": "sha512-7+n9NpIz9QtKYnxmw1fHi9C8o0GrX8LbBR4D50c7bH6Iq5+XdSuL5AFOWWQ5cMD0JhqYYJhK/fJsVau3nUtC4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.879.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.879.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.879.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.0", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.19", + "@smithy/middleware-retry": "^4.1.20", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.27", + "@smithy/util-defaults-mode-node": "^4.0.27", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.873.0.tgz", + "integrity": "sha512-q9sPoef+BBG6PJnc4x60vK/bfVwvRWsPgcoQyIra057S/QGjq5VkjvNk6H8xedf6vnKlXNBwq9BaANBXnldUJg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.879.0.tgz", + "integrity": "sha512-MDsw0EWOHyKac75X3gD8tLWtmPuRliS/s4IhWRhsdDCU13wewHIs5IlA5B65kT6ISf49yEIalEH3FHUSVqdmIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/signature-v4": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.879.0.tgz", + "integrity": "sha512-47J7sCwXdnw9plRZNAGVkNEOlSiLb/kR2slnDIHRK9NB/ECKsoqgz5OZQJ9E2f0yqOs8zSNJjn3T01KxpgW8Qw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.879.0", + "@aws-sdk/nested-clients": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.862.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.862.0.tgz", + "integrity": "sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.873.0.tgz", + "integrity": "sha512-qag+VTqnJWDn8zTAXX4wiVioa0hZDQMtbZcGRERVnLar4/3/VIKBhxX2XibNQXFu1ufgcRn4YntT/XEPecFWcg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.879.0.tgz", + "integrity": "sha512-aVAJwGecYoEmbEFju3127TyJDF9qJsKDUUTRMDuS8tGn+QiWQFnfInmbt+el9GU1gEJupNTXV+E3e74y51fb7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-endpoints": "^3.0.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.873.0.tgz", + "integrity": "sha512-xcVhZF6svjM5Rj89T1WzkjQmrTF6dpR2UvIHPMTnSZoNe6CixejPZ6f0JJ2kAhO8H+dUHwNBlsUgOTIKiK/Syg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.873.0.tgz", + "integrity": "sha512-AcRdbK6o19yehEcywI43blIBhOCSo6UgyWcuOJX5CFF8k39xm1ILCjQlRRjchLAxWrm0lU0Q7XV90RiMMFMZtA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.879.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.879.0.tgz", + "integrity": "sha512-A5KGc1S+CJRzYnuxJQQmH1BtGsz46AgyHkqReKfGiNQA8ET/9y9LQ5t2ABqnSBHHIh3+MiCcQSkUZ0S3rTodrQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.879.0", + "@aws-sdk/types": "3.862.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.873.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.873.0.tgz", + "integrity": "sha512-kLO7k7cGJ6KaHiExSJWojZurF7SnGMDHXRuQunFnEoD0n1yB6Lqy/S/zHiQ7oJnBhPr9q0TW9qFkrsZb1Uc54w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@skillmate/shared": { + "resolved": "../shared", + "link": true + }, + "node_modules/@smithy/abort-controller": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.5.tgz", + "integrity": "sha512-jcrqdTQurIrBbUm4W2YdLVMQDoL0sA9DTxYd2s+R/y+2U9NLOP7Xf/YqfSg1FZhlZIYEnvk2mwbyvIfdLEPo8g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.5.tgz", + "integrity": "sha512-viuHMxBAqydkB0AfWwHIdwf/PRH2z5KHGUzqyRtS/Wv+n3IHI993Sk76VCA7dD/+GzgGOmlJDITfPcJC1nIVIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.9.0.tgz", + "integrity": "sha512-B/GknvCfS3llXd/b++hcrwIuqnEozQDnRL4sBmOac5/z/dr0/yG1PURNPOyU4Lsiy1IyTj8scPxVqRs5dYWf6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.0.9", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-stream": "^4.2.4", + "@smithy/util-utf8": "^4.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.7.tgz", + "integrity": "sha512-dDzrMXA8d8riFNiPvytxn0mNwR4B3h8lgrQ5UjAGu6T9z/kRg/Xncf4tEQHE/+t25sY8IH3CowcmWi+1U5B1Gw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.1.tgz", + "integrity": "sha512-61WjM0PWmZJR+SnmzaKI7t7G0UkkNFboDpzIdzSoy7TByUzlxo18Qlh9s71qug4AY4hlH/CwXdubMtkcNEb/sQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.3", + "@smithy/querystring-builder": "^4.0.5", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.5.tgz", + "integrity": "sha512-cv1HHkKhpyRb6ahD8Vcfb2Hgz67vNIXEp2vnhzfxLFGRukLCNEA5QdsorbUEzXma1Rco0u3rx5VTqbM06GcZqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.5.tgz", + "integrity": "sha512-IVnb78Qtf7EJpoEVo7qJ8BEXQwgC4n3igeJNNKEj/MLYtapnx8A67Zt/J3RXAj2xSO1910zk0LdFiygSemuLow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.5.tgz", + "integrity": "sha512-l1jlNZoYzoCC7p0zCtBDE5OBXZ95yMKlRlftooE5jPWQn4YBPLgsp+oeHp7iMHaTGoUdFqmHOPa8c9G3gBsRpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.1.19", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.19.tgz", + "integrity": "sha512-EAlEPncqo03siNZJ9Tm6adKCQ+sw5fNU8ncxWwaH0zTCwMPsgmERTi6CEKaermZdgJb+4Yvh0NFm36HeO4PGgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.9.0", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-middleware": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.1.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.20.tgz", + "integrity": "sha512-T3maNEm3Masae99eFdx1Q7PIqBBEVOvRd5hralqKZNeIivnoGNx5OFtI3DiZ5gCjUkl0mNondlzSXeVxkinh7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.4", + "@smithy/protocol-http": "^5.1.3", + "@smithy/service-error-classification": "^4.0.7", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.9.tgz", + "integrity": "sha512-uAFFR4dpeoJPGz8x9mhxp+RPjo5wW0QEEIPPPbLXiRRWeCATf/Km3gKIVR5vaP8bN1kgsPhcEeh+IZvUlBv6Xg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.5.tgz", + "integrity": "sha512-/yoHDXZPh3ocRVyeWQFvC44u8seu3eYzZRveCMfgMOBcNKnAmOvjbL9+Cp5XKSIi9iYA9PECUuW2teDAk8T+OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.4.tgz", + "integrity": "sha512-+UDQV/k42jLEPPHSn39l0Bmc4sB1xtdI9Gd47fzo/0PbXzJ7ylgaOByVjF5EeQIumkepnrJyfx86dPa9p47Y+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.1.tgz", + "integrity": "sha512-RHnlHqFpoVdjSPPiYy/t40Zovf3BBHc2oemgD7VsVTFFZrU5erFFe0n52OANZZ/5sbshgD93sOh5r6I35Xmpaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/querystring-builder": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.5.tgz", + "integrity": "sha512-R/bswf59T/n9ZgfgUICAZoWYKBHcsVDurAGX88zsiUtOTA/xUAPyiT+qkNCPwFn43pZqN84M4MiUsbSGQmgFIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.3.tgz", + "integrity": "sha512-fCJd2ZR7D22XhDY0l+92pUag/7je2BztPRQ01gU5bMChcyI0rlly7QFibnYHzcxDvccMjlpM/Q1ev8ceRIb48w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.5.tgz", + "integrity": "sha512-NJeSCU57piZ56c+/wY+AbAw6rxCCAOZLCIniRE7wqvndqxcKKDOXzwWjrY7wGKEISfhL9gBbAaWWgHsUGedk+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.5.tgz", + "integrity": "sha512-6SV7md2CzNG/WUeTjVe6Dj8noH32r4MnUeFKZrnVYsQxpGSIcphAanQMayi8jJLZAWm6pdM9ZXvKCpWOsIGg0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.7.tgz", + "integrity": "sha512-XvRHOipqpwNhEjDf2L5gJowZEm5nsxC16pAZOeEcsygdjv9A2jdOh3YoDQvOXBGTsaJk6mNWtzWalOB9976Wlg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.5.tgz", + "integrity": "sha512-YVVwehRDuehgoXdEL4r1tAAzdaDgaC9EQvhK0lEbfnbrd0bd5+CTQumbdPryX3J2shT7ZqQE+jPW4lmNBAB8JQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.3.tgz", + "integrity": "sha512-mARDSXSEgllNzMw6N+mC+r1AQlEBO3meEAkR/UlfAgnMzJUB3goRBWgip1EAMG99wh36MDqzo86SfIX5Y+VEaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.5.0.tgz", + "integrity": "sha512-ZSdE3vl0MuVbEwJBxSftm0J5nL/gw76xp5WF13zW9cN18MFuFXD5/LV0QD8P+sCU5bSWGyy6CTgUupE1HhOo1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.9.0", + "@smithy/middleware-endpoint": "^4.1.19", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-stream": "^4.2.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.2.tgz", + "integrity": "sha512-QO4zghLxiQ5W9UZmX2Lo0nta2PuE1sSrXUYDoaB6HMR762C0P7v/HEPHf6ZdglTVssJG1bsrSBxdc3quvDSihw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.5.tgz", + "integrity": "sha512-j+733Um7f1/DXjYhCbvNXABV53NyCRRA54C7bNEIxNPs0YjfRxeMKjjgm2jvTYrciZyCjsicHwQ6Q0ylo+NAUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.27.tgz", + "integrity": "sha512-i/Fu6AFT5014VJNgWxKomBJP/GB5uuOsM4iHdcmplLm8B1eAqnRItw4lT2qpdO+mf+6TFmf6dGcggGLAVMZJsQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.5", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.27.tgz", + "integrity": "sha512-3W0qClMyxl/ELqTA39aNw1N+pN0IjpXT7lPFvZ8zTxqVFP7XCpACB9QufmN4FQtd39xbgS7/Lekn7LmDa63I5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.1.5", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/smithy-client": "^4.5.0", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.7.tgz", + "integrity": "sha512-klGBP+RpBp6V5JbrY2C/VKnHXn3d5V2YrifZbmMY8os7M6m8wdYFoO6w/fe5VkP+YVwrEktW3IWYaSQVNZJ8oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.5.tgz", + "integrity": "sha512-N40PfqsZHRSsByGB81HhSo+uvMxEHT+9e255S53pfBw/wI6WKDI7Jw9oyu5tJTLwZzV5DsMha3ji8jk9dsHmQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.7.tgz", + "integrity": "sha512-TTO6rt0ppK70alZpkjwy+3nQlTiqNfoXja+qwuAchIEAIoSZW8Qyd76dvBv3I5bCpE38APafG23Y/u270NspiQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.0.7", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.4.tgz", + "integrity": "sha512-vSKnvNZX2BXzl0U2RgCLOwWaAP9x/ddd/XobPK02pCbzRm5s55M53uwb1rl/Ts7RXZvdJZerPkA+en2FDghLuQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "24.0.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz", + "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.1.tgz", + "integrity": "sha512-UfHAghPmGZVzaL8x9y+mKZMWyHC399+iq0MOmya5tIyenWX3lcdSb60vOmp0DocR6gCDTYTozv/ULQnREyyjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/client-sesv2": "^3.839.0", + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/better-sqlite3-multiple-ciphers": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-12.2.0.tgz", + "integrity": "sha512-RWNb++urDg03mRKoHTLWOgZijiQmDnwdvm2SavauOOniJUBPBh1HMisJ9NxDF0Fqf0ml5iGunXhWu9fM7ALSyA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "optional": true + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT", + "optional": true + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nodemailer": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", + "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.6.tgz", + "integrity": "sha512-pe4Y2yzru68lXCb38aAqRf5gvN8YdjP1lok5o0J7BOHljkyCGKVz7H3vpVIXKD27rj2giOJ7DwVyk/GWrPHDWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..cce5385 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/scripts/migrate-users.js b/backend/scripts/migrate-users.js new file mode 100644 index 0000000..51a1480 --- /dev/null +++ b/backend/scripts/migrate-users.js @@ -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() + diff --git a/backend/scripts/purge-users.js b/backend/scripts/purge-users.js new file mode 100644 index 0000000..5ece0e0 --- /dev/null +++ b/backend/scripts/purge-users.js @@ -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() + diff --git a/backend/scripts/reset-admin.js b/backend/scripts/reset-admin.js new file mode 100644 index 0000000..5741ad8 --- /dev/null +++ b/backend/scripts/reset-admin.js @@ -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() + diff --git a/backend/scripts/seed-skills-from-frontend.js b/backend/scripts/seed-skills-from-frontend.js new file mode 100644 index 0000000..6abf276 --- /dev/null +++ b/backend/scripts/seed-skills-from-frontend.js @@ -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() + diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts new file mode 100644 index 0000000..80a5b7c --- /dev/null +++ b/backend/src/config/database.ts @@ -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); + `) +} \ No newline at end of file diff --git a/backend/src/config/secureDatabase.ts b/backend/src/config/secureDatabase.ts new file mode 100644 index 0000000..6f63b31 --- /dev/null +++ b/backend/src/config/secureDatabase.ts @@ -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) diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e3f4c2e --- /dev/null +++ b/backend/src/index.ts @@ -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 diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..e9d753f --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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() + } +} \ No newline at end of file diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..94a1cc7 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -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 + }) + } + }) +} \ No newline at end of file diff --git a/backend/src/middleware/roleAuth.ts b/backend/src/middleware/roleAuth.ts new file mode 100644 index 0000000..ba292af --- /dev/null +++ b/backend/src/middleware/roleAuth.ts @@ -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() + } +} \ No newline at end of file diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts new file mode 100644 index 0000000..2fb18f6 --- /dev/null +++ b/backend/src/routes/analytics.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..1d7931e --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/bookings.ts b/backend/src/routes/bookings.ts new file mode 100644 index 0000000..1375fc1 --- /dev/null +++ b/backend/src/routes/bookings.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/employees.ts b/backend/src/routes/employees.ts new file mode 100644 index 0000000..905b6f2 --- /dev/null +++ b/backend/src/routes/employees.ts @@ -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 = { + '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 \ No newline at end of file diff --git a/backend/src/routes/employeesSecure.ts b/backend/src/routes/employeesSecure.ts new file mode 100644 index 0000000..f311bbe --- /dev/null +++ b/backend/src/routes/employeesSecure.ts @@ -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 = { + '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 diff --git a/backend/src/routes/network.ts b/backend/src/routes/network.ts new file mode 100644 index 0000000..82de058 --- /dev/null +++ b/backend/src/routes/network.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts new file mode 100644 index 0000000..886b12a --- /dev/null +++ b/backend/src/routes/profiles.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts new file mode 100644 index 0000000..c386cb6 --- /dev/null +++ b/backend/src/routes/settings.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/skills.ts b/backend/src/routes/skills.ts new file mode 100644 index 0000000..aa9da1e --- /dev/null +++ b/backend/src/routes/skills.ts @@ -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 = {} + 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 = {} + 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 }>() + 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 diff --git a/backend/src/routes/sync.ts b/backend/src/routes/sync.ts new file mode 100644 index 0000000..706d72d --- /dev/null +++ b/backend/src/routes/sync.ts @@ -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 \ No newline at end of file diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts new file mode 100644 index 0000000..4c76faf --- /dev/null +++ b/backend/src/routes/upload.ts @@ -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 diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..4f17d2c --- /dev/null +++ b/backend/src/routes/users.ts @@ -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 diff --git a/backend/src/routes/usersAdmin.ts b/backend/src/routes/usersAdmin.ts new file mode 100644 index 0000000..fddaaa2 --- /dev/null +++ b/backend/src/routes/usersAdmin.ts @@ -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) + } + } +) diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts new file mode 100644 index 0000000..33467dd --- /dev/null +++ b/backend/src/routes/workspaces.ts @@ -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 \ No newline at end of file diff --git a/backend/src/services/emailService.ts b/backend/src/services/emailService.ts new file mode 100644 index 0000000..fdf6507 --- /dev/null +++ b/backend/src/services/emailService.ts @@ -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 { + 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 { + 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 = ` +
+

SkillMate - Ihr Zugangs-Passwort

+ +

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.

+ +

+ + Jetzt einloggen + +

+ +
+ +

+ Mit freundlichen Grüßen,
+ Ihr SkillMate-Team +

+
` + + return this.sendEmail({ + to: email, + subject, + text, + html + }) + } + + isServiceEnabled(): boolean { + return this.isEnabled + } +} + +export const emailService = new EmailService() \ No newline at end of file diff --git a/backend/src/services/encryption.ts b/backend/src/services/encryption.ts new file mode 100644 index 0000000..c7419db --- /dev/null +++ b/backend/src/services/encryption.ts @@ -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>( + 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>( + 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' +] \ No newline at end of file diff --git a/backend/src/services/reminderService.ts b/backend/src/services/reminderService.ts new file mode 100644 index 0000000..ee255d4 --- /dev/null +++ b/backend/src/services/reminderService.ts @@ -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() \ No newline at end of file diff --git a/backend/src/services/syncScheduler.ts b/backend/src/services/syncScheduler.ts new file mode 100644 index 0000000..0dc7569 --- /dev/null +++ b/backend/src/services/syncScheduler.ts @@ -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 = { + '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() \ No newline at end of file diff --git a/backend/src/services/syncService.ts b/backend/src/services/syncService.ts new file mode 100644 index 0000000..034e5e8 --- /dev/null +++ b/backend/src/services/syncService.ts @@ -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 { + 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 { + 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 { + 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() \ No newline at end of file diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..af5f8bf --- /dev/null +++ b/backend/src/utils/logger.ts @@ -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() + ) + })) +} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..90c7f24 --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/debug-console.cmd b/debug-console.cmd new file mode 100644 index 0000000..e4bc526 --- /dev/null +++ b/debug-console.cmd @@ -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 \ No newline at end of file diff --git a/frontend/electron-builder.json b/frontend/electron-builder.json new file mode 100644 index 0000000..95c8de3 --- /dev/null +++ b/frontend/electron-builder.json @@ -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 +} \ No newline at end of file diff --git a/frontend/electron/main.js b/frontend/electron/main.js new file mode 100644 index 0000000..e05fda4 --- /dev/null +++ b/frontend/electron/main.js @@ -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) +}) \ No newline at end of file diff --git a/frontend/electron/preload.js b/frontend/electron/preload.js new file mode 100644 index 0000000..37e9e72 --- /dev/null +++ b/frontend/electron/preload.js @@ -0,0 +1,21 @@ +const { contextBridge, ipcRenderer } = require('electron') + +contextBridge.exposeInMainWorld('electronAPI', { + minimize: () => ipcRenderer.invoke('app:minimize'), + maximize: () => ipcRenderer.invoke('app:maximize'), + close: () => ipcRenderer.invoke('app:close'), + getVersion: () => ipcRenderer.invoke('app:getVersion'), + getPath: (name) => ipcRenderer.invoke('app:getPath', name), + + onWindowMaximized: (callback) => { + ipcRenderer.on('window-maximized', callback) + }, + + onWindowUnmaximized: (callback) => { + ipcRenderer.on('window-unmaximized', callback) + }, + + removeAllListeners: (channel) => { + ipcRenderer.removeAllListeners(channel) + } +}) \ No newline at end of file diff --git a/frontend/electron/renderer-preload.js b/frontend/electron/renderer-preload.js new file mode 100644 index 0000000..ea29529 --- /dev/null +++ b/frontend/electron/renderer-preload.js @@ -0,0 +1,5 @@ +// Inject process object for renderer +window.process = { + env: {}, + platform: 'win32' +} \ No newline at end of file diff --git a/frontend/index-electron.html b/frontend/index-electron.html new file mode 100644 index 0000000..b91bf50 --- /dev/null +++ b/frontend/index-electron.html @@ -0,0 +1,20 @@ + + + + + + + SkillMate - Mitarbeiter-Skills-Management + + + +
+ + + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1624614 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + SkillMate - Mitarbeiter-Skills-Management + + +
+ + + \ No newline at end of file diff --git a/frontend/installer/skillmate-setup.iss b/frontend/installer/skillmate-setup.iss new file mode 100644 index 0000000..bdc45b1 --- /dev/null +++ b/frontend/installer/skillmate-setup.iss @@ -0,0 +1,79 @@ +; SkillMate Inno Setup Script +; Erstellt professionelle Windows-Installer + +#define MyAppName "SkillMate" +#define MyAppVersion "1.0.0" +#define MyAppPublisher "SkillMate Development" +#define MyAppURL "https://skillmate.local" +#define MyAppExeName "SkillMate.exe" + +[Setup] +AppId={{8B3F4D2A-1E5C-4A7B-9C3D-2F1A6E8B9D7C} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DisableProgramGroupPage=yes +LicenseFile=..\..\..\LICENSE.txt +OutputDir=..\..\dist +OutputBaseFilename=SkillMate-Setup-{#MyAppVersion} +SetupIconFile=..\build\icon.ico +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +PrivilegesRequired=admin +ArchitecturesAllowed=x64 +ArchitecturesInstallIn64BitMode=x64 +UninstallDisplayName={#MyAppName} +UninstallDisplayIcon={app}\{#MyAppExeName} +VersionInfoVersion={#MyAppVersion} +VersionInfoCompany={#MyAppPublisher} +VersionInfoDescription=Mitarbeiter-Skills-Management für Sicherheitsbehörden +VersionInfoCopyright=Copyright (C) 2024 {#MyAppPublisher} +VersionInfoProductName={#MyAppName} +VersionInfoProductVersion={#MyAppVersion} + +[Languages] +Name: "german"; MessagesFile: "compiler:Languages\German.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checked +Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; OnlyBelowVersion: 6.1; Flags: checked + +[Files] +Source: "..\dist\win-unpacked\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\..\backend\dist\*"; DestDir: "{app}\resources\backend"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\..\backend\node_modules\*"; DestDir: "{app}\resources\backend\node_modules"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[Code] +function InitializeSetup(): Boolean; +var + ResultCode: Integer; +begin + // Check for .NET Framework or Visual C++ Redistributables if needed + Result := True; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + // Create necessary directories + ForceDirectories(ExpandConstant('{userappdata}\SkillMate\logs')); + ForceDirectories(ExpandConstant('{userappdata}\SkillMate\data')); + end; +end; + +[UninstallDelete] +Type: filesandordirs; Name: "{userappdata}\SkillMate" \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b389209 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3406 @@ +{ + "name": "@skillmate/frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@skillmate/frontend", + "version": "1.0.0", + "dependencies": { + "@skillmate/shared": "file:../shared", + "axios": "^1.6.2", + "lucide-react": "^0.542.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "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" + } + }, + "../shared": { + "name": "@skillmate/shared", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.3.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.0.tgz", + "integrity": "sha512-2o/FgACbji4tW1dzXOqAV15Eu7DdgbKsF2QKcxfG4xbh5iwU7yr5RRP5/U+0asQliSYv5M4o7BevlGIoSL0LXg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.0.tgz", + "integrity": "sha512-PSZ0SvMOjEAxwZeTx32eI/j5xSYtDCRxGu5k9zvzoY77xUNssZM+WV6HYBLROpY5CkXsbQjvz40fBb7WPwDqtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.0.tgz", + "integrity": "sha512-BA4yPIPssPB2aRAWzmqzQ3y2/KotkLyZukVB7j3psK/U3nVJdceo6qr9pLM2xN6iRP/wKfxEbOb1yrlZH6sYZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.0.tgz", + "integrity": "sha512-Pr2o0lvTwsiG4HCr43Zy9xXrHspyMvsvEw4FwKYqhli4FuLE5FjcZzuQ4cfPe0iUFCvSQG6lACI0xj74FDZKRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.0.tgz", + "integrity": "sha512-lYE8LkE5h4a/+6VnnLiL14zWMPnx6wNbDG23GcYFpRW1V9hYWHAw9lBZ6ZUIrOaoK7NliF1sdwYGiVmziUF4vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.0.tgz", + "integrity": "sha512-PVQWZK9sbzpvqC9Q0GlehNNSVHR+4m7+wET+7FgSnKG3ci5nAMgGmr9mGBXzAuE5SvguCKJ6mHL6vq1JaJ/gvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.0.tgz", + "integrity": "sha512-hLrmRl53prCcD+YXTfNvXd776HTxNh8wPAMllusQ+amcQmtgo3V5i/nkhPN6FakW+QVLoUUr2AsbtIRPFU3xIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.0.tgz", + "integrity": "sha512-XBKGSYcrkdiRRjl+8XvrUR3AosXU0NvF7VuqMsm7s5nRy+nt58ZMB19Jdp1RdqewLcaYnpk8zeVs/4MlLZEJxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.0.tgz", + "integrity": "sha512-fRvZZPUiBz7NztBE/2QnCS5AtqLVhXmUOPj9IHlfGEXkapgImf4W9+FSkL8cWqoAjozyUzqFmSc4zh2ooaeF6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.0.tgz", + "integrity": "sha512-Btv2WRZOcUGi8XU80XwIvzTg4U6+l6D0V6sZTrZx214nrwxw5nAi8hysaXj/mctyClWgesyuxbeLylCBNauimg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.0.tgz", + "integrity": "sha512-Li0emNnwtUZdLwHjQPBxn4VWztcrw/h7mgLyHiEI5Z0MhpeFGlzaiBHpSNVOMB/xucjXTTcO+dhv469Djr16KA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.0.tgz", + "integrity": "sha512-sB8+pfkYx2kvpDCfd63d5ScYT0Fz1LO6jIb2zLZvmK9ob2D8DeVqrmBDE0iDK8KlBVmsTNzrjr3G1xV4eUZhSw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.0.tgz", + "integrity": "sha512-5GQ6PFhh7E6jQm70p1aW05G2cap5zMOvO0se5JMecHeAdj5ZhWEHbJ4hiKpfi1nnnEdTauDXxPgXae/mqjow9w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.0.tgz", + "integrity": "sha512-N/euLsBd1rekWcuduakTo/dJw6U6sBP3eUq+RXM9RNfPuWTvG2w/WObDkIvJ2KChy6oxZmOSC08Ak2OJA0UiAA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.0.tgz", + "integrity": "sha512-2l9sA7d7QdikL0xQwNMO3xURBUNEWyHVHfAsHsUdq+E/pgLTUcCE+gih5PCdmyHmfTDeXUWVhqL0WZzg0nua3g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.0.tgz", + "integrity": "sha512-7ayfgvtmmWgKWBkCGg5+xTQ0r5V1owVm67zTrsEY1008L5ro7mCyGYORomARt/OquB9KY7LpxVBZes+oSniAAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.0.tgz", + "integrity": "sha512-B+IJgcBnE2bm93jEW5kHisqvPITs4ddLOROAcOc/diBgrEiQJJ6Qcjby75rFSmH5eMGrqJryUgJDhrfj942apQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.0.tgz", + "integrity": "sha512-+CXwwG66g0/FpWOnP/v1HnrGVSOygK/osUbu3wPRy8ECXjoYKjRAyfxYpDQOfghC5qPJYLPH0oN4MCOjwgdMug==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.0.tgz", + "integrity": "sha512-SRf1cytG7wqcHVLrBc9VtPK4pU5wxiB/lNIkNmW2ApKXIg+RpqwHfsaEK+e7eH4A1BpI6BX/aBWXxZCIrJg3uA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@skillmate/shared": { + "resolved": "../shared", + "link": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "18.19.119", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.119.tgz", + "integrity": "sha512-d0F6m9itIPaKnrvEMlzE48UjwZaAnFW7Jwibacw9MNdqadjKNpUm9tfJYDwmShJmgqcoqYUX3EMKO1+RWiuuNg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.183", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.183.tgz", + "integrity": "sha512-vCrDBYjQCAEefWGjlK3EpoSKfKbT10pR4XXPdn65q7snuNOZnthoVpBfZPykmDapOKfoD+MMIPG8ZjKyyc9oHA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.542.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz", + "integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.0.tgz", + "integrity": "sha512-WLjEcJRIo7i3WDDgOIJqVI2d+lAC3EwvOGy+Xfq6hs+GQuAA4Di/H72xmXkOhrIWFg2PFYSKZYfH0f4vfKXN4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.45.0", + "@rollup/rollup-android-arm64": "4.45.0", + "@rollup/rollup-darwin-arm64": "4.45.0", + "@rollup/rollup-darwin-x64": "4.45.0", + "@rollup/rollup-freebsd-arm64": "4.45.0", + "@rollup/rollup-freebsd-x64": "4.45.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.0", + "@rollup/rollup-linux-arm-musleabihf": "4.45.0", + "@rollup/rollup-linux-arm64-gnu": "4.45.0", + "@rollup/rollup-linux-arm64-musl": "4.45.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.0", + "@rollup/rollup-linux-riscv64-gnu": "4.45.0", + "@rollup/rollup-linux-riscv64-musl": "4.45.0", + "@rollup/rollup-linux-s390x-gnu": "4.45.0", + "@rollup/rollup-linux-x64-gnu": "4.45.0", + "@rollup/rollup-linux-x64-musl": "4.45.0", + "@rollup/rollup-win32-arm64-msvc": "4.45.0", + "@rollup/rollup-win32-ia32-msvc": "4.45.0", + "@rollup/rollup-win32-x64-msvc": "4.45.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.45.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.0.tgz", + "integrity": "sha512-XZdD3fEEQcwG2KrJDdEQu7NrHonPxxaV0/w2HpvINBdcqebz1aL+0vM2WFJq4DeiAVT6F5SUQas65HY5JDqoPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..68df1cb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "@skillmate/frontend", + "version": "1.0.0", + "description": "SkillMate - Mitarbeiter-Skills-Management für Sicherheitsbehörden", + "author": "SkillMate Development", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@skillmate/shared": "file:../shared", + "axios": "^1.6.2", + "lucide-react": "^0.542.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..96bb01e --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/public/debug.html b/frontend/public/debug.html new file mode 100644 index 0000000..58fc0b5 --- /dev/null +++ b/frontend/public/debug.html @@ -0,0 +1,30 @@ + + + + + Debug Test + + +

Debug Test

+
+

Wenn Sie diesen Text sehen, funktioniert HTML.

+
+ + + \ No newline at end of file diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..b04a022 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..60a13b0 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,120 @@ +import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom' +import { useThemeStore } from './stores/themeStore' +import { useAuthStore } from './stores/authStore' +import { useEffect } from 'react' +import Layout from './components/Layout' +import Login from './views/Login' +import Dashboard from './views/Dashboard' +import EmployeeList from './views/EmployeeList' +import EmployeeDetail from './views/EmployeeDetail' +import EmployeeForm from './views/EmployeeForm' +import SkillSearch from './views/SkillSearch' +import TeamZusammenstellung from './views/TeamZusammenstellung' +// import ProfileSearch from './views/ProfileSearch' +// import ProfileEdit from './views/ProfileEdit' +import Settings from './views/Settings' +import MyProfile from './views/MyProfile' + +function App() { + const { isDarkMode } = useThemeStore() + const { isAuthenticated } = useAuthStore() + + useEffect(() => { + if (isDarkMode) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + }, [isDarkMode]) + + return ( + + + : + } /> + + + + ) : + } /> + + + + ) : + } /> + + + + ) : + } /> + + + + ) : + } /> + + + + ) : + } /> + + + + ) : + } /> + + + + ) : + } /> + {/* Temporär deaktiviert + + + + ) : + } /> + + + + ) : + } /> + + + + ) : + } /> + */} + + + + ) : + } /> + + + ) +} + +export default App diff --git a/frontend/src/components/EmployeeCard.tsx b/frontend/src/components/EmployeeCard.tsx new file mode 100644 index 0000000..eff457e --- /dev/null +++ b/frontend/src/components/EmployeeCard.tsx @@ -0,0 +1,90 @@ +import type { Employee } from '@skillmate/shared' + +interface EmployeeCardProps { + employee: Employee + onClick: () => void +} + +export default function EmployeeCard({ employee, onClick }: EmployeeCardProps) { + const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api' + const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '') + const photoSrc = employee.photo && employee.photo.startsWith('/uploads/') + ? `${PUBLIC_BASE}${employee.photo}` + : employee.photo || '' + const getAvailabilityBadge = (status: Employee['availability']) => { + const badges = { + available: { class: 'badge-success', text: 'Verfügbar' }, + parttime: { class: 'badge-info', text: 'Teilzeit' }, + unavailable: { class: 'badge-error', text: 'Nicht verfügbar' }, + busy: { class: 'badge-warning', text: 'Beschäftigt' }, + away: { class: 'badge-info', text: 'Abwesend' }, + vacation: { class: 'badge-info', text: 'Urlaub' }, + sick: { class: 'badge-error', text: 'Krank' }, + training: { class: 'badge-info', text: 'Fortbildung' }, + operation: { class: 'badge-warning', text: 'Im Einsatz' }, + } + return badges[status] || { class: 'badge', text: status } + } + + const availability = getAvailabilityBadge(employee.availability) + + return ( +
+
+
+
+ {photoSrc ? ( + {`${employee.firstName} + ) : ( + + {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} + + )} +
+
+

+ {employee.firstName} {employee.lastName} +

+ {employee.employeeNumber && ( +

{employee.employeeNumber}

+ )} +
+
+ + {availability.text} + +
+ +
+

+ Position: {employee.position} +

+

+ Dienststelle: {employee.department} +

+
+ + {employee.specializations.length > 0 && ( +
+

Spezialisierungen:

+
+ {employee.specializations.slice(0, 3).map((spec, index) => ( + + {spec} + + ))} + {employee.specializations.length > 3 && ( + + +{employee.specializations.length - 3} weitere + + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..30ebd84 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react' +import { useThemeStore } from '../stores/themeStore' +import { SunIcon, MoonIcon } from './icons' +import { useAuthStore } from '../stores/authStore' +import { authApi } from '../services/api' +import { usePermissions } from '../hooks/usePermissions' +import WindowControls from './WindowControls' + +export default function Header() { + const { isDarkMode, toggleTheme } = useThemeStore() + const { user, isAuthenticated, login, logout } = useAuthStore() + const { canAccessAdminPanel } = usePermissions() + const [showLogin, setShowLogin] = useState(false) + const [loginForm, setLoginForm] = useState({ username: '', password: '' }) + const [loginError, setLoginError] = useState('') + const [loginLoading, setLoginLoading] = useState(false) + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoginError('') + setLoginLoading(true) + + try { + const response = await authApi.login(loginForm.username, loginForm.password) + login(response.user, response.token) + localStorage.setItem('token', response.token) + setShowLogin(false) + setLoginForm({ username: '', password: '' }) + } catch (error: any) { + setLoginError(error.response?.data?.message || 'Login fehlgeschlagen') + } finally { + setLoginLoading(false) + } + } + + const handleLogout = () => { + logout() + localStorage.removeItem('token') + } + + return ( +
+
+

+ SkillMate +

+ + {/* Login/Logout Section */} +
+ {!isAuthenticated ? ( +
+ + + {/* Login Dropdown */} + {showLogin && ( +
+
+
+ setLoginForm(prev => ({ ...prev, username: e.target.value }))} + className="input-field w-full text-sm" + required + /> +
+
+ setLoginForm(prev => ({ ...prev, password: e.target.value }))} + className="input-field w-full text-sm" + required + /> +
+ {loginError && ( +

{loginError}

+ )} +
+ + +
+
+
+ )} +
+ ) : ( +
+ {user?.username} +
+ {user?.username.charAt(0).toUpperCase()} +
+ {canAccessAdminPanel() && ( + + Admin + + )} + +
+ )} +
+
+ +
+ {/* Theme Toggle Slider - moved to the right */} +
+ + + +
+
+ + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..8207786 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react' +import Sidebar from './Sidebar' +import Header from './Header' + +interface LayoutProps { + children: ReactNode +} + +export default function Layout({ children }: LayoutProps) { + return ( +
+ +
+
+
+ {children} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/PhotoPreview.tsx b/frontend/src/components/PhotoPreview.tsx new file mode 100644 index 0000000..8e99f16 --- /dev/null +++ b/frontend/src/components/PhotoPreview.tsx @@ -0,0 +1,144 @@ +import { useState, useRef } from 'react' + +interface PhotoPreviewProps { + currentPhoto?: string + onPhotoSelect: (file: File) => void + onPhotoRemove?: () => void + disabled?: boolean +} + +export default function PhotoPreview({ + currentPhoto, + onPhotoSelect, + onPhotoRemove, + disabled = false +}: PhotoPreviewProps) { + const [dragOver, setDragOver] = useState(false) + const [error, setError] = useState('') + const fileInputRef = useRef(null) + + const handleFileSelect = (file: File) => { + setError('') + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + setError('Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)') + return + } + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + setError('Datei zu groß (max. 5MB)') + return + } + + onPhotoSelect(file) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + if (!disabled) { + setDragOver(true) + } + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + + if (disabled) return + + const files = e.dataTransfer.files + if (files.length > 0) { + handleFileSelect(files[0]) + } + } + + const handleFileInputChange = (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + handleFileSelect(files[0]) + } + } + + const triggerFileInput = () => { + if (!disabled) { + fileInputRef.current?.click() + } + } + + return ( +
+
+ {currentPhoto ? ( + Employee photo + ) : ( +
+ + + +
+ )} + + +
+ +
+ + + {currentPhoto && onPhotoRemove && ( + + )} +
+ + {error && ( +

{error}

+ )} + +

+ Drag & Drop oder klicken zum Auswählen +
+ Max. 5MB, JPEG/PNG/GIF/WebP +

+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/PhotoUpload.tsx b/frontend/src/components/PhotoUpload.tsx new file mode 100644 index 0000000..73b91f4 --- /dev/null +++ b/frontend/src/components/PhotoUpload.tsx @@ -0,0 +1,227 @@ +import { useState, useRef } from 'react' +import { useAuthStore } from '../stores/authStore' + +interface PhotoUploadProps { + employeeId?: string + currentPhoto?: string + onPhotoUpdate?: (photoUrl: string | null) => void + disabled?: boolean +} + +export default function PhotoUpload({ + employeeId, + currentPhoto, + onPhotoUpdate, + disabled = false +}: PhotoUploadProps) { + const [uploading, setUploading] = useState(false) + const [error, setError] = useState('') + const [dragOver, setDragOver] = useState(false) + const fileInputRef = useRef(null) + const { token } = useAuthStore() + + const handleFileSelect = (file: File) => { + if (!employeeId) { + setError('Employee ID is required') + return + } + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'] + if (!allowedTypes.includes(file.type)) { + setError('Nur Bilddateien sind erlaubt (JPEG, PNG, GIF, WebP)') + return + } + + // Validate file size (5MB) + if (file.size > 5 * 1024 * 1024) { + setError('Datei zu groß (max. 5MB)') + return + } + + uploadPhoto(file) + } + + const uploadPhoto = async (file: File) => { + if (!token) { + setError('Not authenticated') + return + } + + setUploading(true) + setError('') + + try { + const formData = new FormData() + formData.append('photo', file) + + const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api' + const response = await fetch(`${API_BASE_URL}/upload/employee-photo/${employeeId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Upload failed') + } + + // Update photo URL + const base = (import.meta as any).env?.VITE_API_PUBLIC_URL || (API_BASE_URL.replace(/\/api$/, '')) + const photoUrl = `${base}${data.data.photoUrl}` + onPhotoUpdate?.(photoUrl) + + } catch (err: any) { + setError(err.message || 'Upload failed') + } finally { + setUploading(false) + } + } + + const deletePhoto = async () => { + if (!employeeId || !token) return + + setUploading(true) + setError('') + + try { + const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api' + const response = await fetch(`${API_BASE_URL}/upload/employee-photo/${employeeId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error?.message || 'Delete failed') + } + + onPhotoUpdate?.(null) + + } catch (err: any) { + setError(err.message || 'Delete failed') + } finally { + setUploading(false) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + + if (disabled || uploading) return + + const files = e.dataTransfer.files + if (files.length > 0) { + handleFileSelect(files[0]) + } + } + + const handleFileInputChange = (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + handleFileSelect(files[0]) + } + } + + const triggerFileInput = () => { + if (!disabled && !uploading) { + fileInputRef.current?.click() + } + } + + const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api' + const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '') + const displayPhoto = currentPhoto && currentPhoto.startsWith('/uploads/') ? `${PUBLIC_BASE}${currentPhoto}` : currentPhoto + + return ( +
+
+ {displayPhoto ? ( + Employee photo + ) : ( +
+ + + +
+ )} + + {uploading && ( +
+
+
+ )} + + +
+ +
+ + + {currentPhoto && ( + + )} +
+ + {error && ( +

{error}

+ )} + +

+ Drag & Drop oder klicken zum Auswählen +
+ Max. 5MB, JPEG/PNG/GIF/WebP +

+
+ ) +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..3004309 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,55 @@ +import { NavLink } from 'react-router-dom' +import { + HomeIcon, + UsersIcon, + SearchIcon, + SettingsIcon, + DeskIcon, + MapIcon, + ChartIcon +} from './icons' +import { useAuthStore } from '../stores/authStore' + +const navigation = [ + { name: 'Dashboard', href: '/', icon: HomeIcon }, + { name: 'Mein Profil', href: '/profile', icon: UsersIcon }, + { name: 'Mitarbeiter', href: '/employees', icon: UsersIcon }, + { name: 'Skill-Suche', href: '/search', icon: SearchIcon }, + { name: 'Team-Zusammenstellung', href: '/team', icon: UsersIcon }, + { name: 'Einstellungen', href: '/settings', icon: SettingsIcon }, +] + +export default function Sidebar() { + const { user } = useAuthStore() + + // Filter navigation items based on user role + const filteredNavigation = navigation.filter(item => { + if (!item.roles) return true + return item.roles.includes(user?.role || '') + }) + + return ( +
+
+

+ SkillMate +

+
+ + +
+ ) +} diff --git a/frontend/src/components/SkillLevelBar.tsx b/frontend/src/components/SkillLevelBar.tsx new file mode 100644 index 0000000..a347c37 --- /dev/null +++ b/frontend/src/components/SkillLevelBar.tsx @@ -0,0 +1,77 @@ +import React, { useCallback } from 'react' + +interface SkillLevelBarProps { + value: number | '' + onChange: (value: number) => void + min?: number + max?: number + disabled?: boolean + showHelp?: boolean +} + +function segmentColor(i: number) { + // Use darker, more contrast-friendly shades in dark mode + if (i <= 3) return 'bg-red-500 hover:bg-red-600 dark:bg-red-700 dark:hover:bg-red-600' + if (i <= 6) return 'bg-green-500 hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-600' + return 'bg-purple-500 hover:bg-purple-600 dark:bg-purple-700 dark:hover:bg-purple-600' +} + +export default function SkillLevelBar({ value, onChange, min = 1, max = 10, disabled = false, showHelp = true }: SkillLevelBarProps) { + const handleKey = useCallback((e: React.KeyboardEvent) => { + if (disabled) return + if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { + e.preventDefault() + const v = typeof value === 'number' ? value : min - 1 + onChange(Math.min(max, v + 1)) + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { + e.preventDefault() + const v = typeof value === 'number' ? value : min + onChange(Math.max(min, v - 1)) + } else if (e.key === 'Home') { + e.preventDefault(); onChange(min) + } else if (e.key === 'End') { + e.preventDefault(); onChange(max) + } + }, [value, onChange, min, max, disabled]) + + const current = typeof value === 'number' ? value : 0 + + return ( +
+
+ {Array.from({ length: max }, (_, idx) => idx + 1).map(i => { + const active = i <= current + return ( +
+ {showHelp && ( +
+ 1 Anfänger + 5 Fortgeschritten + 10 Experte +
+ )} +
+ ) +} diff --git a/frontend/src/components/WindowControls.tsx b/frontend/src/components/WindowControls.tsx new file mode 100644 index 0000000..be0d600 --- /dev/null +++ b/frontend/src/components/WindowControls.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect } from 'react' + +export default function WindowControls() { + const [isMaximized, setIsMaximized] = useState(false) + + useEffect(() => { + if (window.electronAPI) { + window.electronAPI.onWindowMaximized(() => setIsMaximized(true)) + window.electronAPI.onWindowUnmaximized(() => setIsMaximized(false)) + + return () => { + window.electronAPI?.removeAllListeners('window-maximized') + window.electronAPI?.removeAllListeners('window-unmaximized') + } + } + }, []) + + if (!window.electronAPI || process.platform !== 'win32') { + return null + } + + return ( +
+ + + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/ChartIcon.tsx b/frontend/src/components/icons/ChartIcon.tsx new file mode 100644 index 0000000..68c2068 --- /dev/null +++ b/frontend/src/components/icons/ChartIcon.tsx @@ -0,0 +1,18 @@ +export function ChartIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/DeskIcon.tsx b/frontend/src/components/icons/DeskIcon.tsx new file mode 100644 index 0000000..609c003 --- /dev/null +++ b/frontend/src/components/icons/DeskIcon.tsx @@ -0,0 +1,18 @@ +export function DeskIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/HomeIcon.tsx b/frontend/src/components/icons/HomeIcon.tsx new file mode 100644 index 0000000..b025668 --- /dev/null +++ b/frontend/src/components/icons/HomeIcon.tsx @@ -0,0 +1,7 @@ +export default function HomeIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/MapIcon.tsx b/frontend/src/components/icons/MapIcon.tsx new file mode 100644 index 0000000..a8c4c60 --- /dev/null +++ b/frontend/src/components/icons/MapIcon.tsx @@ -0,0 +1,18 @@ +export function MapIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/MoonIcon.tsx b/frontend/src/components/icons/MoonIcon.tsx new file mode 100644 index 0000000..580a26a --- /dev/null +++ b/frontend/src/components/icons/MoonIcon.tsx @@ -0,0 +1,7 @@ +export default function MoonIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/SearchIcon.tsx b/frontend/src/components/icons/SearchIcon.tsx new file mode 100644 index 0000000..df5884e --- /dev/null +++ b/frontend/src/components/icons/SearchIcon.tsx @@ -0,0 +1,7 @@ +export default function SearchIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/SettingsIcon.tsx b/frontend/src/components/icons/SettingsIcon.tsx new file mode 100644 index 0000000..af347df --- /dev/null +++ b/frontend/src/components/icons/SettingsIcon.tsx @@ -0,0 +1,8 @@ +export default function SettingsIcon({ className }: { className?: string }) { + return ( + + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/SunIcon.tsx b/frontend/src/components/icons/SunIcon.tsx new file mode 100644 index 0000000..ce6de54 --- /dev/null +++ b/frontend/src/components/icons/SunIcon.tsx @@ -0,0 +1,7 @@ +export default function SunIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/UsersIcon.tsx b/frontend/src/components/icons/UsersIcon.tsx new file mode 100644 index 0000000..98cc12a --- /dev/null +++ b/frontend/src/components/icons/UsersIcon.tsx @@ -0,0 +1,7 @@ +export default function UsersIcon({ className }: { className?: string }) { + return ( + + + + ) +} \ No newline at end of file diff --git a/frontend/src/components/icons/index.ts b/frontend/src/components/icons/index.ts new file mode 100644 index 0000000..8497567 --- /dev/null +++ b/frontend/src/components/icons/index.ts @@ -0,0 +1,9 @@ +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' +export { DeskIcon } from './DeskIcon' +export { MapIcon } from './MapIcon' +export { ChartIcon } from './ChartIcon' \ No newline at end of file diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts new file mode 100644 index 0000000..f99ccc0 --- /dev/null +++ b/frontend/src/hooks/usePermissions.ts @@ -0,0 +1,97 @@ +import { UserRole } from '@skillmate/shared' +import { useAuthStore } from '../stores/authStore' + +// Define role permissions directly to avoid import issues +const ROLE_PERMISSIONS: Record = { + admin: [ + 'admin:panel:access', + 'users:create', + 'users:read', + 'users:update', + 'users:delete', + 'users:manage_roles', + 'employees:create', + 'employees:read', + 'employees:update', + 'employees:delete', + 'profiles:create', + 'profiles:read', + 'profiles:update', + 'profiles:delete' + ], + superuser: [ + 'employees:create', + 'employees:read', + 'profiles:create', + 'profiles:read', + 'profiles:update_own' + ], + user: [ + 'employees:read', + 'profiles:read', + 'profiles:update_own' + ] +} + +export function usePermissions() { + const { user, isAuthenticated } = useAuthStore() + + const hasPermission = (permission: string): boolean => { + if (!isAuthenticated || !user) return false + + const rolePermissions = ROLE_PERMISSIONS[user.role] || [] + return rolePermissions.includes(permission) + } + + const hasRole = (role: string | string[]): boolean => { + if (!isAuthenticated || !user) return false + + const allowedRoles = Array.isArray(role) ? role : [role] + return allowedRoles.includes(user.role) + } + + const canCreateEmployee = (): boolean => { + return hasPermission('employees:create') + } + + const canEditEmployee = (employeeId?: string): boolean => { + if (!isAuthenticated || !user) return false + + // Admins can edit anyone + if (user.role === 'admin') return true + + // Superusers can edit anyone + if (user.role === 'superuser') return true + + // Users can only edit their own profile (if linked) + if (user.role === 'user' && employeeId && user.employeeId === employeeId) { + return true + } + + return false + } + + const canDeleteEmployee = (): boolean => { + return hasPermission('employees:delete') + } + + const canAccessAdminPanel = (): boolean => { + return hasPermission('admin:panel:access') + } + + const canManageUsers = (): boolean => { + return hasPermission('users:create') || hasPermission('users:update') || hasPermission('users:delete') + } + + return { + hasPermission, + hasRole, + canCreateEmployee, + canEditEmployee, + canDeleteEmployee, + canAccessAdminPanel, + canManageUsers, + user, + isAuthenticated + } +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..94785c9 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..dc909f4 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,66 @@ +import axios from 'axios' +import { Employee } from '@skillmate/shared' + +const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api' + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json' + } +}) + +// Auth interceptor +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +export const authApi = { + login: async (email: string, password: string) => { + const response = await api.post('/auth/login', { email, password }) + return response.data.data + }, + logout: async () => { + localStorage.removeItem('token') + } +} + +export const employeeApi = { + getAll: async () => { + const response = await api.get('/employees/public') + return response.data.data + }, + getById: async (id: string) => { + const response = await api.get(`/employees/${id}`) + return response.data.data + }, + create: async (employee: Partial) => { + const response = await api.post('/employees', employee) + return response.data + }, + update: async (id: string, employee: Partial) => { + const response = await api.put(`/employees/${id}`, employee) + return response.data.data + }, + delete: async (id: string) => { + await api.delete(`/employees/${id}`) + }, + search: async (query: string) => { + const response = await api.get(`/employees/search?q=${query}`) + return response.data.data + } +} + +export const skillsApi = { + searchBySkills: async (skills: string[]) => { + const response = await api.post('/skills/search', { skills }) + return response.data + } +} + +export { api } +export default api diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..750b452 --- /dev/null +++ b/frontend/src/stores/authStore.ts @@ -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()( + 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', + } + ) +) \ No newline at end of file diff --git a/frontend/src/stores/themeStore.ts b/frontend/src/stores/themeStore.ts new file mode 100644 index 0000000..edc57a9 --- /dev/null +++ b/frontend/src/stores/themeStore.ts @@ -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()( + persist( + (set) => ({ + isDarkMode: false, + toggleTheme: () => set((state) => ({ isDarkMode: !state.isDarkMode })), + setTheme: (isDark) => set({ isDarkMode: isDark }), + }), + { + name: 'theme-storage', + } + ) +) \ No newline at end of file diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css new file mode 100644 index 0000000..8adcd74 --- /dev/null +++ b/frontend/src/styles/index.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/temp/skills.ts b/frontend/src/temp/skills.ts new file mode 100644 index 0000000..4a974ae --- /dev/null +++ b/frontend/src/temp/skills.ts @@ -0,0 +1,23 @@ +export const SKILL_CATEGORIES = { + languages: 'Sprachen', + it: 'IT-Kenntnisse', + investigation: 'Ermittlung', + analysis: 'Analyse', + operations: 'Einsatz', + certificates: 'Zertifikate', + weapons: 'Waffen', + driving: 'Fahrerlaubnisse', + special: 'Sonderqualifikationen' +}; + +export const DEFAULT_SKILLS = { + languages: ['Deutsch'], + it: ['Python'], + investigation: ['Verdeckte Ermittlung'], + analysis: ['Finanzermittlungen'], + operations: ['Einsatzplanung'], + certificates: ['Sicherheitsüberprüfung Ü2'], + weapons: ['Waffensachkunde'], + driving: ['Führerschein Klasse B'], + special: ['Drohnenpilot'] +}; diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts new file mode 100644 index 0000000..43f3052 --- /dev/null +++ b/frontend/src/types/electron.d.ts @@ -0,0 +1,22 @@ +export interface ElectronAPI { + minimize: () => Promise + maximize: () => Promise + close: () => Promise + getVersion: () => Promise + getPath: (name: string) => Promise + onWindowMaximized: (callback: () => void) => void + onWindowUnmaximized: (callback: () => void) => void + removeAllListeners: (channel: string) => void +} + +declare global { + interface Window { + electronAPI?: ElectronAPI + } + + namespace NodeJS { + interface Process { + platform: string + } + } +} \ No newline at end of file diff --git a/frontend/src/views/Dashboard.tsx b/frontend/src/views/Dashboard.tsx new file mode 100644 index 0000000..4ebac96 --- /dev/null +++ b/frontend/src/views/Dashboard.tsx @@ -0,0 +1,270 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { employeeApi } from '../services/api' +import { useAuthStore } from '../stores/authStore' + +interface DashboardStats { + totalEmployees: number + totalSkills: number + recentEmployees: number + availableEmployees: number +} + +export default function Dashboard() { + const navigate = useNavigate() + const { user } = useAuthStore() + const [stats, setStats] = useState({ + totalEmployees: 0, + totalSkills: 0, + recentEmployees: 0, + availableEmployees: 0, + }) + const [employees, setEmployees] = useState([]) + const [trendWindow, setTrendWindow] = useState<'today' | 7 | 30>('today') + + useEffect(() => { + fetchStats() + }, []) + + const fetchStats = async () => { + try { + const employees = await employeeApi.getAll() + setEmployees(employees) + const totalSkills = employees.reduce((sum: number, emp: any) => sum + emp.skills.length, 0) + const availableEmployees = employees.filter((emp: any) => emp.availability === 'available').length + + setStats({ + totalEmployees: employees.length, + totalSkills: totalSkills, + recentEmployees: employees.filter((emp: any) => { + const created = new Date(emp.createdAt) + const weekAgo = new Date() + weekAgo.setDate(weekAgo.getDate() - 7) + return created > weekAgo + }).length, + availableEmployees: availableEmployees, + }) + } catch (error) { + console.error('Failed to fetch stats:', error) + // Fallback zu Demo-Daten + setStats({ + totalEmployees: 0, + totalSkills: 0, + recentEmployees: 0, + availableEmployees: 0, + }) + setEmployees([]) + } finally { + // Loading complete + } + } + + const statsCards = [ + { + title: 'Mitarbeiter gesamt', + value: stats.totalEmployees, + color: 'text-primary-blue', + bgColor: 'bg-bg-accent', + }, + { + title: 'Skills erfasst', + value: stats.totalSkills, + color: 'text-success', + bgColor: 'bg-success-bg', + }, + ] + + // Trends: Top-Kompetenzen im Zeitraum (basierend auf aktualisierten Profilen) + const topSkills = useMemo(() => { + if (!employees.length) return [] as { name: string; count: number }[] + const since = new Date() + if (trendWindow === 'today') { + since.setHours(0, 0, 0, 0) + } else { + since.setDate(since.getDate() - trendWindow) + } + const recent = employees.filter((e: any) => { + const updated = new Date(e.updatedAt) + return !isNaN(updated.getTime()) && updated > since + }) + const counts = new Map() + for (const emp of recent) { + const skills = (emp.skills || []) as { name?: string; id: string }[] + for (const s of skills) { + const key = (s && (s as any).name) || (s && (s as any).id) || 'Unbenannt' + counts.set(key, (counts.get(key) || 0) + 1) + } + } + const arr = Array.from(counts.entries()).map(([name, count]) => ({ name, count })) + arr.sort((a, b) => b.count - a.count) + return arr.slice(0, 5) + }, [employees, trendWindow]) + + // Profilqualität (mein Profil): Kontakt & Dienststelle + ob Skills vorhanden + const me = useMemo(() => { + if (!user?.employeeId) return null + return employees.find(e => e.id === user.employeeId) || null + }, [employees, user?.employeeId]) + + const profileQuality = useMemo(() => { + if (!me) return { percent: 0, missing: ['Profil wird geladen oder nicht verknüpft'] as string[], detailed: [] as { key: string; label: string; ok: boolean }[] } + const checks: { key: string; label: string; ok: boolean }[] = [ + { key: 'firstName', label: 'Vorname', ok: !!me.firstName }, + { key: 'lastName', label: 'Nachname', ok: !!me.lastName }, + { key: 'email', label: 'E-Mail', ok: !!me.email }, + { key: 'department', label: 'Dienststelle', ok: !!me.department }, + { key: 'position', label: 'Position', ok: !!me.position }, + { key: 'phone', label: 'Telefon', ok: !!me.phone && me.phone !== 'Nicht angegeben' }, + { key: 'mobile', label: 'Mobil', ok: !!me.mobile }, + { key: 'office', label: 'Büro', ok: !!me.office }, + { key: 'employeeNumber', label: 'NW-Kennung', ok: !!me.employeeNumber }, + { key: 'skills', label: 'Mindestens eine Kompetenz erfasst', ok: Array.isArray(me.skills) && me.skills.length > 0 }, + ] + const total = checks.length + const done = checks.filter(c => c.ok).length + const percent = Math.round((done / total) * 100) + const missing = checks.filter(c => !c.ok).map(c => c.label) + const detailed = checks + return { percent, missing, detailed } + }, [me]) + + const qualityBarColor = (percent: number) => { + if (percent <= 33) return 'bg-red-500 dark:bg-red-700' + if (percent <= 66) return 'bg-green-500 dark:bg-green-700' + return 'bg-purple-500 dark:bg-purple-700' + } + + return ( +
+

+ Dashboard +

+ +
+ {statsCards.map((stat, index) => ( +
+
+ + {stat.value} + +
+

+ {stat.title} +

+
+ ))} +
+ +
+ {/* Trends */} +
+
+

Trends

+
+ + + +
+
+
+ {topSkills.length === 0 ? ( +

Keine Daten im ausgewählten Zeitraum.

+ ) : ( + topSkills.map((s, idx) => ( +
+ {s.name} + {s.count} Profile +
+ )) + )} +

Basis: Profile mit Aktualisierung im Zeitraum.

+
+
+ + {/* Profilqualität */} +
+

Profilqualität

+ {!user?.employeeId ? ( +

Kein Mitarbeiterprofil mit Ihrem Nutzer verknüpft.

+ ) : ( + <> +
+
+
+
+ {profileQuality.percent}% vollständig + +
+
+ {profileQuality.detailed.map((c, i) => ( +
+ {c.label} + {c.ok ? ( + + ) : ( + • fehlt + )} +
+ ))} +
+ + )} +
+
+

+ Schnellzugriff +

+
+ + + +
+
+ +
+

+ Letzte Aktivitäten +

+
+

+ Keine aktuellen Aktivitäten +

+
+
+
+
+ ) +} diff --git a/frontend/src/views/DeskBooking.tsx b/frontend/src/views/DeskBooking.tsx new file mode 100644 index 0000000..a6f77f6 --- /dev/null +++ b/frontend/src/views/DeskBooking.tsx @@ -0,0 +1,260 @@ +import React, { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../services/api' +import { Workspace, Booking, WorkspaceFilter } from '@skillmate/shared' + +export default function DeskBooking() { + const navigate = useNavigate() + const [workspaces, setWorkspaces] = useState([]) + const [myBookings, setMyBookings] = useState([]) + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) + const [selectedTime, setSelectedTime] = useState('09:00') + const [duration, setDuration] = useState(8) // hours + const [filter, setFilter] = useState({}) + const [loading, setLoading] = useState(true) + const [showBookingModal, setShowBookingModal] = useState(false) + const [selectedWorkspace, setSelectedWorkspace] = useState(null) + + useEffect(() => { + loadData() + }, [selectedDate, filter]) + + const loadData = async () => { + try { + setLoading(true) + + // Calculate start and end times + const startTime = new Date(`${selectedDate}T${selectedTime}:00`).toISOString() + const endTime = new Date(new Date(startTime).getTime() + duration * 60 * 60 * 1000).toISOString() + + // Load available workspaces + const availableResponse = await api.post('/workspaces/availability', { + start_time: startTime, + end_time: endTime, + type: filter.type || 'desk' + }) + setWorkspaces(availableResponse.data) + + // Load user's bookings + const bookingsResponse = await api.get('/bookings/my-bookings', { + params: { + from_date: selectedDate, + status: 'confirmed' + } + }) + setMyBookings(bookingsResponse.data) + } catch (error) { + console.error('Failed to load data:', error) + } finally { + setLoading(false) + } + } + + const handleBook = async (workspace: Workspace) => { + try { + const startTime = new Date(`${selectedDate}T${selectedTime}:00`).toISOString() + const endTime = new Date(new Date(startTime).getTime() + duration * 60 * 60 * 1000).toISOString() + + await api.post('/bookings', { + workspace_id: workspace.id, + start_time: startTime, + end_time: endTime + }) + + // Reload data + await loadData() + + // Show success message + alert('Arbeitsplatz erfolgreich gebucht!') + } catch (error: any) { + alert(error.response?.data?.error || 'Buchung fehlgeschlagen') + } + } + + const handleCancelBooking = async (bookingId: string) => { + if (!confirm('Möchten Sie diese Buchung wirklich stornieren?')) return + + try { + await api.post(`/bookings/${bookingId}/cancel`) + await loadData() + alert('Buchung erfolgreich storniert') + } catch (error) { + alert('Stornierung fehlgeschlagen') + } + } + + const handleCheckIn = async (bookingId: string) => { + try { + await api.post(`/bookings/${bookingId}/check-in`) + await loadData() + alert('Check-in erfolgreich') + } catch (error: any) { + alert(error.response?.data?.error || 'Check-in fehlgeschlagen') + } + } + + return ( +
+
+

+ Arbeitsplatz buchen +

+

+ Buchen Sie Ihren Arbeitsplatz flexibel und einfach +

+
+ + {/* Filter Section */} +
+
+
+ + setSelectedDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* My Bookings */} + {myBookings.length > 0 && ( +
+

+ Meine Buchungen heute +

+
+ {myBookings.map(booking => ( +
+
+

+ {booking.workspace?.name || 'Arbeitsplatz'} +

+

+ {new Date(booking.start_time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} - + {new Date(booking.end_time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} +

+
+
+ {!booking.check_in_time && new Date(booking.start_time) <= new Date(new Date().getTime() + 15 * 60 * 1000) && ( + + )} + +
+
+ ))} +
+
+ )} + + {/* Available Workspaces */} +
+

+ Verfügbare Arbeitsplätze +

+ + {loading ? ( +
+
+
+ ) : workspaces.length === 0 ? ( +

+ Keine Arbeitsplätze für den gewählten Zeitraum verfügbar +

+ ) : ( +
+ {workspaces.map(workspace => ( +
+

+ {workspace.name} +

+
+

Etage: {workspace.floor}

+ {workspace.building &&

Gebäude: {workspace.building}

} + {workspace.equipment && workspace.equipment.length > 0 && ( +

Ausstattung: {workspace.equipment.join(', ')}

+ )} +
+ +
+ ))} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/views/EmployeeDetail.tsx b/frontend/src/views/EmployeeDetail.tsx new file mode 100644 index 0000000..a679b7f --- /dev/null +++ b/frontend/src/views/EmployeeDetail.tsx @@ -0,0 +1,232 @@ +import { useParams, useNavigate } from 'react-router-dom' +import { useState, useEffect } from 'react' +import type { Employee } from '@skillmate/shared' +// Dynamische Hierarchie für Darstellung der Nutzer-Skills +import SkillLevelBar from '../components/SkillLevelBar' +import { employeeApi } from '../services/api' + +export default function EmployeeDetail() { + const { id } = useParams() + const navigate = useNavigate() + const [employee, setEmployee] = useState(null) + const [error, setError] = useState('') + const [hierarchy, setHierarchy] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([]) + const API_BASE = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api' + const PUBLIC_BASE = (import.meta as any).env?.VITE_API_PUBLIC_URL || API_BASE.replace(/\/api$/, '') + const toPublic = (p?: string | null) => (p && p.startsWith('/uploads/')) ? `${PUBLIC_BASE}${p}` : (p || '') + + useEffect(() => { + if (id) { + fetchEmployee(id) + } + }, [id]) + + useEffect(() => { + const load = async () => { + try { + const res = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }) + const data = await res.json() + if (data?.success) setHierarchy(data.data || []) + } catch {} + } + load() + }, []) + + const fetchEmployee = async (employeeId: string) => { + try { + const data = await employeeApi.getById(employeeId) + setEmployee(data) + } catch (error) { + console.error('Failed to fetch employee:', error) + // Fallback to mock data + const mockEmployee: Employee = { + id: '1', + firstName: 'Max', + lastName: 'Mustermann', + employeeNumber: 'EMP001', + position: 'Senior Analyst', + department: 'Cybercrime', + email: 'max.mustermann@behörde.de', + phone: '+49 30 12345-100', + mobile: '+49 170 1234567', + office: 'Raum 3.42', + availability: 'available', + skills: [ + { id: '1', name: 'Python', category: 'it', level: 'expert', verified: true }, + { id: '2', name: 'Netzwerkforensik', category: 'it', level: 'advanced', verified: true }, + { id: '3', name: 'OSINT-Tools', category: 'it', level: 'advanced' }, + ], + languages: [ + { language: 'Deutsch', proficiency: 'native', isNative: true }, + { language: 'Englisch', proficiency: 'fluent', certified: true, certificateType: 'C1' }, + { language: 'Russisch', proficiency: 'intermediate' }, + ], + clearance: { + level: 'Ü3', + validUntil: new Date('2025-12-31'), + issuedDate: new Date('2021-01-15'), + }, + specializations: ['Digitale Forensik', 'Malware-Analyse', 'Darknet-Ermittlungen'], + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 'admin', + } + setEmployee(mockEmployee) + } + } + + if (!employee) { + return ( +
+

Mitarbeiter wird geladen...

+
+ ) + } + + // Hinweis: Verfügbarkeits-Badge wird im Mitarbeiter-Detail nicht angezeigt + + return ( +
+ + +
+
+
+
+
+ {employee.photo ? ( + Foto + ) : ( +
+ {employee.firstName.charAt(0)}{employee.lastName.charAt(0)} +
+ )} +
+

+ {employee.firstName} {employee.lastName} +

+ {employee.employeeNumber && ( +

{employee.employeeNumber}

+ )} +
+
+ +
+

+ Kontaktdaten +

+
+
+ E-Mail: +

{employee.email}

+
+
+ Telefon: +

{employee.phone}

+
+ {employee.mobile && ( +
+ Mobil: +

{employee.mobile}

+
+ )} + {employee.office && ( +
+ Büro: +

{employee.office}

+
+ )} +
+
+
+ +
+
+

+ Allgemeine Informationen +

+
+
+ Position: +

{employee.position}

+
+
+ Dienststelle: +

{employee.department}

+
+ {employee.clearance && ( +
+ Sicherheitsüberprüfung: +

+ {employee.clearance.level} (gültig bis {new Date(employee.clearance.validUntil).toLocaleDateString('de-DE')}) +

+
+ )} +
+
+ +
+

Kompetenzen

+
+ {hierarchy.map(cat => { + const subs = cat.subcategories.map(sub => { + const selected = sub.skills.filter(sk => employee.skills.some(es => es.id === sk.id)) + return { sub, selected } + }).filter(x => x.selected.length > 0) + if (subs.length === 0) return null + return ( +
+

{cat.name}

+ {subs.map(({ sub, selected }) => ( +
+
+ {sub.name} +
+
    + {selected.map((sk) => { + const info = employee.skills.find(es => es.id === sk.id) + const levelVal = info?.level ? Number(info.level) : '' + return ( +
  • +
    + {sk.name} +
    + {}} disabled showHelp={false} /> +
  • + ) + })} +
+
+ ))} +
+ ) + })} +
+
+ + {employee.specializations.length > 0 && ( +
+

+ Spezialisierungen +

+
+ {employee.specializations.map((spec, index) => ( + + {spec} + + ))} +
+
+ )} +
+
+
+ ) +} diff --git a/frontend/src/views/EmployeeForm.tsx b/frontend/src/views/EmployeeForm.tsx new file mode 100644 index 0000000..9068891 --- /dev/null +++ b/frontend/src/views/EmployeeForm.tsx @@ -0,0 +1,684 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import type { Employee } from '@skillmate/shared' +import { employeeApi } from '../services/api' +import { SKILL_HIERARCHY, LANGUAGE_LEVELS } from '../data/skillCategories' +import PhotoPreview from '../components/PhotoPreview' + +export default function EmployeeForm() { + const navigate = useNavigate() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [validationErrors, setValidationErrors] = useState>({}) + + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + employeeNumber: '', + position: '', + department: '', + email: '', + phone: '', + mobile: '', + office: '', + clearance: '', + availability: 'available' as 'available' | 'parttime' | 'unavailable', + partTimeHours: '', + skills: [] as any[], + languages: [] as string[], + specializations: [] as string[] + }) + + const [employeePhoto, setEmployeePhoto] = useState(null) + const [photoFile, setPhotoFile] = useState(null) + + const [expandedCategories, setExpandedCategories] = useState>(new Set()) + const [expandedSubCategories, setExpandedSubCategories] = useState>(new Set()) + const [skillSearchTerm, setSkillSearchTerm] = useState('') + const [searchResults, setSearchResults] = useState([]) + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData(prev => ({ ...prev, [name]: value })) + } + + const toggleCategory = (categoryId: string) => { + setExpandedCategories(prev => { + const newSet = new Set(prev) + if (newSet.has(categoryId)) { + newSet.delete(categoryId) + } else { + newSet.add(categoryId) + } + return newSet + }) + } + + const toggleSubCategory = (subCategoryId: string) => { + setExpandedSubCategories(prev => { + const newSet = new Set(prev) + if (newSet.has(subCategoryId)) { + newSet.delete(subCategoryId) + } else { + newSet.add(subCategoryId) + } + return newSet + }) + } + + const handleSkillToggle = (categoryId: string, subCategoryId: string, skillId: string, skillName: string) => { + setFormData(prev => { + const skills = [...prev.skills] + const existingIndex = skills.findIndex(s => + s.categoryId === categoryId && + s.subCategoryId === subCategoryId && + s.skillId === skillId + ) + + if (existingIndex > -1) { + skills.splice(existingIndex, 1) + } else { + skills.push({ + categoryId, + subCategoryId, + skillId, + name: skillName, + level: '' + }) + } + return { ...prev, skills } + }) + } + + const handleSkillLevelChange = (categoryId: string, subCategoryId: string, skillId: string, level: string) => { + setFormData(prev => { + const skills = [...prev.skills] + const skill = skills.find(s => + s.categoryId === categoryId && + s.subCategoryId === subCategoryId && + s.skillId === skillId + ) + if (skill) { + skill.level = level + } + return { ...prev, skills } + }) + } + + const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) => { + return formData.skills.some(s => + s.categoryId === categoryId && + s.subCategoryId === subCategoryId && + s.skillId === skillId + ) + } + + const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) => { + const skill = formData.skills.find(s => + s.categoryId === categoryId && + s.subCategoryId === subCategoryId && + s.skillId === skillId + ) + return skill?.level || '' + } + + // Skill-Suche + const handleSkillSearch = (searchTerm: string) => { + setSkillSearchTerm(searchTerm) + + if (searchTerm.length < 2) { + setSearchResults([]) + return + } + + const results: any[] = [] + const lowerSearch = searchTerm.toLowerCase() + + SKILL_HIERARCHY.forEach(category => { + category.subcategories.forEach(subCategory => { + subCategory.skills.forEach(skill => { + if (skill.name.toLowerCase().includes(lowerSearch)) { + results.push({ + categoryId: category.id, + categoryName: category.name, + subCategoryId: subCategory.id, + subCategoryName: subCategory.name, + skillId: skill.id, + skillName: skill.name + }) + } + }) + }) + }) + + setSearchResults(results) + } + + const handleSearchResultClick = (result: any) => { + // Öffne die entsprechenden Kategorien + setExpandedCategories(prev => new Set([...prev, result.categoryId])) + setExpandedSubCategories(prev => new Set([...prev, `${result.categoryId}-${result.subCategoryId}`])) + + // Wähle den Skill aus + handleSkillToggle(result.categoryId, result.subCategoryId, result.skillId, result.skillName) + + // Lösche die Suche + setSkillSearchTerm('') + setSearchResults([]) + } + + const validateForm = () => { + const errors: Record = {} + + if (!formData.firstName.trim()) errors.firstName = 'Vorname ist erforderlich' + if (!formData.lastName.trim()) errors.lastName = 'Nachname ist erforderlich' + if (!formData.employeeNumber.trim()) errors.employeeNumber = 'Mitarbeiternummer ist erforderlich' + if (!formData.position.trim()) errors.position = 'Position ist erforderlich' + if (!formData.department.trim()) errors.department = 'Abteilung ist erforderlich' + if (!formData.email.trim()) errors.email = 'E-Mail ist erforderlich' + else if (!/\S+@\S+\.\S+/.test(formData.email)) errors.email = 'Ungültige E-Mail-Adresse' + if (!formData.phone.trim()) errors.phone = 'Telefonnummer ist erforderlich' + + setValidationErrors(errors) + return Object.keys(errors).length === 0 + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setValidationErrors({}) + + if (!validateForm()) { + setError('Bitte füllen Sie alle Pflichtfelder aus') + return + } + + setLoading(true) + + try { + const newEmployee: Partial = { + ...formData, + skills: formData.skills.map((skill, index) => ({ + id: `skill-${index}`, + name: skill.name, + category: skill.categoryId, + level: skill.level || 3 + })), + languages: formData.skills + .filter(s => s.subCategoryId === 'languages') + .map(s => ({ + language: s.name, + proficiency: s.level || 'B1' + })), + clearance: formData.clearance ? { + level: formData.clearance as 'Ü2' | 'Ü3', + validUntil: new Date(new Date().setFullYear(new Date().getFullYear() + 5)), + issuedDate: new Date() + } : undefined, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 'admin' + } + + const result = await employeeApi.create(newEmployee) + const newEmployeeId = result.data.id + + // Upload photo if we have one + if (photoFile && newEmployeeId) { + const formData = new FormData() + formData.append('photo', photoFile) + + try { + await fetch(`http://localhost:3001/api/upload/employee-photo/${newEmployeeId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: formData + }) + } catch (uploadError) { + console.error('Failed to upload photo:', uploadError) + } + } + + navigate('/employees') + } catch (err: any) { + if (err.response?.status === 401) { + setError('Ihre Session ist abgelaufen. Bitte melden Sie sich erneut an.') + } else { + setError(err.response?.data?.message || 'Fehler beim Erstellen des Mitarbeiters') + } + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ Neuer Mitarbeiter +

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Persönliche Informationen */} +
+

+ Persönliche Informationen +

+ +
+
+
+ + { + setPhotoFile(file) + // Create preview URL + const reader = new FileReader() + reader.onloadend = () => { + setEmployeePhoto(reader.result as string) + } + reader.readAsDataURL(file) + }} + onPhotoRemove={() => { + setPhotoFile(null) + setEmployeePhoto(null) + }} + /> +
+
+ +
+ + + {validationErrors.firstName && ( +

{validationErrors.firstName}

+ )} +
+ +
+ + + {validationErrors.lastName && ( +

{validationErrors.lastName}

+ )} +
+ +
+ + + {validationErrors.employeeNumber && ( +

{validationErrors.employeeNumber}

+ )} +
+ +
+ + + {validationErrors.email && ( +

{validationErrors.email}

+ )} +
+ +
+ + + {validationErrors.position && ( +

{validationErrors.position}

+ )} +
+ +
+ + + {validationErrors.department && ( +

{validationErrors.department}

+ )} +
+ +
+ + + {validationErrors.phone && ( +

{validationErrors.phone}

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {formData.availability === 'parttime' && ( +
+ + +
+ )} +
+
+ + {/* Skills */} +
+
+

+ Fähigkeiten und Qualifikationen +

+ + {/* Skill-Suche */} +
+ handleSkillSearch(e.target.value)} + placeholder="Skills suchen..." + className="input-field w-full pl-10 pr-3" + /> + + + + + {/* Suchergebnisse */} + {searchResults.length > 0 && ( +
+ {searchResults.map((result, index) => ( + + ))} +
+ )} + + {skillSearchTerm.length >= 2 && searchResults.length === 0 && ( +
+

Keine Skills gefunden

+
+ )} +
+
+ +
+ {SKILL_HIERARCHY.map(category => ( +
+ {/* Oberste Kategorie */} + + + {/* Unterkategorien */} + {expandedCategories.has(category.id) && ( +
+ {category.subcategories.map(subCategory => ( +
+ {/* Mittlere Kategorie */} + + + {/* Skills */} + {expandedSubCategories.has(`${category.id}-${subCategory.id}`) && ( +
+ {subCategory.skills.map(skill => ( +
+ + + {/* Niveauauswahl */} + {isSkillSelected(category.id, subCategory.id, skill.id) && ( + subCategory.id === 'languages' ? ( + + ) : ( + + ) + )} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ))} +
+
+ + {/* Submit */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/views/EmployeeList.tsx b/frontend/src/views/EmployeeList.tsx new file mode 100644 index 0000000..1eb5993 --- /dev/null +++ b/frontend/src/views/EmployeeList.tsx @@ -0,0 +1,197 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import type { Employee } from '@skillmate/shared' +import { SearchIcon } from '../components/icons' +import EmployeeCard from '../components/EmployeeCard' +import { employeeApi } from '../services/api' +import { useAuthStore } from '../stores/authStore' +import { usePermissions } from '../hooks/usePermissions' + +export default function EmployeeList() { + const navigate = useNavigate() + const { user } = useAuthStore() + const { canCreateEmployee } = usePermissions() + const [employees, setEmployees] = useState([]) + const [searchTerm, setSearchTerm] = useState('') + const [filteredEmployees, setFilteredEmployees] = useState([]) + const [filters, setFilters] = useState({ + department: '', + skills: '', + availability: '' + }) + const [showFilters, setShowFilters] = useState(false) + + useEffect(() => { + fetchEmployees() + }, []) + + const fetchEmployees = async () => { + try { + const data = await employeeApi.getAll() + setEmployees(data) + setFilteredEmployees(data) + } catch (error) { + console.error('Failed to fetch employees:', error) + // Fallback zu Mock-Daten bei Fehler + const mockEmployees: Employee[] = [ + { + id: '1', + firstName: 'Max', + lastName: 'Mustermann', + employeeNumber: 'EMP001', + position: 'Senior Analyst', + department: 'Cybercrime', + email: 'max.mustermann@behörde.de', + phone: '+49 30 12345-100', + availability: 'available', + skills: [], + languages: [], + specializations: ['Digitale Forensik', 'Malware-Analyse'], + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 'admin', + }, + // Add more mock data as needed + ] + setEmployees(mockEmployees) + setFilteredEmployees(mockEmployees) + } + } + + useEffect(() => { + let filtered = employees.filter(emp => { + // Text search + const matchesSearch = searchTerm === '' || + `${emp.firstName} ${emp.lastName}`.toLowerCase().includes(searchTerm.toLowerCase()) || + emp.department.toLowerCase().includes(searchTerm.toLowerCase()) || + emp.position.toLowerCase().includes(searchTerm.toLowerCase()) || + emp.specializations.some(spec => spec.toLowerCase().includes(searchTerm.toLowerCase())) + + // Department filter + const matchesDepartment = filters.department === '' || emp.department === filters.department + + // Availability filter + const matchesAvailability = filters.availability === '' || emp.availability === filters.availability + + // Skills filter (basic - später erweitern) + const matchesSkills = filters.skills === '' || + emp.specializations.some(spec => spec.toLowerCase().includes(filters.skills.toLowerCase())) + + return matchesSearch && matchesDepartment && matchesAvailability && matchesSkills + }) + + setFilteredEmployees(filtered) + }, [searchTerm, employees, filters]) + + return ( +
+
+

+ Mitarbeiter & Expert:innen +

+

+ {filteredEmployees.length} von {employees.length} Mitarbeitern +

+ {canCreateEmployee() && ( + + )} +
+ + {/* Erweiterte Suchleiste */} +
+
+
+ setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500" + /> + +
+ +
+ + {/* Erweiterte Filter */} + {showFilters && ( +
+
+ + +
+ +
+ + +
+ +
+ + setFilters(prev => ({ ...prev, skills: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+ )} +
+ +
+ {filteredEmployees.map((employee) => ( + navigate(`/employees/${employee.id}`)} + /> + ))} +
+ + {filteredEmployees.length === 0 && ( +
+

Keine Mitarbeiter gefunden

+
+ )} +
+ ) +} diff --git a/frontend/src/views/FloorPlan.tsx b/frontend/src/views/FloorPlan.tsx new file mode 100644 index 0000000..8b395e4 --- /dev/null +++ b/frontend/src/views/FloorPlan.tsx @@ -0,0 +1,251 @@ +import React, { useState, useEffect } from 'react' +import { api } from '../services/api' +import { Workspace, Booking } from '@skillmate/shared' + +interface WorkspaceWithStatus extends Workspace { + isBooked?: boolean + currentUser?: { + name: string + photo?: string + } +} + +export default function FloorPlan() { + const [selectedFloor, setSelectedFloor] = useState('1') + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]) + const [selectedTime, setSelectedTime] = useState(new Date().toISOString().substring(11, 16)) + const [workspaces, setWorkspaces] = useState([]) + const [loading, setLoading] = useState(true) + const [hoveredWorkspace, setHoveredWorkspace] = useState(null) + + const floors = ['EG', '1', '2', '3', '4'] + + useEffect(() => { + loadFloorData() + }, [selectedFloor, selectedDate, selectedTime]) + + const loadFloorData = async () => { + try { + setLoading(true) + + // Get all workspaces for the floor + const workspaceResponse = await api.get('/workspaces', { + params: { floor: selectedFloor } + }) + + // Get bookings for the selected time + const dateTime = new Date(`${selectedDate}T${selectedTime}:00`).toISOString() + const bookingResponse = await api.get('/bookings', { + params: { + from_date: dateTime, + to_date: dateTime, + status: 'confirmed' + } + }) + + // Map bookings to workspaces + const bookingMap = new Map() + bookingResponse.data.forEach((booking: any) => { + if (new Date(booking.start_time) <= new Date(dateTime) && + new Date(booking.end_time) > new Date(dateTime)) { + bookingMap.set(booking.workspace_id, { + name: `${booking.first_name} ${booking.last_name}`, + photo: booking.photo + }) + } + }) + + // Combine workspace and booking data + const workspacesWithStatus = workspaceResponse.data.map((ws: Workspace) => ({ + ...ws, + isBooked: bookingMap.has(ws.id), + currentUser: bookingMap.get(ws.id) + })) + + setWorkspaces(workspacesWithStatus) + } catch (error) { + console.error('Failed to load floor data:', error) + } finally { + setLoading(false) + } + } + + const getWorkspaceColor = (workspace: WorkspaceWithStatus) => { + if (!workspace.is_active) return 'bg-gray-300' + if (workspace.isBooked) return 'bg-red-500' + return 'bg-green-500' + } + + const renderWorkspace = (workspace: WorkspaceWithStatus) => { + const size = workspace.type === 'meeting_room' ? 'w-24 h-16' : 'w-16 h-12' + + return ( +
setHoveredWorkspace(workspace)} + onMouseLeave={() => setHoveredWorkspace(null)} + > + {workspace.name} +
+ ) + } + + return ( +
+
+

+ Anwesenheitsübersicht +

+

+ Sehen Sie auf einen Blick, wer wo im Büro ist +

+
+ + {/* Controls */} +
+
+
+ + +
+ +
+ + setSelectedDate(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" + /> +
+ +
+ + setSelectedTime(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" + /> +
+
+ +
+
+
+ Frei +
+
+
+ Belegt +
+
+
+ Inaktiv +
+
+
+ + {/* Floor Plan */} +
+ {loading ? ( +
+
+
+ ) : ( + <> +
+ {/* Grid background */} +
+ + {/* Workspaces */} + {workspaces.map(workspace => renderWorkspace(workspace))} + + {/* Hover tooltip */} + {hoveredWorkspace && ( +
+

{hoveredWorkspace.name}

+

+ {hoveredWorkspace.type === 'desk' ? 'Arbeitsplatz' : + hoveredWorkspace.type === 'meeting_room' ? 'Meetingraum' : + hoveredWorkspace.type === 'phone_booth' ? 'Telefonbox' : 'Sonstiges'} +

+ {hoveredWorkspace.isBooked && hoveredWorkspace.currentUser && ( +
+

Belegt von:

+

{hoveredWorkspace.currentUser.name}

+
+ )} + {hoveredWorkspace.equipment && hoveredWorkspace.equipment.length > 0 && ( +

+ {hoveredWorkspace.equipment.join(', ')} +

+ )} +
+ )} +
+ + {/* Statistics */} +
+
+

Gesamt

+

+ {workspaces.length} +

+
+
+

Frei

+

+ {workspaces.filter(ws => ws.is_active && !ws.isBooked).length} +

+
+
+

Belegt

+

+ {workspaces.filter(ws => ws.isBooked).length} +

+
+
+

Auslastung

+

+ {workspaces.length > 0 + ? Math.round((workspaces.filter(ws => ws.isBooked).length / workspaces.filter(ws => ws.is_active).length) * 100) + : 0}% +

+
+
+ + )} +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx new file mode 100644 index 0000000..a62509f --- /dev/null +++ b/frontend/src/views/Login.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { authApi } from '../services/api' +import { useAuthStore } from '../stores/authStore' + +export default function Login() { + const navigate = useNavigate() + const { login } = useAuthStore() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + + try { + const response = await authApi.login(email, password) + localStorage.setItem('token', response.token.accessToken) + login(response.user, response.token.accessToken) + navigate('/') + } catch (err: any) { + setError(err.response?.data?.error?.message || 'Anmeldung fehlgeschlagen') + } finally { + setLoading(false) + } + } + + return ( +
+
+

+ SkillMate Login +

+ +
+
+ + setEmail(e.target.value)} + className="input-field" + placeholder="E-Mail-Adresse eingeben" + required + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + className="input-field" + placeholder="Passwort eingeben" + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +
+

Für erste Anmeldung wenden Sie sich an Ihren Administrator

+
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/views/MyProfile.tsx b/frontend/src/views/MyProfile.tsx new file mode 100644 index 0000000..4a97dc3 --- /dev/null +++ b/frontend/src/views/MyProfile.tsx @@ -0,0 +1,255 @@ +import { useEffect, useState } from 'react' +import { useAuthStore } from '../stores/authStore' +import { employeeApi } from '../services/api' +import PhotoUpload from '../components/PhotoUpload' +import SkillLevelBar from '../components/SkillLevelBar' + +interface SkillSelection { categoryId: string; subCategoryId: string; skillId: string; name: string; level: string } + +export default function MyProfile() { + const { user } = useAuthStore() + const employeeId = user?.employeeId + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + const [form, setForm] = useState(null) + const [catalog, setCatalog] = useState<{ id: string; name: string; subcategories: { id: string; name: string; skills: { id: string; name: string }[] }[] }[]>([]) + const [skills, setSkills] = useState([]) + + useEffect(() => { + if (!employeeId) { + setLoading(false) + return + } + load() + }, [employeeId]) + + const load = async () => { + if (!employeeId) return + setLoading(true) + setError('') + try { + // Load employee first + const data = await employeeApi.getById(employeeId) + setForm({ ...data, email: user?.email || data.email || '' }) + const mapped: SkillSelection[] = (data.skills || []).map((s: any) => { + const catStr = s.category || '' + const [catId, subId] = String(catStr).split('.') + return { + categoryId: catId || '', + subCategoryId: subId || '', + skillId: s.id, + name: s.name, + level: (s.level || '').toString() + } + }) + setSkills(mapped) + // Load hierarchy non-critically + try { + const hierRes = await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + '/skills/hierarchy', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }).then(r => r.json()) + if (hierRes?.success) setCatalog(hierRes.data || []) + } catch { + // ignore hierarchy errors; keep profile usable + } + } catch (e: any) { + setError('Profil konnte nicht geladen werden') + } finally { + setLoading(false) + } + } + + const handleSkillToggle = (categoryId: string, subCategoryId: string, skillId: string, skillName: string) => { + setSkills(prev => { + const list = [...prev] + const idx = list.findIndex(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId) + if (idx >= 0) { + list.splice(idx, 1) + } else { + list.push({ categoryId, subCategoryId, skillId, name: skillName, level: '' }) + } + return list + }) + } + + const handleSkillLevelChange = (categoryId: string, subCategoryId: string, skillId: string, level: string) => { + setSkills(prev => prev.map(s => (s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId) ? { ...s, level } : s)) + } + + const isSkillSelected = (categoryId: string, subCategoryId: string, skillId: string) => + skills.some(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId) + + const getSkillLevel = (categoryId: string, subCategoryId: string, skillId: string) => + (skills.find(s => s.categoryId === categoryId && s.subCategoryId === subCategoryId && s.skillId === skillId)?.level) || '' + + const onSave = async () => { + if (!employeeId || !form) return + setSaving(true) + setError('') + setSuccess('') + try { + const payload = { + firstName: form.firstName, + lastName: form.lastName, + position: form.position || 'Mitarbeiter', + department: form.department || '', + employeeNumber: form.employeeNumber || undefined, + email: user?.email || form.email, + phone: form.phone || '', + mobile: form.mobile || null, + office: form.office || null, + availability: form.availability || 'available', + skills: skills.map((s, i) => ({ id: s.skillId, name: s.name, category: s.categoryId, level: Number(s.level) || 3 })), + languages: form.languages || [], + specializations: form.specializations || [] + } + await fetch(((import.meta as any).env?.VITE_API_URL || 'http://localhost:3004/api') + `/employees/${employeeId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(payload) + }).then(async r => { + const d = await r.json() + if (!r.ok) throw new Error(d?.error?.message || 'Speichern fehlgeschlagen') + }) + setSuccess('Profil gespeichert') + await load() + } catch (e: any) { + setError(e.message || 'Speichern fehlgeschlagen') + } finally { + setSaving(false) + } + } + + if (!employeeId) { + return ( +
+

Mein Profil

+

Kein Mitarbeiterprofil mit Ihrem Nutzer verknüpft.

+
+ ) + } + + if (loading) { + return
Lade Profil...
+ } + if (!form) { + return ( +
+

Mein Profil

+
+ {error || 'Profil konnte nicht geladen werden'} +
+
+ ) + } + + return ( +
+

Mein Profil

+ + {error && (
{error}
)} + {success && (
{success}
)} + +
+
+

Foto

+ setForm((prev: any) => ({ ...prev, photo: url }))} /> +
+ +
+

Kontakt & Dienststelle

+
+
+ + +

Wird automatisch aus dem Login übernommen. Änderung ggf. im Admin Panel.

+
+
+ + setForm((p: any) => ({ ...p, position: e.target.value }))} placeholder="z. B. Sachbearbeiter; Führungskraft g. D.; Führungskraft h. D." /> +

Beispiele: Sachbearbeiter, Führungskraft g. D., Führungskraft h. D.

+
+
+ + setForm((p: any) => ({ ...p, employeeNumber: e.target.value }))} placeholder="z. B. NW068111" /> +

Ihre behördliche Kennung, z. B. NW068111.

+
+
+ + setForm((p: any) => ({ ...p, department: e.target.value }))} placeholder="z. B. Abteilung 4, Dezernat 42, Sachgebiet 42.1" /> +

Hierarchische Angabe (Abteilung, Dezernat, Sachgebiet).

+
+
+ + setForm((p: any) => ({ ...p, phone: e.target.value }))} placeholder="z. B. +49 30 12345-100" /> +

Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).

+
+
+ + setForm((p: any) => ({ ...p, mobile: e.target.value }))} placeholder="z. B. +49 171 1234567" /> +

Bitte Rufnummern im internationalen Format angeben (z. B. +49 ...).

+
+
+ + setForm((p: any) => ({ ...p, office: e.target.value }))} placeholder="z. B. Gebäude A, 3.OG, Raum 3.12" /> +

Angabe zum Standort, z. B. Gebäude, Etage und Raum.

+
+
+
+
+ +
+

Kompetenzen

+
+ {catalog.map(category => ( +
+

{category.name}

+ {category.subcategories.map(sub => ( +
+

{sub.name}

+
+ {sub.skills.map(skill => ( +
+ +
+ ))} +
+
+ ))} +
+ ))} +
+
+ +
+ +
+
+ ) +} diff --git a/frontend/src/views/ProfileEdit.tsx b/frontend/src/views/ProfileEdit.tsx new file mode 100644 index 0000000..248982b --- /dev/null +++ b/frontend/src/views/ProfileEdit.tsx @@ -0,0 +1,685 @@ +import React, { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { Save, X, Plus, Trash2, AlertCircle, Check } from 'lucide-react' +import { api } from '../services/api' +import { useAuthStore } from '../stores/authStore' + +interface ProfileForm { + 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: 'basic' | 'fluent' | 'native' | 'business' }[] + 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 +} + +const JOB_CATEGORIES = [ + 'Technik', + 'IT & Digitalisierung', + 'Verwaltung', + 'F&E', + 'Kommunikation & HR', + 'Produktion', + 'Sonstiges' +] + +const LANGUAGE_LEVELS = [ + { value: 'basic', label: 'Grundkenntnisse' }, + { value: 'business', label: 'Verhandlungssicher' }, + { value: 'fluent', label: 'Flie\u00dfend' }, + { value: 'native', label: 'Muttersprache' } +] + +export default function ProfileEdit() { + const { id } = useParams() + const navigate = useNavigate() + const { user } = useAuthStore() + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [showSuccess, setShowSuccess] = useState(false) + const [errors, setErrors] = useState([]) + const [suggestions, setSuggestions] = useState>({}) + const [activeInput, setActiveInput] = useState(null) + + const [formData, setFormData] = useState({ + name: '', + department: '', + location: '', + role: '', + contacts: { email: '', phone: '', teams: '' }, + domains: [], + tools: [], + methods: [], + industryKnowledge: [], + regulatory: [], + languages: [], + projects: [], + networks: [], + digitalSkills: [], + socialSkills: [], + jobCategory: undefined, + jobTitle: '', + jobDesc: '', + consentPublicProfile: false, + consentSearchable: false + }) + + useEffect(() => { + if (id && id !== 'new') { + loadProfile() + } + }, [id]) + + const loadProfile = async () => { + setLoading(true) + try { + const response = await api.get(`/profiles/${id}`) + if (response.data.success) { + setFormData(response.data.data) + } + } catch (error) { + console.error('Error loading profile:', error) + setErrors(['Profil konnte nicht geladen werden']) + } finally { + setLoading(false) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setErrors([]) + + // Validierung + if (!formData.name) { + setErrors(['Name ist erforderlich']) + return + } + + if (!formData.consentPublicProfile && !formData.consentSearchable) { + const confirmed = window.confirm( + 'Ihr Profil wird f\u00fcr andere nicht sichtbar sein. M\u00f6chten Sie fortfahren?' + ) + if (!confirmed) return + } + + setSaving(true) + try { + if (id === 'new') { + const response = await api.post('/profiles', formData) + if (response.data.success) { + setShowSuccess(true) + setTimeout(() => { + navigate(`/profile/${response.data.data.id}`) + }, 1500) + } + } else { + const response = await api.put(`/profiles/${id}`, formData) + if (response.data.success) { + setShowSuccess(true) + setTimeout(() => { + navigate(`/profile/${id}`) + }, 1500) + } + } + } catch (error: any) { + setErrors([error.response?.data?.error?.message || 'Fehler beim Speichern']) + } finally { + setSaving(false) + } + } + + const loadSuggestions = async (category: string, query: string) => { + try { + const response = await api.post('/profiles/tags/suggest', { + category, + query + }) + if (response.data.success) { + setSuggestions(prev => ({ + ...prev, + [category]: response.data.data.map((s: any) => s.value) + })) + } + } catch (error) { + console.error('Error loading suggestions:', error) + } + } + + const addToArray = (field: keyof ProfileForm, value: string) => { + const array = formData[field] as string[] + if (!array.includes(value)) { + setFormData(prev => ({ + ...prev, + [field]: [...array, value] + })) + } + } + + const removeFromArray = (field: keyof ProfileForm, index: number) => { + const array = formData[field] as string[] + setFormData(prev => ({ + ...prev, + [field]: array.filter((_, i) => i !== index) + })) + } + + const addLanguage = () => { + setFormData(prev => ({ + ...prev, + languages: [...prev.languages, { code: '', level: 'basic' }] + })) + } + + const updateLanguage = (index: number, field: 'code' | 'level', value: string) => { + setFormData(prev => ({ + ...prev, + languages: prev.languages.map((lang, i) => + i === index ? { ...lang, [field]: value } : lang + ) + })) + } + + const removeLanguage = (index: number) => { + setFormData(prev => ({ + ...prev, + languages: prev.languages.filter((_, i) => i !== index) + })) + } + + const addProject = () => { + setFormData(prev => ({ + ...prev, + projects: [...prev.projects, { title: '', role: '', summary: '', links: [] }] + })) + } + + const updateProject = (index: number, field: string, value: any) => { + setFormData(prev => ({ + ...prev, + projects: prev.projects.map((proj, i) => + i === index ? { ...proj, [field]: value } : proj + ) + })) + } + + const removeProject = (index: number) => { + setFormData(prev => ({ + ...prev, + projects: prev.projects.filter((_, i) => i !== index) + })) + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+

+ {id === 'new' ? 'Neues Profil erstellen' : 'Profil bearbeiten'} +

+ + {errors.length > 0 && ( +
+ +
+ {errors.map((error, i) => ( +

{error}

+ ))} +
+
+ )} + + {showSuccess && ( +
+ +

Profil erfolgreich gespeichert!

+
+ )} +
+ + {/* Basisdaten */} +
+

+ Basisdaten +

+ +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + required + /> +
+ +
+ + setFormData(prev => ({ ...prev, role: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ + setFormData(prev => ({ ...prev, department: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ + setFormData(prev => ({ ...prev, location: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+
+ + {/* Kontaktdaten */} +
+

+ Kontaktdaten +

+ +
+
+ + setFormData(prev => ({ + ...prev, + contacts: { ...prev.contacts, email: e.target.value } + }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ + setFormData(prev => ({ + ...prev, + contacts: { ...prev.contacts, phone: e.target.value } + }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ + setFormData(prev => ({ + ...prev, + contacts: { ...prev.contacts, teams: e.target.value } + }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+
+ + {/* Berufsbilder */} +
+

+ Berufsbild +

+ +
+
+ + +
+ +
+ + setFormData(prev => ({ ...prev, jobTitle: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ +