Datenbank bereinigt / Gitea-Integration gefixt
Dieser Commit ist enthalten in:
committet von
Server Deploy
Ursprung
395598c2b0
Commit
c21be47428
@ -23,7 +23,32 @@
|
|||||||
"Bash(timeout /t 5 /nobreak)",
|
"Bash(timeout /t 5 /nobreak)",
|
||||||
"Bash(start chrome:*)",
|
"Bash(start chrome:*)",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"Bash(wc:*)"
|
"Bash(wc:*)",
|
||||||
|
"Bash(sqlite3:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(docker restart:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(docker build:*)",
|
||||||
|
"Bash(docker stop:*)",
|
||||||
|
"Bash(docker rm:*)",
|
||||||
|
"Bash(docker run:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(rg:*)",
|
||||||
|
"Bash(cp:*)",
|
||||||
|
"Bash(rm:*)",
|
||||||
|
"Bash(docker start:*)",
|
||||||
|
"Bash(apt list:*)",
|
||||||
|
"Bash(sudo apt:*)",
|
||||||
|
"Bash(sudo apt install:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(docker inspect:*)",
|
||||||
|
"Bash(touch:*)",
|
||||||
|
"Bash(docker port:*)",
|
||||||
|
"Bash(docker-compose down:*)",
|
||||||
|
"Bash(__NEW_LINE__ cp /app/data/taskmate.db.backup-20260103-201322 /app/data/taskmate.db)",
|
||||||
|
"Bash(docker system prune:*)",
|
||||||
|
"Bash(docker cp:*)",
|
||||||
|
"Bash(mv:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,7 +52,7 @@ ADMINISTRATOREN
|
|||||||
|
|
||||||
Standard-Admin-Zugangsdaten:
|
Standard-Admin-Zugangsdaten:
|
||||||
- Benutzername: admin
|
- Benutzername: admin
|
||||||
- Passwort: Kx9#mP2$vL7@nQ4!wR
|
- Passwort: [Vom Administrator gesetzt]
|
||||||
|
|
||||||
Nach der Anmeldung als regulärer Benutzer sehen Sie das Kanban-Board.
|
Nach der Anmeldung als regulärer Benutzer sehen Sie das Kanban-Board.
|
||||||
|
|
||||||
|
|||||||
596
CHANGELOG.txt
596
CHANGELOG.txt
@ -1,6 +1,602 @@
|
|||||||
TASKMATE - CHANGELOG
|
TASKMATE - CHANGELOG
|
||||||
====================
|
====================
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
03.01.2025 - GIT-INTEGRATION CODING-KACHELN
|
||||||
|
================================================================================
|
||||||
|
✅ Git-Repository-Erkennung für TaskMate-Kachel repariert
|
||||||
|
✅ Docker-Pfad-Mapping: /home/claude-dev/TaskMate → /app/taskmate-source
|
||||||
|
✅ Git-Status und Commit-Funktionen funktionieren wieder
|
||||||
|
✅ Debug-Logging für Git-Operationen hinzugefügt
|
||||||
|
|
||||||
|
Technische Details:
|
||||||
|
- windowsToContainerPath() Funktion erweitert
|
||||||
|
- Spezialfall für TaskMate-Repository implementiert
|
||||||
|
- Container-Volume-Mapping berücksichtigt
|
||||||
|
- Cache-Version auf 189 erhöht
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
03.01.2025 - SICHERHEITSVERBESSERUNGEN PHASE 1
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
SCHRITT 1: HARTCODIERTE CREDENTIALS ENTFERNT
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Hartcodierte Credentials aus CLAUDE.md entfernt
|
||||||
|
- Admin-Passwort aus ANWENDUNGSBESCHREIBUNG.txt entfernt
|
||||||
|
- Gitea-Token nicht mehr im Klartext in Dokumentation
|
||||||
|
- JWT_SECRET Mindestlänge von 32 Zeichen erzwungen
|
||||||
|
- Fallback für unsicheres JWT_SECRET entfernt
|
||||||
|
|
||||||
|
SCHRITT 2: TOKEN-ROTATION & REFRESH-TOKENS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Refresh-Token System implementiert (7 Tage Gültigkeit)
|
||||||
|
- Access-Tokens haben nur noch 15 Minuten Gültigkeit
|
||||||
|
- Neue Datenbank-Tabelle refresh_tokens
|
||||||
|
- Automatische Bereinigung abgelaufener Tokens
|
||||||
|
- Logout widerruft alle Refresh-Tokens des Benutzers
|
||||||
|
|
||||||
|
TECHNISCHE ÄNDERUNGEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- dotenv Package zum Backend hinzugefügt
|
||||||
|
- server.js lädt nun .env Datei beim Start
|
||||||
|
- Dockerfile angepasst (npm install statt npm ci)
|
||||||
|
- auth.js erweitert um Refresh-Token Funktionen
|
||||||
|
- Frontend API-Client unterstützt Refresh-Tokens
|
||||||
|
- Service Worker Version: 181 → 182
|
||||||
|
|
||||||
|
SICHERHEITSVERBESSERUNGEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Kürzere Token-Lebensdauer reduziert Angriffsfenster
|
||||||
|
- Refresh-Tokens ermöglichen sichere lange Sessions
|
||||||
|
- Token-Rotation bei jedem Refresh
|
||||||
|
- IP und User-Agent werden geloggt
|
||||||
|
|
||||||
|
AUTOMATISCHES TOKEN-REFRESH IMPLEMENTIERT
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Proaktiver Token-Refresh nach 10 Minuten (bevor 15min Limit erreicht)
|
||||||
|
- Automatischer Fallback-Refresh bei 401-Fehlern
|
||||||
|
- Benutzer bleiben 7 Tage eingeloggt ohne Unterbrechung
|
||||||
|
- Nahtlose Erneuerung im Hintergrund - keine Logout-Unterbrechungen
|
||||||
|
|
||||||
|
API-ÄNDERUNGEN (Rückwärtskompatibel)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- POST /api/auth/login gibt zusätzlich refreshToken zurück
|
||||||
|
- POST /api/auth/refresh akzeptiert refreshToken im Body
|
||||||
|
- Legacy-Support für alte Clients ohne Breaking Changes
|
||||||
|
|
||||||
|
SCHRITT 3: XSS-SCHUTZ & EINGABEVALIDIERUNG VERSTÄRKT
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Erweiterte Content Security Policy (CSP) implementiert
|
||||||
|
- DOMPurify für doppelte Markdown-Bereinigung hinzugefügt
|
||||||
|
- Strikte File-Upload Validierung gegen gefährliche Dateien
|
||||||
|
- URL-Validierung gegen SSRF und JavaScript-Injection
|
||||||
|
- Automatisches Input-Sanitizing für alle API-Requests
|
||||||
|
- Zusätzliche Security Headers (HSTS, Referrer Policy, etc.)
|
||||||
|
|
||||||
|
NEUE SICHERHEITS-FEATURES
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Executable Dateien (.exe, .bat, etc.) werden komplett blockiert
|
||||||
|
- Doppelte Dateiendungen (.txt.exe) werden abgelehnt
|
||||||
|
- Lokale URLs (localhost, 192.168.x.x) sind nicht erlaubt (SSRF-Schutz)
|
||||||
|
- Gefährliche Dateinamen mit Pfad-Traversal werden blockiert
|
||||||
|
- MIME-Type Validierung gegen Spoofing-Angriffe
|
||||||
|
|
||||||
|
SECURITY HEADERS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Content-Security-Policy mit strict-dynamic
|
||||||
|
- HTTP Strict Transport Security (HSTS)
|
||||||
|
- X-Content-Type-Options: nosniff
|
||||||
|
- Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
- Permissions-Policy: Kamera/Mikrofon deaktiviert
|
||||||
|
|
||||||
|
FRONTEND-VERBESSERUNGEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Automatisches Token-Management im API-Client
|
||||||
|
- Retry-Logic für abgelaufene Tokens
|
||||||
|
- Service Worker Version: 183 → 184
|
||||||
|
|
||||||
|
PHASE 2: DATENBANK UND BACKUP-VERSCHLÜSSELUNG IMPLEMENTIERT
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Vollständige Backup-Verschlüsselung mit AES-256-CBC implementiert
|
||||||
|
- Neue encryption.js Bibliothek für sichere Verschlüsselung
|
||||||
|
- Automatische verschlüsselte Backups (.enc Dateien)
|
||||||
|
- 256-bit Verschlüsselungsschlüssel über Umgebungsvariablen
|
||||||
|
- Kompatible Backups: sowohl verschlüsselt als auch unverschlüsselt
|
||||||
|
- Sichere Wiederherstellung mit Entschlüsselung
|
||||||
|
- PBKDF2 Key-Derivation für passwort-basierte Verschlüsselung
|
||||||
|
|
||||||
|
NEUE VERSCHLÜSSELUNGS-FEATURES
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Header-basiertes Dateiformat für Versionierung (TMENC001)
|
||||||
|
- Salt und IV für jede Verschlüsselung einzigartig
|
||||||
|
- Automatisches Fallback bei fehlgeschlagener Verschlüsselung
|
||||||
|
- Admin-Endpunkte für manuelle Backup-Erstellung (/api/admin/backup)
|
||||||
|
- Backup-Liste mit verschlüsselten Dateien anzeigen
|
||||||
|
|
||||||
|
DOCKER UND INFRASTRUKTUR
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Docker-Container mit Verschlüsselungsunterstützung neu gebaut
|
||||||
|
- ENCRYPTION_KEY über docker-compose.yml Umgebungsvariablen
|
||||||
|
- Korrekte Portmapping (3001 extern → 3000 intern)
|
||||||
|
- Automatische Backup-Erstellung beim Server-Start getestet
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
03.01.2025 - CLAUDE.MD NEUSTRUKTURIERUNG & DATENSCHUTZ
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
DOKUMENTATION
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CLAUDE.md komplett neu strukturiert für bessere Entwickler-Erfahrung.
|
||||||
|
|
||||||
|
WICHTIGER HINWEIS FÜR KI-ASSISTENTEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Prominenter Hinweis: Anwender hat KEINE Programmierkenntnisse
|
||||||
|
- Klare Anweisung: KI übernimmt ALLE technischen Aufgaben
|
||||||
|
- Kommunikations-Regeln mit Richtig/Falsch Beispielen
|
||||||
|
- Arbeitsweise-Sektion für nicht-technische Anwender
|
||||||
|
|
||||||
|
NEUE STRUKTUR
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Quick Start Sektion mit wichtigsten Befehlen ganz oben
|
||||||
|
- Kritische Regeln prominent am Anfang platziert
|
||||||
|
- Klare Gliederung nach typischen Entwicklungsaufgaben
|
||||||
|
- Erweiterte Troubleshooting-Sektion mit Lösungen
|
||||||
|
- Code-Patterns und Best Practices hinzugefügt
|
||||||
|
- Performance- und Sicherheitshinweise dokumentiert
|
||||||
|
|
||||||
|
DATENSCHUTZ & PROJEKTSICHERHEIT
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Neue Sektion für Schutz von Produktivdaten hinzugefügt
|
||||||
|
- Warnung: Projekt "AegisSight" niemals beeinträchtigen
|
||||||
|
- Warnung: Bestehende Benutzer niemals ändern/löschen
|
||||||
|
- Backup-Anweisung vor Datenbank-Arbeiten
|
||||||
|
- Rollback-Strategie für Live-System dokumentiert
|
||||||
|
- Anforderung: JEDE Änderung muss umkehrbar sein
|
||||||
|
- Docker-Image Backup-Befehle hinzugefügt
|
||||||
|
- Änderungs-Workflow für Live-Betrieb definiert
|
||||||
|
|
||||||
|
HIGHLIGHTS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Docker-Befehle direkt im Quick Start
|
||||||
|
- Echtzeit-Update Patterns mit Code-Beispielen
|
||||||
|
- Datums-Formatierung mit richtig/falsch Beispielen
|
||||||
|
- Deployment-Checkliste als Copy&Paste Template
|
||||||
|
- Debug-Tipps für Frontend und Backend
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
03.01.2026 - LISTE: MEHRERE AVATARE FÜR MEHRFACHZUWEISUNG
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
FEATURE ENHANCEMENT
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Listen-Ansicht zeigt jetzt alle zugewiesenen Benutzer als separate Avatare an.
|
||||||
|
|
||||||
|
NEUE FUNKTIONEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Mehrere Avatar-Symbole nebeneinander bei Mehrfachzuweisung
|
||||||
|
- Avatare werden aus task_assignees Tabelle und assignedTo kombiniert
|
||||||
|
- Container für mehrere Avatare mit 2px Abstand
|
||||||
|
- Hover-Effekt: Avatare vergrößern sich bei Mouse-Over
|
||||||
|
- Alle Avatare sind klickbar für Bearbeitung
|
||||||
|
|
||||||
|
TECHNISCHE DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- JavaScript: Sammelt User-IDs aus task.assignees Array
|
||||||
|
- CSS: .avatar-container für Flexbox-Layout mehrerer Avatare
|
||||||
|
- Backend nutzt bereits vorhandene getFullTask() Funktion
|
||||||
|
- Service Worker Cache-Version: 178 -> 179
|
||||||
|
|
||||||
|
VERHALTEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Task mit User 1 + 4: Zeigt 2 Avatare nebeneinander
|
||||||
|
- Task mit nur User 1: Zeigt 1 Avatar
|
||||||
|
- Task ohne Zuweisung: Zeigt "?" Placeholder
|
||||||
|
- Klick auf beliebigen Avatar: Öffnet Bearbeitung-Dropdown
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
03.01.2026 - LISTE: NUR AVATAR-SYMBOLE BEI ZUGEWIESEN
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
UX-VERBESSERUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
In der Listen-Ansicht werden bei der Spalte "Zugewiesen" nur noch Symbole angezeigt.
|
||||||
|
|
||||||
|
ÄNDERUNGEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Benutzernamen werden nicht mehr neben Avataren angezeigt
|
||||||
|
- Nur noch farbige Avatar-Symbole mit Initialen sichtbar
|
||||||
|
- Tooltip zeigt Namen beim Hover über Avatar
|
||||||
|
- Platzhalter "?" für nicht zugewiesene Aufgaben
|
||||||
|
- Klick auf Avatar öffnet Dropdown zur Bearbeitung
|
||||||
|
|
||||||
|
TECHNISCHE DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- list.js: Dropdown standardmäßig versteckt (display: none)
|
||||||
|
- CSS: Neue Klassen für avatar-empty und editing-Modus
|
||||||
|
- JavaScript: Avatar-Click-Handler für Bearbeitung
|
||||||
|
- Service Worker Cache-Version: 177 -> 178
|
||||||
|
|
||||||
|
BEDIENUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
1. In Listen-Ansicht sind nur Avatar-Symbole sichtbar
|
||||||
|
2. Hover zeigt Namen als Tooltip
|
||||||
|
3. Klick auf Avatar öffnet Benutzer-Dropdown
|
||||||
|
4. Auswahl ändert Zuweisung und versteckt Dropdown wieder
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
03.01.2026 - BACKUP MIT AEGISSIGHT-PROJEKT ERSTELLT
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
BACKUP-WIEDERHERSTELLUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Erfolgreiches Backup mit allen wiederhergestellten AegisSight-Daten erstellt.
|
||||||
|
|
||||||
|
BACKUP-DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Datei: backup_2026-01-03T00-38-47-492Z.db
|
||||||
|
- Inhalt: AegisSight-Projekt mit 22 Aufgaben
|
||||||
|
- Benutzer: 3 (HG, MH, admin)
|
||||||
|
- Status: Vollständig und verifiziert
|
||||||
|
|
||||||
|
TECHNICAL
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Docker-Container mit korrekten Volume-Mounts neu gestartet
|
||||||
|
- Datenbank-Paths korrekt gemappt: /home/claude-dev/TaskMate/data → /app/data
|
||||||
|
- WAL-Dateien korrekt synchronisiert
|
||||||
|
|
||||||
|
ERGEBNIS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Alle AegisSight-Projektdaten sind wiederhergestellt und gesichert.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - ADMIN: PASSWORT-BEARBEITUNG IMPLEMENTIERT
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
NEUE FUNKTION
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Admins können jetzt Benutzer-Passwörter im Admin-Bereich bearbeiten.
|
||||||
|
|
||||||
|
FUNKTIONEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Passwort-Bearbeitung: Klick auf Stift-Symbol aktiviert Bearbeitungsmodus
|
||||||
|
- Passwort-Generator: Klick auf Refresh-Symbol generiert starkes Passwort
|
||||||
|
- Beim Bearbeiten von Benutzern: Passwort optional ändern oder leer lassen
|
||||||
|
- Automatische Validierung: Mindestens 8 Zeichen erforderlich
|
||||||
|
|
||||||
|
TECHNISCHE DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- HTML: Neue Button-Gruppe für Passwort-Eingabe hinzugefügt
|
||||||
|
- CSS: Styling für password-input-group implementiert
|
||||||
|
- JavaScript: togglePasswordEdit() und generatePassword() Methoden
|
||||||
|
- Backend: Nutzt vorhandene PUT /api/admin/users/:id Route
|
||||||
|
- Service Worker Cache-Version: 176 -> 177
|
||||||
|
|
||||||
|
BEDIENUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
1. Benutzer im Admin-Bereich bearbeiten
|
||||||
|
2. Stift-Symbol bei Passwort klicken → Eingabefeld wird bearbeitbar
|
||||||
|
3. Neues Passwort eingeben ODER Generator-Button für zufälliges Passwort
|
||||||
|
4. Formular speichern → Passwort wird sofort aktualisiert
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - DATENBANK WIEDERHERGESTELLT
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
KRITISCHER BUGFIX
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Datenbank-Verlust durch Container-Neustart behoben - alle Daten wiederhergestellt.
|
||||||
|
|
||||||
|
PROBLEM
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Beim Docker-Container Neustart wurde eine neue, leere Datenbank erstellt
|
||||||
|
- Alle Benutzer, Aufgaben, Board-Einträge und Einstellungen waren verloren
|
||||||
|
- Nur Standard-Benutzer (HG, MH) vorhanden
|
||||||
|
|
||||||
|
LÖSUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Backup vom 02.01.2026 23:46 Uhr wiederhergestellt
|
||||||
|
- Originale Benutzerdaten und Inhalte sind wieder verfügbar
|
||||||
|
- Login mit ursprünglichen Benutzerkonten funktioniert wieder
|
||||||
|
|
||||||
|
ERGEBNIS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Alle Daten sind wieder da - Login mit ursprünglichen Credentials möglich.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - BUGFIX: LOGIN-FEHLER BEHOBEN
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
BUGFIX
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Login-Problem behoben: NotificationManager-Fehler beim Login korrigiert.
|
||||||
|
|
||||||
|
TECHNISCHE DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- notifications.js: Sicherheitscheck für this.badge hinzugefügt
|
||||||
|
- Verhindert "Cannot read properties of undefined (reading 'classList')" Fehler
|
||||||
|
- Service Worker Cache-Version: 175 -> 176
|
||||||
|
|
||||||
|
AUSWIRKUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Login funktioniert wieder korrekt ohne JavaScript-Fehler.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CODING-TAB: GITEA INTEGRATION CACHE-FIX
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
BUGFIX
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Browser-Cache Problem behoben: Gitea Repository-Dropdown zeigt wieder Repos an.
|
||||||
|
|
||||||
|
TECHNISCHE DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Service Worker Cache-Version: 170 -> 175 (aggressiver Cache-Bust)
|
||||||
|
- Docker Container komplett neu gebaut und gestartet
|
||||||
|
- getGiteaRepositories() API-Fix wird jetzt geladen
|
||||||
|
|
||||||
|
ERGEBNIS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Repository-Dropdown in Coding-Anwendungen funktioniert wieder korrekt.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CODING-TAB: CLAUDE.MD ALS POPUP MODAL
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
NEUE FUNKTION
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CLAUDE.md wird jetzt in einem separaten Vollbild-Modal angezeigt:
|
||||||
|
|
||||||
|
VERBESSERUNGEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Klickbarer Link statt kleine Box
|
||||||
|
- Vollbild-Modal mit 70% Viewport-Höhe
|
||||||
|
- Zeigt Dateigröße im Link (z.B. "7KB")
|
||||||
|
- Bessere Lesbarkeit für längere Dokumentation
|
||||||
|
- ESC-Taste zum Schließen
|
||||||
|
- Service Worker Cache-Version: 168 -> 169
|
||||||
|
|
||||||
|
BEDIENUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
1. Klick auf "CLAUDE.md anzeigen (XKB)" öffnet Modal
|
||||||
|
2. ESC oder X schließt Modal
|
||||||
|
3. Klick außerhalb schließt Modal
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CODING-TAB: CLAUDE.MD ANZEIGE FINAL BEHOBEN
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
BUGFIX
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Problem mit unsichtbarer CLAUDE.md endgültig gelöst:
|
||||||
|
|
||||||
|
BEHOBENE PROBLEME
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Backend: Fallback-Pfad für TaskMate (/app/taskmate-source) implementiert
|
||||||
|
- CSS: claude-content war standardmäßig versteckt (display: none)
|
||||||
|
- HTML: Überflüssige Hinweistexte entfernt
|
||||||
|
- Service Worker Cache-Version: 167 -> 168
|
||||||
|
|
||||||
|
ERGEBNIS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CLAUDE.md wird jetzt korrekt angezeigt mit "Test für TaskMate" am Ende.
|
||||||
|
Nur-Lesen-Modus funktioniert wie gewünscht.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CODING-TAB: UX VERBESSERUNGEN
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
UX-VERBESSERUNGEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Kacheln sind jetzt direkt klickbar (ohne Drei-Punkte-Menü)
|
||||||
|
- Drei-Punkte-Menü entfernt - weniger Verwirrung
|
||||||
|
- Cursor zeigt Klickbarkeit an
|
||||||
|
- CLAUDE.md Badge korrigiert - zeigt jetzt wieder CLAUDE.md an
|
||||||
|
- Service Worker Cache-Version: 164 -> 165
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CODING-TAB: CLAUDE.MD NUR NOCH READONLY
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
ÄNDERUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
CLAUDE.md im Coding-Bereich ist jetzt nur noch lesbar (readonly).
|
||||||
|
|
||||||
|
DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Bearbeiten-Tab entfernt - nur noch Ansicht verfügbar
|
||||||
|
- CLAUDE.md kann nicht mehr über TaskMate bearbeitet werden
|
||||||
|
- Zeigt immer die aktuellen Inhalte aus dem Dateisystem
|
||||||
|
- Verhindert versehentliches Überschreiben wichtiger Projektanweisungen
|
||||||
|
- Service Worker Cache-Version: 163 -> 164
|
||||||
|
|
||||||
|
BEGRÜNDUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Sicherheit: Keine Schreibrechte-Konflikte mehr
|
||||||
|
- Klarheit: CLAUDE.md sollte außerhalb von TaskMate gepflegt werden
|
||||||
|
- Konsistenz: Immer aktuelle Inhalte aus dem Dateisystem
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CODING-TAB: CLAUDE.MD ANZEIGE BEHOBEN
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
BUGFIX
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Problem behoben: CLAUDE.md wird im Coding-Bereich jetzt korrekt angezeigt.
|
||||||
|
|
||||||
|
TECHNISCHE DETAILS
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Backend PUT/POST-Routes erweitert: claudeMdFromDisk in Antworten
|
||||||
|
- CLAUDE.md wird nach dem Speichern aus dem Dateisystem gelesen
|
||||||
|
- Service Worker Cache-Version: 162 -> 163
|
||||||
|
|
||||||
|
AUSWIRKUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Im Coding-Bereich werden alle bisherigen Änderungen der CLAUDE.md nun
|
||||||
|
korrekt im Editor-Fenster angezeigt.
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CLAUDE.MD DOKUMENTATION ERWEITERT
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
DOKUMENTATION
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Erweiterte CLAUDE.md mit hilfreichen Informationen für effizientere Entwicklung:
|
||||||
|
|
||||||
|
NEUE ABSCHNITTE
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Projektübersicht: Kurzbeschreibung und Hauptfunktionen
|
||||||
|
- Architektur-Kurzübersicht: Technologie-Stack auf einen Blick
|
||||||
|
- Wichtige Dateien & Einstiegspunkte: Zentrale Dateien für schnellen Einstieg
|
||||||
|
- Häufige Entwicklungsaufgaben: Schritt-für-Schritt Anleitungen
|
||||||
|
- Testing & Debugging: Logs, häufige Probleme und Lösungen
|
||||||
|
- Deployment-Checkliste: Strukturierte Schritte für sicheres Deployment
|
||||||
|
|
||||||
|
ZWECK
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Schnelleres Verständnis der Projektstruktur
|
||||||
|
- Effizientere Entwicklung durch klare Anleitungen
|
||||||
|
- Weniger Fehler durch dokumentierte Best Practices
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
02.01.2026 - CODING-TAB IMPLEMENTIERUNG
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
NEUE FEATURES
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Neuer "Coding"-Tab ersetzt den bisherigen "Gitea"-Tab mit erweiterter
|
||||||
|
Funktionalitaet zur Verwaltung von Entwicklungsverzeichnissen.
|
||||||
|
|
||||||
|
CODING-TAB
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Projektubergreifende Verwaltung von Entwicklungsverzeichnissen
|
||||||
|
- Kachel-basiertes Grid-Layout
|
||||||
|
- Claude Code Button (orange) - Startet Claude Code im Verzeichnis
|
||||||
|
- Codex Button (gruen) - Startet OpenAI Codex im Verzeichnis
|
||||||
|
- Server-Pfade: Direkte Ausfuehrung auf dem Linux-Server
|
||||||
|
- Windows-Pfade: Befehl zum manuellen Kopieren fuer WSL
|
||||||
|
- Optionale Gitea-Repository-Verknuepfung pro Verzeichnis
|
||||||
|
- Git-Operationen (Fetch, Pull, Push, Commit) bei Verknuepfung
|
||||||
|
- Auto-Refresh des Git-Status alle 30 Sekunden
|
||||||
|
- Farbauswahl pro Verzeichnis
|
||||||
|
|
||||||
|
BACKEND
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Neue Datenbank-Tabelle: coding_directories
|
||||||
|
- Neue Route: /api/coding mit 12 Endpunkten
|
||||||
|
- Terminal-Start-Logik fuer Claude/Codex
|
||||||
|
|
||||||
|
FRONTEND
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Neuer Manager: coding.js
|
||||||
|
- Neues Styling: coding.css
|
||||||
|
- Modals: Verzeichnis-Verwaltung, Befehl-Anzeige
|
||||||
|
- Service Worker Cache-Version: 154 -> 155
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
31.12.2025 - MOBILE OPTIMIERUNG
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
NEUE FEATURES
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Vollstaendige Mobile-Optimierung der Anwendung mit Touch-Unterstuetzung.
|
||||||
|
|
||||||
|
HAMBURGER-MENU
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Slide-in Navigation von links
|
||||||
|
- Projekt-Auswahl im Menu
|
||||||
|
- Alle Views ueber Menu erreichbar
|
||||||
|
- Benutzer-Info und Logout
|
||||||
|
- Smooth Animation (Hamburger zu X)
|
||||||
|
|
||||||
|
SWIPE-GESTEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Horizontal wischen zum View-Wechsel
|
||||||
|
- Swipe rechts: vorheriger View
|
||||||
|
- Swipe links: naechster View
|
||||||
|
- Visuelle Indikatoren am Bildschirmrand
|
||||||
|
|
||||||
|
TOUCH DRAG & DROP
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Long-Press (300ms) startet Task-Drag
|
||||||
|
- Visuelles Feedback beim Ziehen
|
||||||
|
- Auto-Scroll am Bildschirmrand
|
||||||
|
- Drop-Zonen werden hervorgehoben
|
||||||
|
|
||||||
|
BOARD-ANSICHT
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Horizontal Scroll mit Scroll-Snap
|
||||||
|
- Spalten snappen am Viewport
|
||||||
|
|
||||||
|
BETROFFENE DATEIEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- frontend/css/mobile.css (NEU)
|
||||||
|
- frontend/js/mobile.js (NEU)
|
||||||
|
- frontend/index.html: Hamburger-Button, Mobile-Menu, Swipe-Indikatoren
|
||||||
|
- frontend/js/app.js: Mobile-Modul Integration
|
||||||
|
- frontend/sw.js: Cache-Version auf 154
|
||||||
|
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
30.12.2025 - BUGFIX: HTML-Entity-Encoding in Textfeldern
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
PROBLEM
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Sonderzeichen wie "&" wurden beim Speichern zu "&" konvertiert.
|
||||||
|
Beispiel: "Claude&Codex" wurde zu "Claude&Codex" gespeichert.
|
||||||
|
|
||||||
|
URSACHE
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Die sanitize-html Bibliothek encoded HTML-Entities auch wenn alle Tags
|
||||||
|
entfernt werden (allowedTags: []). Dies führte zu unerwünschter Konvertierung
|
||||||
|
von & zu &, < zu <, etc.
|
||||||
|
|
||||||
|
LÖSUNG
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Neue Funktion decodeHtmlEntities() in validation.js
|
||||||
|
- stripHtml() dekodiert nun Entities nach der Bereinigung
|
||||||
|
- Ampersand (&), Klammern (<>), Anführungszeichen bleiben erhalten
|
||||||
|
|
||||||
|
BETROFFENE DATEIEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- backend/middleware/validation.js: decodeHtmlEntities() hinzugefügt
|
||||||
|
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
30.12.2025 - ADMINBEREICH: Dateiendungen frei definierbar
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
NEUES FEATURE
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Vereinfachtes System zur Verwaltung erlaubter Dateiendungen im Adminbereich.
|
||||||
|
Das bisherige komplexe Kategorien-System (Bilder, Dokumente, Office, etc.)
|
||||||
|
wurde durch eine einfache, flexible Dateiendungs-Verwaltung ersetzt.
|
||||||
|
|
||||||
|
FUNKTIONSWEISE
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- Standard-Endungen: pdf, docx, txt (können geändert werden)
|
||||||
|
- Tags-System: Aktive Endungen werden als Tags mit ×-Button angezeigt
|
||||||
|
- Freifeld: Beliebige Endungen manuell hinzufügen (z.B. xlsx, png, zip)
|
||||||
|
- Vorschläge: Schnellauswahl häufiger Endungen per Klick
|
||||||
|
- Validierung: Backend prüft Dateiendung UND MIME-Type
|
||||||
|
|
||||||
|
VORGESCHLAGENE ENDUNGEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
Office: xlsx, pptx, doc, xls, ppt, odt, ods, rtf
|
||||||
|
Bilder: png, jpg, gif, svg, webp
|
||||||
|
Daten: csv, json, xml, md
|
||||||
|
Archive: zip, rar, 7z
|
||||||
|
|
||||||
|
BETROFFENE DATEIEN
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
- backend/routes/admin.js: Neues Format (allowedExtensions statt allowedTypes)
|
||||||
|
- backend/middleware/upload.js: Extension-basierte Validierung, MIME-Mapping
|
||||||
|
- frontend/index.html: Neues UI mit Tags, Input, Vorschläge
|
||||||
|
- frontend/js/admin.js: Neue Render- und Event-Logik
|
||||||
|
- frontend/css/admin.css: Styles für Extension-Tags und Vorschläge
|
||||||
|
- frontend/sw.js: Cache-Version auf 153 erhöht
|
||||||
|
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
30.12.2025 - BUGFIX: Login-Problem behoben (Sofort-Logout nach Login)
|
30.12.2025 - BUGFIX: Login-Problem behoben (Sofort-Logout nach Login)
|
||||||
================================================================================
|
================================================================================
|
||||||
|
|||||||
496
CLAUDE.md
496
CLAUDE.md
@ -1,60 +1,464 @@
|
|||||||
# TaskMate - Projektanweisungen
|
# TaskMate - Entwicklerdokumentation
|
||||||
|
|
||||||
## Infrastruktur/Server
|
## ⚠️ WICHTIGER HINWEIS FÜR KI-ASSISTENTEN
|
||||||
- **Docker-Container**: `taskmate` (hauptsächlich Backend), läuft auf Port 3001 intern → 3000 im Container
|
Der Anwender hat **KEINE Programmierkenntnisse**. Das bedeutet:
|
||||||
- **Frontend-Domain**: https://taskmate.aegis-sight.de
|
- **DU übernimmst ALLE technischen Aufgaben vollständig**
|
||||||
- **Gitea-Repository**: https://gitea-undso.aegis-sight.de/AegisSight/TaskMate
|
- **Erkläre in einfachen Worten**, was du tust und warum
|
||||||
- **Gitea-Token**: `7c62fea51bfe0506a25131bd50ac710ac5aa7e3a9dca37a962e7822bdc7db840`
|
- **Frage NIEMALS nach technischen Details** oder Code-Schnipseln
|
||||||
- **Projektverzeichnis auf Server**: `/home/claude-dev/TaskMate`
|
- **Führe ALLE Schritte selbstständig aus**
|
||||||
|
- Der Anwender kann nur bestätigen/ablehnen, nicht selbst coden
|
||||||
|
|
||||||
## Allgemein
|
### Kommunikations-Regeln
|
||||||
- Sprache: Deutsch für Benutzer-Kommunikation
|
✅ **RICHTIG**: "Ich werde jetzt die Benutzeroberfläche anpassen, damit..."
|
||||||
- Änderungen immer in CHANGELOG.txt dokumentieren nach bisher bekanntem Schema in der Datei
|
❌ **FALSCH**: "Kannst du mir den Code aus Zeile 42 zeigen?"
|
||||||
- Beim Start ANWENDUNGSBESCHREIBUNG.txt lesen
|
|
||||||
- Cache-Version in frontend/sw.js erhöhen nach Änderungen
|
|
||||||
- Ich bin kein Mensch mit Fachwissen im Bereich Coding, daher musst du sämtliche Aufgaben in der Regel übernehmen.
|
|
||||||
|
|
||||||
## Technologie
|
✅ **RICHTIG**: "Ich starte jetzt den Server neu. Das dauert etwa 30 Sekunden."
|
||||||
- Frontend: Vanilla JavaScript (kein Framework)
|
❌ **FALSCH**: "Führe bitte folgenden Befehl aus: docker restart..."
|
||||||
- Backend: Node.js mit Express
|
|
||||||
- Datenbank: SQLite
|
|
||||||
|
|
||||||
## Konventionen
|
## 🚀 Quick Start
|
||||||
- CSS-Variablen in frontend/css/variables.css
|
|
||||||
- Deutsche Umlaute (ä, ö, ü) in Texten verwenden
|
|
||||||
|
|
||||||
## Datumsformatierung (WICHTIG)
|
### Wichtigste Befehle
|
||||||
- NIEMALS `toISOString()` für Datumsvergleiche oder -anzeigen verwenden!
|
```bash
|
||||||
- `toISOString()` konvertiert in UTC und verursacht Zeitzonenverschiebungen (z.B. 28.12. wird zu 27.12.)
|
# Docker Container neu starten (nach Backend-Änderungen)
|
||||||
- Stattdessen lokale Formatierung verwenden:
|
docker restart taskmate
|
||||||
|
|
||||||
|
# Container neu bauen (bei Dependency-Änderungen)
|
||||||
|
docker build -t taskmate . && docker restart taskmate
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
docker logs taskmate -f
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kritische Regeln - NIEMALS VERGESSEN! ⚠️
|
||||||
|
1. **Cache-Version erhöhen** nach Frontend-Änderungen: `frontend/sw.js` → `CACHE_VERSION`
|
||||||
|
2. **CHANGELOG.txt** bei JEDER Änderung aktualisieren
|
||||||
|
3. **Keine `toISOString()`** für Datums-Operationen (UTC-Problem!)
|
||||||
|
4. **Echtzeit-Updates** - User darf NIE F5 drücken müssen
|
||||||
|
5. **Docker restart** nach Backend-Änderungen
|
||||||
|
|
||||||
|
### Datenschutz & Projektsicherheit 🔐
|
||||||
|
**ABSOLUT KRITISCH**: Das Projekt "AegisSight" ist produktiv im Einsatz!
|
||||||
|
|
||||||
|
- **Projekt "AegisSight" NIEMALS löschen, ändern oder beeinträchtigen**
|
||||||
|
- **Bestehende Benutzer NIEMALS zurücksetzen, löschen oder verändern**
|
||||||
|
- **Produktivdaten sind TABU** - keine Testdaten in echte Projekte
|
||||||
|
- **Keine Datenbank-Resets** ohne explizite Anweisung
|
||||||
|
- **JEDE Änderung MUSS umkehrbar sein** - Live-System!
|
||||||
|
- **Backup vor kritischen Änderungen** ist Pflicht
|
||||||
|
|
||||||
|
### Rollback-Strategie für Live-Betrieb
|
||||||
|
Bei JEDER Änderung sicherstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Vor Änderungen - Backup erstellen
|
||||||
|
cp data/taskmate.db data/taskmate.db.backup-$(date +%Y%m%d-%H%M%S)
|
||||||
|
docker commit taskmate taskmate-backup-$(date +%Y%m%d-%H%M%S)
|
||||||
|
|
||||||
|
# 2. Bei Problemen - Rollback durchführen
|
||||||
|
docker stop taskmate
|
||||||
|
docker run -d --name taskmate-temp taskmate-backup-TIMESTAMP
|
||||||
|
# Nach Test: docker rm -f taskmate && docker rename taskmate-temp taskmate
|
||||||
|
|
||||||
|
# 3. Code-Rollback via Git
|
||||||
|
git stash # Aktuelle Änderungen sichern
|
||||||
|
git checkout HEAD~1 # Zum vorherigen Commit
|
||||||
|
docker build -t taskmate . && docker restart taskmate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Änderungs-Workflow für Live-System:**
|
||||||
|
1. Backup von Datenbank UND Docker-Image
|
||||||
|
2. Kleine, inkrementelle Änderungen
|
||||||
|
3. Sofortiger Test nach jeder Änderung
|
||||||
|
4. Rollback-Plan dokumentieren
|
||||||
|
5. Bei kritischen Änderungen: Wartungsfenster planen
|
||||||
|
|
||||||
|
### Arbeitsweise mit nicht-technischem Anwender
|
||||||
|
**Der Anwender versteht KEIN Coding!** Daher:
|
||||||
|
|
||||||
|
1. **Vollständige Übernahme**: Du führst ALLE technischen Schritte durch
|
||||||
|
2. **Einfache Erklärungen**: "Ich passe jetzt X an, damit Y funktioniert"
|
||||||
|
3. **Status-Updates**: "Änderung abgeschlossen, teste jetzt..."
|
||||||
|
4. **Keine technischen Fragen**: Niemals nach Code, Logs oder Befehlen fragen
|
||||||
|
5. **Proaktives Handeln**: Selbstständig debuggen und lösen
|
||||||
|
|
||||||
|
**Beispiel-Kommunikation:**
|
||||||
|
- ✅ "Ich habe ein Problem gefunden und behebe es jetzt..."
|
||||||
|
- ❌ "Welche Version von Node.js ist installiert?"
|
||||||
|
- ✅ "Die Änderung ist fertig. Bitte die Seite neu laden und testen."
|
||||||
|
- ❌ "Kannst du mal in die Console schauen?"
|
||||||
|
|
||||||
|
### Zugriff & Domains
|
||||||
|
- **Frontend**: https://taskmate.aegis-sight.de
|
||||||
|
- **Lokaler Port**: 3001 → Container Port 3000
|
||||||
|
- **Gitea**: https://gitea-undso.aegis-sight.de/AegisSight/TaskMate
|
||||||
|
- **Gitea-Token**: Siehe `.env` Datei (NIEMALS in Git einchecken!)
|
||||||
|
|
||||||
|
## 📁 Projektstruktur
|
||||||
|
|
||||||
|
### Wichtige Dateien - Hier starten!
|
||||||
|
```
|
||||||
|
frontend/js/app.js # Hauptanwendung & View-Controller
|
||||||
|
backend/server.js # Express Server mit Socket.io
|
||||||
|
backend/database.js # Datenbankschema (20+ Tabellen)
|
||||||
|
frontend/js/store.js # State Management (Pub-Sub)
|
||||||
|
frontend/js/api.js # API Client mit Auth/CSRF
|
||||||
|
frontend/sw.js # Service Worker → CACHE_VERSION!
|
||||||
|
CHANGELOG.txt # Änderungsprotokoll
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend-Module (22 Dateien)
|
||||||
|
```
|
||||||
|
# Core
|
||||||
|
app.js # Hauptanwendung
|
||||||
|
store.js # State Management
|
||||||
|
api.js # Backend-Kommunikation
|
||||||
|
auth.js # Login/Token-Verwaltung
|
||||||
|
utils.js # Hilfsfunktionen
|
||||||
|
|
||||||
|
# Views
|
||||||
|
board.js # Kanban-Board mit Drag&Drop
|
||||||
|
calendar.js # Kalender (Monat/Woche)
|
||||||
|
list.js # Listenansicht
|
||||||
|
dashboard.js # Statistik-Dashboard
|
||||||
|
proposals.js # Vorschlagssystem
|
||||||
|
knowledge.js # Wissensdatenbank
|
||||||
|
admin.js # Benutzerverwaltung
|
||||||
|
|
||||||
|
# Features
|
||||||
|
task-modal.js # Aufgaben-Details
|
||||||
|
notifications.js # Benachrichtigungen
|
||||||
|
sync.js # Socket.io Echtzeit
|
||||||
|
mobile.js # Mobile Features
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend-Routes (22 Module)
|
||||||
|
```
|
||||||
|
/api/auth # Login/Logout
|
||||||
|
/api/tasks # Aufgaben CRUD
|
||||||
|
/api/projects # Projekte
|
||||||
|
/api/columns # Kanban-Spalten
|
||||||
|
/api/comments # Kommentare
|
||||||
|
/api/files # Datei-Upload
|
||||||
|
/api/proposals # Vorschläge
|
||||||
|
/api/gitea # Git-Integration
|
||||||
|
/api/knowledge # Wissensdatenbank
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Entwicklung
|
||||||
|
|
||||||
|
### Neue View/Ansicht hinzufügen
|
||||||
```javascript
|
```javascript
|
||||||
// Richtig: Lokale Formatierung
|
// 1. Datei erstellen: frontend/js/myview.js
|
||||||
|
export function initMyView() {
|
||||||
|
// KRITISCH: Echtzeit-Updates registrieren!
|
||||||
|
store.subscribe('tasks', updateView);
|
||||||
|
window.addEventListener('app:refresh', updateView);
|
||||||
|
|
||||||
|
function updateView() {
|
||||||
|
// UI aktualisieren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. In index.html einbinden
|
||||||
|
<script type="module" src="js/myview.js"></script>
|
||||||
|
|
||||||
|
// 3. Navigation erweitern in navigation.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neue API-Route erstellen
|
||||||
|
```javascript
|
||||||
|
// 1. Route-Datei: backend/routes/myroute.js
|
||||||
|
const router = require('express').Router();
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
|
||||||
|
router.get('/', auth, (req, res) => {
|
||||||
|
// Implementation
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
|
// 2. In server.js registrieren
|
||||||
|
app.use('/api/myroute', require('./routes/myroute'));
|
||||||
|
|
||||||
|
// 3. Frontend API-Call in api.js
|
||||||
|
async myRouteCall() {
|
||||||
|
return this.request('/api/myroute');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datums-Formatierung (RICHTIG!)
|
||||||
|
```javascript
|
||||||
|
// ✅ RICHTIG - Lokale Zeit
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
const dateStr = `${year}-${month}-${day}`;
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
// Falsch: UTC-Konvertierung
|
// ❌ FALSCH - UTC-Konvertierung
|
||||||
const dateStr = date.toISOString().split('T')[0]; // NICHT VERWENDEN!
|
const dateStr = date.toISOString().split('T')[0]; // NIEMALS!
|
||||||
```
|
```
|
||||||
|
|
||||||
## Echtzeit-Aktualisierung (KRITISCH)
|
### Echtzeit-Updates implementieren
|
||||||
- ALLE Nutzeranpassungen müssen SOFORT und ÜBERALL in der Anwendung sichtbar sein
|
```javascript
|
||||||
- Der Nutzer darf NIEMALS den Browser aktualisieren müssen (F5), um Änderungen zu sehen
|
// PFLICHT für ALLE Komponenten!
|
||||||
- Beispiele für Änderungen, die sofort überall wirken müssen:
|
// 1. Store-Subscriptions
|
||||||
- Spaltenfarbe ändern → Board, Kalender, Wochenstreifen sofort aktualisieren
|
store.subscribe('tasks', () => renderTasks());
|
||||||
- Aufgabe erstellen/bearbeiten/löschen → alle Ansichten sofort aktualisieren
|
store.subscribe('columns', () => updateColumns());
|
||||||
- Labels, Benutzer, Projekte ändern → überall sofort sichtbar
|
store.subscribe('labels', () => refreshLabels());
|
||||||
- Technische Umsetzung:
|
|
||||||
- `store.subscribe('tasks', callback)` - für Aufgabenänderungen
|
|
||||||
- `store.subscribe('columns', callback)` - für Spaltenänderungen
|
|
||||||
- `store.subscribe('labels', callback)` - für Label-Änderungen
|
|
||||||
- `store.subscribe('users', callback)` - für Benutzeränderungen
|
|
||||||
- `window.addEventListener('app:refresh', callback)` - für allgemeine Aktualisierungen
|
|
||||||
- `window.addEventListener('modal:close', callback)` - nach Modal-Schließung
|
|
||||||
- Bei JEDER neuen Komponente diese Event-Listener einbauen
|
|
||||||
- Bei JEDER Datenänderung prüfen: Welche UI-Bereiche müssen aktualisiert werden?
|
|
||||||
|
|
||||||
## Berechtigungen/Aktionen
|
// 2. Event-Listener
|
||||||
- Du sollst den Dockercontainer eigenständig - sofern erforderlich - neu starten/neu bauen, dass Änderungen wirksam werden
|
window.addEventListener('app:refresh', () => {
|
||||||
- Erreichbarkeit der Anwendung über https://taskmate.aegis-sight.de (keine automatische Browser-Öffnung)
|
// Komplette UI aktualisieren
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('modal:close', () => {
|
||||||
|
// Nach Modal-Schließung
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Socket.io Events in sync.js
|
||||||
|
socket.on('task:update', (data) => {
|
||||||
|
store.updateTask(data);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 💾 Datenbank
|
||||||
|
|
||||||
|
### Wichtige Tabellen
|
||||||
|
```sql
|
||||||
|
users # Benutzer mit Rollen
|
||||||
|
projects # Projekte
|
||||||
|
columns # Kanban-Spalten
|
||||||
|
tasks # Aufgaben
|
||||||
|
task_labels # M:N Labels
|
||||||
|
task_assignees # M:N Zuweisungen
|
||||||
|
comments # Kommentare
|
||||||
|
attachments # Dateianhänge
|
||||||
|
proposals # Vorschläge
|
||||||
|
notifications # Benachrichtigungen
|
||||||
|
knowledge_* # Wissensdatenbank
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema ändern
|
||||||
|
```javascript
|
||||||
|
// 1. In backend/database.js anpassen
|
||||||
|
CREATE TABLE new_table (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
...
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Datenbank neu initialisieren
|
||||||
|
rm data/taskmate.db*
|
||||||
|
docker restart taskmate
|
||||||
|
|
||||||
|
// 3. API & Frontend anpassen
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚢 Deployment
|
||||||
|
|
||||||
|
### Deployment-Checkliste
|
||||||
|
```bash
|
||||||
|
# 1. Vor Deployment
|
||||||
|
- [ ] Keine console.log() im Code
|
||||||
|
- [ ] Alle Features getestet
|
||||||
|
- [ ] Keine Testdaten in DB
|
||||||
|
|
||||||
|
# 2. Deployment durchführen
|
||||||
|
- [ ] Cache-Version erhöhen: frontend/sw.js
|
||||||
|
- [ ] CHANGELOG.txt aktualisieren
|
||||||
|
- [ ] Git commit & push
|
||||||
|
- [ ] docker build -t taskmate .
|
||||||
|
- [ ] docker restart taskmate
|
||||||
|
|
||||||
|
# 3. Nach Deployment
|
||||||
|
- [ ] https://taskmate.aegis-sight.de testen
|
||||||
|
- [ ] Browser-Cache leeren (Strg+F5)
|
||||||
|
- [ ] Login, Aufgabe erstellen, etc. testen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker-Befehle
|
||||||
|
```bash
|
||||||
|
# Container Status
|
||||||
|
docker ps -a | grep taskmate
|
||||||
|
|
||||||
|
# Container stoppen/starten
|
||||||
|
docker stop taskmate
|
||||||
|
docker start taskmate
|
||||||
|
|
||||||
|
# Container neu erstellen
|
||||||
|
docker rm -f taskmate
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# In Container Shell
|
||||||
|
docker exec -it taskmate sh
|
||||||
|
|
||||||
|
# Logs live verfolgen
|
||||||
|
docker logs taskmate -f --tail 100
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### Häufige Probleme
|
||||||
|
|
||||||
|
**401 Unauthorized**
|
||||||
|
- Token abgelaufen → Neu einloggen
|
||||||
|
- Prüfen: localStorage.getItem('token')
|
||||||
|
|
||||||
|
**CSRF Token ungültig**
|
||||||
|
- Browser-Cache/Cookies löschen
|
||||||
|
- Neu einloggen
|
||||||
|
|
||||||
|
**Änderungen nicht sichtbar**
|
||||||
|
- Service Worker Cache → sw.js Version erhöhen!
|
||||||
|
- Browser: Strg+F5
|
||||||
|
- Prüfen: Echtzeit-Updates implementiert?
|
||||||
|
|
||||||
|
**Docker startet nicht**
|
||||||
|
```bash
|
||||||
|
docker logs taskmate
|
||||||
|
docker ps -a | grep taskmate
|
||||||
|
netstat -tulpn | grep 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
**Datenbank-Fehler**
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
cp data/taskmate.db data/taskmate.db.backup
|
||||||
|
|
||||||
|
# Integrität prüfen
|
||||||
|
sqlite3 data/taskmate.db "PRAGMA integrity_check;"
|
||||||
|
|
||||||
|
# Schema anzeigen
|
||||||
|
sqlite3 data/taskmate.db ".schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug-Tipps
|
||||||
|
|
||||||
|
**Frontend Debugging**
|
||||||
|
```javascript
|
||||||
|
// Store-Status prüfen
|
||||||
|
console.log(store.getState());
|
||||||
|
|
||||||
|
// API-Calls tracken
|
||||||
|
window.api.debug = true;
|
||||||
|
|
||||||
|
// Socket-Events loggen
|
||||||
|
window.socket.on('*', console.log);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend Debugging**
|
||||||
|
```javascript
|
||||||
|
// In server.js
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// SQL Queries loggen
|
||||||
|
db.prepare(sql).run(); // Vorher console.log(sql)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Code-Patterns
|
||||||
|
|
||||||
|
### API Response Format
|
||||||
|
```javascript
|
||||||
|
// Erfolg
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fehler
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Fehlermeldung'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store Update Pattern
|
||||||
|
```javascript
|
||||||
|
// Daten aktualisieren
|
||||||
|
store.updateTasks(tasks);
|
||||||
|
|
||||||
|
// Wird automatisch ausgelöst:
|
||||||
|
// - Alle task-Subscriber
|
||||||
|
// - Socket.io Broadcast
|
||||||
|
// - UI-Updates
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal Pattern
|
||||||
|
```javascript
|
||||||
|
// Modal öffnen
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
// WICHTIG: Bei Schließung
|
||||||
|
modal.addEventListener('close', () => {
|
||||||
|
window.dispatchEvent(new CustomEvent('modal:close'));
|
||||||
|
// Triggert UI-Updates!
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Sicherheit
|
||||||
|
|
||||||
|
### Authentifizierung
|
||||||
|
- JWT Token mit 24h Gültigkeit
|
||||||
|
- Refresh bei jeder Aktivität
|
||||||
|
- Token in localStorage
|
||||||
|
|
||||||
|
### CSRF-Schutz
|
||||||
|
- Token bei Login generiert
|
||||||
|
- Bei jeder Mutation mitgesendet
|
||||||
|
- Header: `X-CSRF-Token`
|
||||||
|
|
||||||
|
### Berechtigungen
|
||||||
|
- Admin: Nur Benutzerverwaltung
|
||||||
|
- User: Alles außer Admin-Bereich
|
||||||
|
- Projekt-basierte Rechte
|
||||||
|
|
||||||
|
## 📝 Wichtige Konventionen
|
||||||
|
|
||||||
|
- **Sprache**: Deutsch für UI, Englisch für Code
|
||||||
|
- **Umlaute**: ä, ö, ü verwenden (keine ae, oe, ue)
|
||||||
|
- **CSS**: Variablen in `frontend/css/variables.css`
|
||||||
|
- **Keine Emojis** in Code/UI (nur Doku)
|
||||||
|
- **Auto-Save**: Änderungen werden automatisch gespeichert
|
||||||
|
|
||||||
|
## 🎯 Performance
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Lazy Loading für Views
|
||||||
|
- Debouncing bei Suche/Filter
|
||||||
|
- Virtual Scrolling bei langen Listen
|
||||||
|
- Service Worker Caching
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- SQLite mit WAL Mode
|
||||||
|
- Prepared Statements
|
||||||
|
- Index auf häufig gefilterte Spalten
|
||||||
|
- Pagination bei großen Datenmengen
|
||||||
|
|
||||||
|
## 🔄 Git Workflow
|
||||||
|
|
||||||
|
### Lokales Repository
|
||||||
|
```bash
|
||||||
|
# Status prüfen
|
||||||
|
git status
|
||||||
|
|
||||||
|
# Commit erstellen
|
||||||
|
git add .
|
||||||
|
git commit -m "Beschreibung der Änderung"
|
||||||
|
|
||||||
|
# Zu Gitea pushen
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea Integration
|
||||||
|
- Automatischer Push bei Commits
|
||||||
|
- Repository-Projekt Verknüpfung
|
||||||
|
- Branch-Verwaltung in UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Hinweis**: Diese Dokumentation ist für die KI-gestützte Entwicklung optimiert. Bei Fragen die `ANWENDUNGSBESCHREIBUNG.txt` für Endnutzer-Dokumentation konsultieren.
|
||||||
@ -22,7 +22,7 @@ RUN git config --system user.email "taskmate@local" && \
|
|||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
|
|
||||||
# Abhängigkeiten installieren
|
# Abhängigkeiten installieren
|
||||||
RUN npm ci --only=production
|
RUN npm install --only=production
|
||||||
|
|
||||||
# Build-Abhängigkeiten entfernen (kleineres Image)
|
# Build-Abhängigkeiten entfernen (kleineres Image)
|
||||||
RUN apk del python3 make g++
|
RUN apk del python3 make g++
|
||||||
|
|||||||
@ -368,6 +368,25 @@ function createTables() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Refresh Tokens für sichere Token-Rotation
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used DATETIME,
|
||||||
|
user_agent TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Index für Token-Lookup
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token)`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at)`);
|
||||||
|
|
||||||
// Anwendungen (Git-Repositories pro Projekt)
|
// Anwendungen (Git-Repositories pro Projekt)
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS applications (
|
CREATE TABLE IF NOT EXISTS applications (
|
||||||
@ -457,6 +476,34 @@ function createTables() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Coding-Verzeichnisse (projektübergreifend)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS coding_directories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
local_path TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
color TEXT DEFAULT '#4F46E5',
|
||||||
|
gitea_repo_url TEXT,
|
||||||
|
gitea_repo_owner TEXT,
|
||||||
|
gitea_repo_name TEXT,
|
||||||
|
default_branch TEXT DEFAULT 'main',
|
||||||
|
last_sync DATETIME,
|
||||||
|
position INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by INTEGER,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Migration: Add claude_instructions column to coding_directories
|
||||||
|
const codingDirColumns = db.prepare("PRAGMA table_info(coding_directories)").all();
|
||||||
|
const hasClaudeInstructions = codingDirColumns.some(col => col.name === 'claude_instructions');
|
||||||
|
if (!hasClaudeInstructions) {
|
||||||
|
db.exec('ALTER TABLE coding_directories ADD COLUMN claude_instructions TEXT');
|
||||||
|
logger.info('Migration: claude_instructions Spalte zu coding_directories hinzugefuegt');
|
||||||
|
}
|
||||||
|
|
||||||
// Indizes für Performance
|
// Indizes für Performance
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
||||||
@ -476,17 +523,36 @@ function createTables() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id);
|
CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_category ON knowledge_entries(category_id);
|
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_category ON knowledge_entries(category_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_knowledge_attachments_entry ON knowledge_attachments(entry_id);
|
CREATE INDEX IF NOT EXISTS idx_knowledge_attachments_entry ON knowledge_attachments(entry_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_coding_directories_position ON coding_directories(position);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
logger.info('Datenbank-Tabellen erstellt');
|
logger.info('Datenbank-Tabellen erstellt');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard-Benutzer erstellen
|
* Standard-Benutzer erstellen und Admin-Passwort korrigieren
|
||||||
*/
|
*/
|
||||||
async function createDefaultUsers() {
|
async function createDefaultUsers() {
|
||||||
const existingUsers = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
const existingUsers = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
|
|
||||||
|
// Admin-Passwort korrigieren (falls aus .env verschieden)
|
||||||
|
const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE username = ? AND role = ?').get('admin', 'admin');
|
||||||
|
if (adminExists) {
|
||||||
|
const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
// Prüfen ob das Passwort bereits korrekt ist
|
||||||
|
const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash);
|
||||||
|
if (!isCorrect) {
|
||||||
|
logger.info('Admin-Passwort wird aus .env aktualisiert');
|
||||||
|
const correctHash = await bcrypt.hash(correctAdminPassword, 12);
|
||||||
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(correctHash, adminExists.id);
|
||||||
|
logger.info('Admin-Passwort erfolgreich aktualisiert');
|
||||||
|
} else {
|
||||||
|
logger.info('Admin-Passwort bereits korrekt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (existingUsers.count === 0) {
|
if (existingUsers.count === 0) {
|
||||||
// Benutzer aus Umgebungsvariablen
|
// Benutzer aus Umgebungsvariablen
|
||||||
const user1 = {
|
const user1 = {
|
||||||
@ -510,10 +576,10 @@ async function createDefaultUsers() {
|
|||||||
|
|
||||||
// Admin-Benutzer
|
// Admin-Benutzer
|
||||||
const adminUser = {
|
const adminUser = {
|
||||||
username: 'admin',
|
username: process.env.ADMIN_USERNAME || 'admin',
|
||||||
password: 'Kx9#mP2$vL7@nQ4!wR',
|
password: process.env.ADMIN_PASSWORD || 'admin123',
|
||||||
displayName: 'Administrator',
|
displayName: process.env.ADMIN_DISPLAYNAME || 'Administrator',
|
||||||
color: '#8B5CF6'
|
color: process.env.ADMIN_COLOR || '#8B5CF6'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Passwoerter hashen und Benutzer erstellen
|
// Passwoerter hashen und Benutzer erstellen
|
||||||
|
|||||||
@ -5,15 +5,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { getDb } = require('../database');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'UNSICHER_BITTE_AENDERN';
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||||
|
throw new Error('JWT_SECRET muss in .env gesetzt und mindestens 32 Zeichen lang sein!');
|
||||||
|
}
|
||||||
|
const ACCESS_TOKEN_EXPIRY = 15; // Minuten (kürzer für mehr Sicherheit)
|
||||||
|
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten
|
||||||
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
|
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT-Token generieren
|
* JWT Access-Token generieren (kurze Lebensdauer)
|
||||||
*/
|
*/
|
||||||
function generateToken(user) {
|
function generateAccessToken(user) {
|
||||||
// Permissions parsen falls als String gespeichert
|
// Permissions parsen falls als String gespeichert
|
||||||
let permissions = user.permissions || [];
|
let permissions = user.permissions || [];
|
||||||
if (typeof permissions === 'string') {
|
if (typeof permissions === 'string') {
|
||||||
@ -31,13 +38,38 @@ function generateToken(user) {
|
|||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
color: user.color,
|
color: user.color,
|
||||||
role: user.role || 'user',
|
role: user.role || 'user',
|
||||||
permissions: permissions
|
permissions: permissions,
|
||||||
|
type: 'access'
|
||||||
},
|
},
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: `${SESSION_TIMEOUT}m` }
|
{ expiresIn: `${ACCESS_TOKEN_EXPIRY}m` }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh-Token generieren (lange Lebensdauer)
|
||||||
|
*/
|
||||||
|
function generateRefreshToken(userId, ipAddress, userAgent) {
|
||||||
|
const db = getDb();
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY * 60 * 1000);
|
||||||
|
|
||||||
|
// Token in Datenbank speichern
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO refresh_tokens (user_id, token, expires_at, ip_address, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, token, expiresAt.toISOString(), ipAddress, userAgent);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy generateToken für Rückwärtskompatibilität
|
||||||
|
*/
|
||||||
|
function generateToken(user) {
|
||||||
|
return generateAccessToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT-Token verifizieren
|
* JWT-Token verifizieren
|
||||||
*/
|
*/
|
||||||
@ -179,8 +211,72 @@ function generateCsrfToken() {
|
|||||||
return randomBytes(32).toString('hex');
|
return randomBytes(32).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh-Token validieren und neuen Access-Token generieren
|
||||||
|
*/
|
||||||
|
async function refreshAccessToken(refreshToken, ipAddress, userAgent) {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Token in Datenbank suchen
|
||||||
|
const tokenRecord = db.prepare(`
|
||||||
|
SELECT rt.*, u.* FROM refresh_tokens rt
|
||||||
|
JOIN users u ON rt.user_id = u.id
|
||||||
|
WHERE rt.token = ? AND rt.expires_at > datetime('now')
|
||||||
|
`).get(refreshToken);
|
||||||
|
|
||||||
|
if (!tokenRecord) {
|
||||||
|
throw new Error('Ungültiger oder abgelaufener Refresh-Token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token als benutzt markieren
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE refresh_tokens SET last_used = CURRENT_TIMESTAMP WHERE id = ?
|
||||||
|
`).run(tokenRecord.id);
|
||||||
|
|
||||||
|
// Neuen Access-Token generieren
|
||||||
|
const user = {
|
||||||
|
id: tokenRecord.user_id,
|
||||||
|
username: tokenRecord.username,
|
||||||
|
display_name: tokenRecord.display_name,
|
||||||
|
color: tokenRecord.color,
|
||||||
|
role: tokenRecord.role,
|
||||||
|
permissions: tokenRecord.permissions
|
||||||
|
};
|
||||||
|
|
||||||
|
return generateAccessToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Refresh-Tokens eines Benutzers löschen (Logout auf allen Geräten)
|
||||||
|
*/
|
||||||
|
function revokeAllRefreshTokens(userId) {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgelaufene Refresh-Tokens aufräumen
|
||||||
|
*/
|
||||||
|
function cleanupExpiredTokens() {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db.prepare(`
|
||||||
|
DELETE FROM refresh_tokens WHERE expires_at < datetime('now')
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
if (result.changes > 0) {
|
||||||
|
logger.info(`Bereinigt: ${result.changes} abgelaufene Refresh-Tokens`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup alle 6 Stunden
|
||||||
|
setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateToken,
|
generateToken,
|
||||||
|
generateAccessToken,
|
||||||
|
generateRefreshToken,
|
||||||
|
refreshAccessToken,
|
||||||
|
revokeAllRefreshTokens,
|
||||||
verifyToken,
|
verifyToken,
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
authenticateSocket,
|
authenticateSocket,
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
@ -18,18 +19,54 @@ if (!fs.existsSync(UPLOAD_DIR)) {
|
|||||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mapping: Dateiendung -> erlaubte MIME-Types
|
||||||
|
const EXTENSION_TO_MIME = {
|
||||||
|
// Dokumente
|
||||||
|
'pdf': ['application/pdf'],
|
||||||
|
'doc': ['application/msword'],
|
||||||
|
'docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||||
|
'xls': ['application/vnd.ms-excel'],
|
||||||
|
'xlsx': ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||||
|
'ppt': ['application/vnd.ms-powerpoint'],
|
||||||
|
'pptx': ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||||
|
'odt': ['application/vnd.oasis.opendocument.text'],
|
||||||
|
'ods': ['application/vnd.oasis.opendocument.spreadsheet'],
|
||||||
|
'odp': ['application/vnd.oasis.opendocument.presentation'],
|
||||||
|
'rtf': ['application/rtf', 'text/rtf'],
|
||||||
|
// Text
|
||||||
|
'txt': ['text/plain'],
|
||||||
|
'csv': ['text/csv', 'application/csv', 'text/comma-separated-values'],
|
||||||
|
'md': ['text/markdown', 'text/x-markdown', 'text/plain'],
|
||||||
|
'json': ['application/json', 'text/json'],
|
||||||
|
'xml': ['application/xml', 'text/xml'],
|
||||||
|
'html': ['text/html'],
|
||||||
|
'log': ['text/plain'],
|
||||||
|
// Bilder
|
||||||
|
'jpg': ['image/jpeg'],
|
||||||
|
'jpeg': ['image/jpeg'],
|
||||||
|
'png': ['image/png'],
|
||||||
|
'gif': ['image/gif'],
|
||||||
|
'webp': ['image/webp'],
|
||||||
|
'svg': ['image/svg+xml'],
|
||||||
|
'bmp': ['image/bmp'],
|
||||||
|
'ico': ['image/x-icon', 'image/vnd.microsoft.icon'],
|
||||||
|
// Archive
|
||||||
|
'zip': ['application/zip', 'application/x-zip-compressed'],
|
||||||
|
'rar': ['application/x-rar-compressed', 'application/vnd.rar'],
|
||||||
|
'7z': ['application/x-7z-compressed'],
|
||||||
|
'tar': ['application/x-tar'],
|
||||||
|
'gz': ['application/gzip', 'application/x-gzip'],
|
||||||
|
// Code/Skripte (als text/plain akzeptiert)
|
||||||
|
'sql': ['application/sql', 'text/plain'],
|
||||||
|
'js': ['text/javascript', 'application/javascript', 'text/plain'],
|
||||||
|
'css': ['text/css', 'text/plain'],
|
||||||
|
'py': ['text/x-python', 'text/plain'],
|
||||||
|
'sh': ['application/x-sh', 'text/plain']
|
||||||
|
};
|
||||||
|
|
||||||
// Standard-Werte (Fallback)
|
// Standard-Werte (Fallback)
|
||||||
let MAX_FILE_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB) || 15) * 1024 * 1024;
|
let MAX_FILE_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB) || 15) * 1024 * 1024;
|
||||||
let ALLOWED_MIME_TYPES = [
|
let ALLOWED_EXTENSIONS = ['pdf', 'docx', 'txt'];
|
||||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
|
||||||
'application/pdf',
|
|
||||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
||||||
'text/plain', 'text/csv', 'text/markdown',
|
|
||||||
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
|
|
||||||
'application/json'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt Upload-Einstellungen aus der Datenbank
|
* Lädt Upload-Einstellungen aus der Datenbank
|
||||||
@ -43,17 +80,9 @@ function loadUploadSettings() {
|
|||||||
if (settings) {
|
if (settings) {
|
||||||
MAX_FILE_SIZE = (settings.maxFileSizeMB || 15) * 1024 * 1024;
|
MAX_FILE_SIZE = (settings.maxFileSizeMB || 15) * 1024 * 1024;
|
||||||
|
|
||||||
// Erlaubte MIME-Types aus den aktiven Kategorien zusammenstellen
|
// Erlaubte Endungen aus den Einstellungen
|
||||||
const types = [];
|
if (Array.isArray(settings.allowedExtensions) && settings.allowedExtensions.length > 0) {
|
||||||
if (settings.allowedTypes) {
|
ALLOWED_EXTENSIONS = settings.allowedExtensions;
|
||||||
Object.values(settings.allowedTypes).forEach(category => {
|
|
||||||
if (category.enabled && Array.isArray(category.types)) {
|
|
||||||
types.push(...category.types);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (types.length > 0) {
|
|
||||||
ALLOWED_MIME_TYPES = types;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -67,7 +96,7 @@ function loadUploadSettings() {
|
|||||||
*/
|
*/
|
||||||
function getCurrentSettings() {
|
function getCurrentSettings() {
|
||||||
loadUploadSettings();
|
loadUploadSettings();
|
||||||
return { maxFileSize: MAX_FILE_SIZE, allowedMimeTypes: ALLOWED_MIME_TYPES };
|
return { maxFileSize: MAX_FILE_SIZE, allowedExtensions: ALLOWED_EXTENSIONS };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -99,19 +128,83 @@ const storage = multer.diskStorage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datei-Filter
|
* Gefährliche Dateinamen prüfen
|
||||||
|
*/
|
||||||
|
function isSecureFilename(filename) {
|
||||||
|
// Null-Bytes, Pfad-Traversal, Steuerzeichen blocken
|
||||||
|
const dangerousPatterns = [
|
||||||
|
/\x00/, // Null-Bytes
|
||||||
|
/\.\./, // Path traversal
|
||||||
|
/[<>:"\\|?*]/, // Windows-spezifische gefährliche Zeichen
|
||||||
|
/[\x00-\x1F]/, // Steuerzeichen
|
||||||
|
/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, // Windows reservierte Namen
|
||||||
|
];
|
||||||
|
|
||||||
|
return !dangerousPatterns.some(pattern => pattern.test(filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datei-Filter: Erweiterte Sicherheitsprüfungen
|
||||||
*/
|
*/
|
||||||
const fileFilter = (req, file, cb) => {
|
const fileFilter = (req, file, cb) => {
|
||||||
// Aktuelle Einstellungen laden
|
// Aktuelle Einstellungen laden
|
||||||
const settings = getCurrentSettings();
|
const settings = getCurrentSettings();
|
||||||
|
|
||||||
// MIME-Type prüfen
|
// Sicherheitsprüfungen für Dateinamen
|
||||||
if (settings.allowedMimeTypes.includes(file.mimetype)) {
|
if (!isSecureFilename(file.originalname)) {
|
||||||
cb(null, true);
|
logger.warn(`Unsicherer Dateiname abgelehnt: ${file.originalname}`);
|
||||||
} else {
|
cb(new Error('Dateiname enthält nicht erlaubte Zeichen'), false);
|
||||||
logger.warn(`Abgelehnter Upload: ${file.originalname} (${file.mimetype})`);
|
return;
|
||||||
cb(new Error(`Dateityp nicht erlaubt: ${file.mimetype}`), false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dateiname-Länge prüfen
|
||||||
|
if (file.originalname.length > 255) {
|
||||||
|
logger.warn(`Dateiname zu lang: ${file.originalname}`);
|
||||||
|
cb(new Error('Dateiname ist zu lang (max. 255 Zeichen)'), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dateiendung extrahieren (ohne Punkt, lowercase)
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
||||||
|
|
||||||
|
// Doppelte Dateiendungen verhindern (z.B. script.txt.exe)
|
||||||
|
const nameWithoutExt = path.basename(file.originalname, path.extname(file.originalname));
|
||||||
|
if (path.extname(nameWithoutExt)) {
|
||||||
|
logger.warn(`Doppelte Dateiendung abgelehnt: ${file.originalname}`);
|
||||||
|
cb(new Error('Dateien mit mehreren Endungen sind nicht erlaubt'), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob Endung erlaubt ist
|
||||||
|
if (!settings.allowedExtensions.includes(ext)) {
|
||||||
|
logger.warn(`Abgelehnter Upload (Endung): ${file.originalname} (.${ext})`);
|
||||||
|
cb(new Error(`Dateityp .${ext} nicht erlaubt`), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Executable Dateien zusätzlich blocken
|
||||||
|
const executableExtensions = [
|
||||||
|
'exe', 'bat', 'cmd', 'com', 'scr', 'pif', 'vbs', 'vbe', 'js', 'jar',
|
||||||
|
'app', 'deb', 'pkg', 'dmg', 'run', 'bin', 'msi', 'gadget'
|
||||||
|
];
|
||||||
|
if (executableExtensions.includes(ext)) {
|
||||||
|
logger.warn(`Executable Datei abgelehnt: ${file.originalname}`);
|
||||||
|
cb(new Error('Ausführbare Dateien sind nicht erlaubt'), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME-Type gegen bekannte Typen prüfen
|
||||||
|
const expectedMimes = EXTENSION_TO_MIME[ext];
|
||||||
|
if (expectedMimes && !expectedMimes.includes(file.mimetype)) {
|
||||||
|
logger.warn(`MIME-Mismatch: ${file.originalname} (erwartet: ${expectedMimes.join('/')}, bekommen: ${file.mimetype})`);
|
||||||
|
// Bei kritischen Mismatches ablehnen
|
||||||
|
if (file.mimetype === 'application/octet-stream' || file.mimetype.startsWith('application/x-')) {
|
||||||
|
cb(new Error('Verdächtiger Dateityp erkannt'), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -194,5 +287,6 @@ module.exports = {
|
|||||||
getCurrentSettings,
|
getCurrentSettings,
|
||||||
UPLOAD_DIR,
|
UPLOAD_DIR,
|
||||||
MAX_FILE_SIZE,
|
MAX_FILE_SIZE,
|
||||||
ALLOWED_MIME_TYPES
|
ALLOWED_EXTENSIONS,
|
||||||
|
EXTENSION_TO_MIME
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,24 +5,52 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const sanitizeHtml = require('sanitize-html');
|
const sanitizeHtml = require('sanitize-html');
|
||||||
|
const createDOMPurify = require('dompurify');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
|
||||||
|
// DOMPurify für Server-side Rendering initialisieren
|
||||||
|
const window = new JSDOM('').window;
|
||||||
|
const DOMPurify = createDOMPurify(window);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTML-Tags entfernen (für reine Text-Felder)
|
* HTML-Entities dekodieren
|
||||||
*/
|
*/
|
||||||
function stripHtml(input) {
|
function decodeHtmlEntities(str) {
|
||||||
if (typeof input !== 'string') return input;
|
if (typeof str !== 'string') return str;
|
||||||
return sanitizeHtml(input, {
|
const entities = {
|
||||||
allowedTags: [],
|
'&': '&',
|
||||||
allowedAttributes: {}
|
'<': '<',
|
||||||
}).trim();
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
''': "'",
|
||||||
|
''': "'"
|
||||||
|
};
|
||||||
|
return str.replace(/&(amp|lt|gt|quot|#039|#x27|apos);/g, match => entities[match] || match);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown-sichere Bereinigung (erlaubt bestimmte Tags)
|
* HTML-Tags entfernen (für reine Text-Felder)
|
||||||
|
* Wichtig: sanitize-html encoded &-Zeichen zu &, daher dekodieren wir danach
|
||||||
|
*/
|
||||||
|
function stripHtml(input) {
|
||||||
|
if (typeof input !== 'string') return input;
|
||||||
|
const sanitized = sanitizeHtml(input, {
|
||||||
|
allowedTags: [],
|
||||||
|
allowedAttributes: {}
|
||||||
|
}).trim();
|
||||||
|
// Entities wieder dekodieren, da sanitize-html sie encoded
|
||||||
|
return decodeHtmlEntities(sanitized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown-sichere Bereinigung mit DOMPurify (doppelte Sicherheit)
|
||||||
*/
|
*/
|
||||||
function sanitizeMarkdown(input) {
|
function sanitizeMarkdown(input) {
|
||||||
if (typeof input !== 'string') return input;
|
if (typeof input !== 'string') return input;
|
||||||
return sanitizeHtml(input, {
|
|
||||||
|
// Erste Bereinigung mit sanitize-html
|
||||||
|
const firstPass = sanitizeHtml(input, {
|
||||||
allowedTags: [
|
allowedTags: [
|
||||||
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||||
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||||
@ -44,6 +72,16 @@ function sanitizeMarkdown(input) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Zweite Bereinigung mit DOMPurify (zusätzliche Sicherheit)
|
||||||
|
return DOMPurify.sanitize(firstPass, {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||||
|
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -65,8 +103,16 @@ function sanitizeObject(obj, options = {}) {
|
|||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
// Bestimmte Felder dürfen Markdown enthalten
|
// Bestimmte Felder dürfen Markdown enthalten
|
||||||
const allowHtml = ['description', 'content'].includes(key);
|
const allowHtml = ['description', 'content'].includes(key);
|
||||||
|
|
||||||
|
// Passwort-Felder NICHT sanitizen (Sonderzeichen erhalten)
|
||||||
|
const skipSanitization = ['password', 'oldPassword', 'newPassword', 'confirmPassword'].includes(key);
|
||||||
|
|
||||||
|
if (skipSanitization) {
|
||||||
|
sanitized[key] = value; // Passwort unverändert lassen
|
||||||
|
} else {
|
||||||
sanitized[key] = sanitizeObject(value, { allowHtml });
|
sanitized[key] = sanitizeObject(value, { allowHtml });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,12 +165,32 @@ const validators = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL-Format prüfen
|
* URL-Format prüfen (erweiterte Sicherheit)
|
||||||
*/
|
*/
|
||||||
url: (value, fieldName) => {
|
url: (value, fieldName) => {
|
||||||
try {
|
try {
|
||||||
if (value) {
|
if (value) {
|
||||||
new URL(value);
|
const url = new URL(value);
|
||||||
|
|
||||||
|
// Nur HTTP/HTTPS erlauben
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
|
return `${fieldName} muss HTTP oder HTTPS verwenden`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localhost und private IPs blocken (SSRF-Schutz)
|
||||||
|
const hostname = url.hostname;
|
||||||
|
if (hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname.startsWith('192.168.') ||
|
||||||
|
hostname.startsWith('10.') ||
|
||||||
|
hostname.startsWith('172.')) {
|
||||||
|
return `${fieldName} darf nicht auf lokale Adressen verweisen`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JavaScript URLs blocken
|
||||||
|
if (url.href.toLowerCase().startsWith('javascript:')) {
|
||||||
|
return `${fieldName} enthält ungültigen JavaScript-Code`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -20,7 +20,10 @@
|
|||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
"express-rate-limiter": "^1.3.1",
|
"express-rate-limiter": "^1.3.1",
|
||||||
"sanitize-html": "^2.11.0",
|
"sanitize-html": "^2.11.0",
|
||||||
"marked": "^11.1.0"
|
"marked": "^11.1.0",
|
||||||
|
"dompurify": "^3.0.6",
|
||||||
|
"jsdom": "^23.0.1",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
|||||||
87
backend/query_users.js
Normale Datei
87
backend/query_users.js
Normale Datei
@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Abfragen der Benutzer aus der SQLite-Datenbank
|
||||||
|
* Verwendung: node query_users.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Datenbank-Pfad - angepasst für Docker-Container
|
||||||
|
const DB_PATH = process.env.DB_PATH || './data/taskmate.db';
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Verbinde zur Datenbank:', DB_PATH);
|
||||||
|
|
||||||
|
// Datenbank öffnen
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
// Alle Benutzer abfragen
|
||||||
|
const users = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
color,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
repositories_base_path,
|
||||||
|
created_at,
|
||||||
|
last_login,
|
||||||
|
failed_attempts,
|
||||||
|
locked_until
|
||||||
|
FROM users
|
||||||
|
ORDER BY id
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
console.log('\n=== BENUTZER IN DER DATENBANK ===\n');
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('Keine Benutzer gefunden!');
|
||||||
|
} else {
|
||||||
|
users.forEach(user => {
|
||||||
|
console.log(`ID: ${user.id}`);
|
||||||
|
console.log(`Benutzername: ${user.username}`);
|
||||||
|
console.log(`Anzeigename: ${user.display_name}`);
|
||||||
|
console.log(`Farbe: ${user.color}`);
|
||||||
|
console.log(`Rolle: ${user.role || 'user'}`);
|
||||||
|
console.log(`E-Mail: ${user.email || 'nicht gesetzt'}`);
|
||||||
|
console.log(`Repository-Basispfad: ${user.repositories_base_path || 'nicht gesetzt'}`);
|
||||||
|
console.log(`Erstellt am: ${user.created_at}`);
|
||||||
|
console.log(`Letzter Login: ${user.last_login || 'noch nie'}`);
|
||||||
|
console.log(`Fehlgeschlagene Versuche: ${user.failed_attempts}`);
|
||||||
|
console.log(`Gesperrt bis: ${user.locked_until || 'nicht gesperrt'}`);
|
||||||
|
console.log('-----------------------------------');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nGesamt: ${users.length} Benutzer gefunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe auch Login-Audit für weitere Informationen
|
||||||
|
const recentAttempts = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
la.timestamp,
|
||||||
|
la.ip_address,
|
||||||
|
la.success,
|
||||||
|
la.user_agent,
|
||||||
|
u.username
|
||||||
|
FROM login_audit la
|
||||||
|
LEFT JOIN users u ON la.user_id = u.id
|
||||||
|
ORDER BY la.timestamp DESC
|
||||||
|
LIMIT 10
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
if (recentAttempts.length > 0) {
|
||||||
|
console.log('\n=== LETZTE LOGIN-VERSUCHE ===\n');
|
||||||
|
recentAttempts.forEach(attempt => {
|
||||||
|
console.log(`${attempt.timestamp}: ${attempt.username || 'Unbekannt'} - ${attempt.success ? 'ERFOLGREICH' : 'FEHLGESCHLAGEN'} - IP: ${attempt.ip_address}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datenbank schließen
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abfragen der Datenbank:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@ -10,45 +10,14 @@ const router = express.Router();
|
|||||||
const { getDb } = require('../database');
|
const { getDb } = require('../database');
|
||||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const backup = require('../utils/backup');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard-Upload-Einstellungen
|
* Standard-Upload-Einstellungen (neues Format mit Dateiendungen)
|
||||||
*/
|
*/
|
||||||
const DEFAULT_UPLOAD_SETTINGS = {
|
const DEFAULT_UPLOAD_SETTINGS = {
|
||||||
maxFileSizeMB: 15,
|
maxFileSizeMB: 15,
|
||||||
allowedTypes: {
|
allowedExtensions: ['pdf', 'docx', 'txt']
|
||||||
images: {
|
|
||||||
enabled: true,
|
|
||||||
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
|
||||||
},
|
|
||||||
documents: {
|
|
||||||
enabled: true,
|
|
||||||
types: ['application/pdf']
|
|
||||||
},
|
|
||||||
office: {
|
|
||||||
enabled: true,
|
|
||||||
types: [
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'application/vnd.ms-excel',
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'application/vnd.ms-powerpoint',
|
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
enabled: true,
|
|
||||||
types: ['text/plain', 'text/csv', 'text/markdown']
|
|
||||||
},
|
|
||||||
archives: {
|
|
||||||
enabled: true,
|
|
||||||
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
enabled: true,
|
|
||||||
types: ['application/json']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
|
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
|
||||||
@ -351,6 +320,17 @@ router.get('/upload-settings', (req, res) => {
|
|||||||
|
|
||||||
if (setting) {
|
if (setting) {
|
||||||
const settings = JSON.parse(setting.value);
|
const settings = JSON.parse(setting.value);
|
||||||
|
|
||||||
|
// Migration: Altes Format (allowedTypes) auf neues Format (allowedExtensions) umstellen
|
||||||
|
if (settings.allowedTypes && !settings.allowedExtensions) {
|
||||||
|
// Altes Format erkannt - auf Standard-Einstellungen zurücksetzen
|
||||||
|
logger.info('Migriere Upload-Einstellungen auf neues Format (allowedExtensions)');
|
||||||
|
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||||
|
.run('upload_settings', JSON.stringify(DEFAULT_UPLOAD_SETTINGS));
|
||||||
|
res.json(DEFAULT_UPLOAD_SETTINGS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.json(settings);
|
res.json(settings);
|
||||||
} else {
|
} else {
|
||||||
// Standard-Einstellungen zurückgeben und speichern
|
// Standard-Einstellungen zurückgeben und speichern
|
||||||
@ -369,24 +349,36 @@ router.get('/upload-settings', (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.put('/upload-settings', (req, res) => {
|
router.put('/upload-settings', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { maxFileSizeMB, allowedTypes } = req.body;
|
const { maxFileSizeMB, allowedExtensions } = req.body;
|
||||||
|
|
||||||
// Validierung
|
// Validierung
|
||||||
if (typeof maxFileSizeMB !== 'number' || maxFileSizeMB < 1 || maxFileSizeMB > 100) {
|
if (typeof maxFileSizeMB !== 'number' || maxFileSizeMB < 1 || maxFileSizeMB > 100) {
|
||||||
return res.status(400).json({ error: 'Maximale Dateigröße muss zwischen 1 und 100 MB liegen' });
|
return res.status(400).json({ error: 'Maximale Dateigröße muss zwischen 1 und 100 MB liegen' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowedTypes || typeof allowedTypes !== 'object') {
|
if (!Array.isArray(allowedExtensions) || allowedExtensions.length === 0) {
|
||||||
return res.status(400).json({ error: 'Ungültige Dateityp-Konfiguration' });
|
return res.status(400).json({ error: 'Mindestens eine Dateiendung muss erlaubt sein' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = { maxFileSizeMB, allowedTypes };
|
// Endungen validieren (nur alphanumerisch, 1-10 Zeichen)
|
||||||
|
const validExtensions = allowedExtensions
|
||||||
|
.map(ext => ext.toLowerCase().replace(/^\./, '')) // Punkt am Anfang entfernen
|
||||||
|
.filter(ext => /^[a-z0-9]{1,10}$/.test(ext));
|
||||||
|
|
||||||
|
if (validExtensions.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Keine gültigen Dateiendungen angegeben' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplikate entfernen
|
||||||
|
const uniqueExtensions = [...new Set(validExtensions)];
|
||||||
|
|
||||||
|
const settings = { maxFileSizeMB, allowedExtensions: uniqueExtensions };
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||||
.run('upload_settings', JSON.stringify(settings));
|
.run('upload_settings', JSON.stringify(settings));
|
||||||
|
|
||||||
logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert`);
|
logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert: ${uniqueExtensions.join(', ')}`);
|
||||||
|
|
||||||
res.json(settings);
|
res.json(settings);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -404,7 +396,12 @@ function getUploadSettings() {
|
|||||||
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
|
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
|
||||||
|
|
||||||
if (setting) {
|
if (setting) {
|
||||||
return JSON.parse(setting.value);
|
const settings = JSON.parse(setting.value);
|
||||||
|
// Bei altem Format oder fehlendem allowedExtensions: Standard verwenden
|
||||||
|
if (!settings.allowedExtensions || !Array.isArray(settings.allowedExtensions)) {
|
||||||
|
return DEFAULT_UPLOAD_SETTINGS;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
}
|
}
|
||||||
return DEFAULT_UPLOAD_SETTINGS;
|
return DEFAULT_UPLOAD_SETTINGS;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -413,6 +410,42 @@ function getUploadSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/backup/create - Sofortiges verschlüsseltes Backup erstellen
|
||||||
|
*/
|
||||||
|
router.post('/backup/create', (req, res) => {
|
||||||
|
try {
|
||||||
|
const backupPath = backup.createBackup();
|
||||||
|
|
||||||
|
if (backupPath) {
|
||||||
|
logger.info(`Admin ${req.user.username} hat manuelles Backup erstellt`);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Verschlüsseltes Backup erfolgreich erstellt',
|
||||||
|
backupPath: backupPath.split('/').pop() // Nur Dateiname zurückgeben
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Backup-Erstellung fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Backup-Erstellung durch Admin fehlgeschlagen:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler beim Erstellen des Backups' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/backup/list - Liste aller verschlüsselten Backups
|
||||||
|
*/
|
||||||
|
router.get('/backup/list', (req, res) => {
|
||||||
|
try {
|
||||||
|
const backups = backup.listBackups();
|
||||||
|
res.json(backups);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Auflisten der Backups:', error);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Auflisten der Backups' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.getUploadSettings = getUploadSettings;
|
module.exports.getUploadSettings = getUploadSettings;
|
||||||
module.exports.DEFAULT_UPLOAD_SETTINGS = DEFAULT_UPLOAD_SETTINGS;
|
module.exports.DEFAULT_UPLOAD_SETTINGS = DEFAULT_UPLOAD_SETTINGS;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { getDb } = require('../database');
|
const { getDb } = require('../database');
|
||||||
const { generateToken, authenticateToken } = require('../middleware/auth');
|
const { generateToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth');
|
||||||
const { getTokenForUser } = require('../middleware/csrf');
|
const { getTokenForUser } = require('../middleware/csrf');
|
||||||
const { validatePassword } = require('../middleware/validation');
|
const { validatePassword } = require('../middleware/validation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@ -37,13 +37,8 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
// Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail
|
// Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail
|
||||||
let user;
|
let user;
|
||||||
if (username.toLowerCase() === 'admin') {
|
// User per Username suchen (kann E-Mail-Adresse oder admin sein)
|
||||||
// Admin-User per Username suchen
|
user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
user = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
|
|
||||||
} else {
|
|
||||||
// Normale User per E-Mail suchen
|
|
||||||
user = db.prepare('SELECT * FROM users WHERE email = ?').get(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit-Log Eintrag vorbereiten
|
// Audit-Log Eintrag vorbereiten
|
||||||
const logAttempt = (userId, success) => {
|
const logAttempt = (userId, success) => {
|
||||||
@ -111,8 +106,11 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
logAttempt(user.id, true);
|
logAttempt(user.id, true);
|
||||||
|
|
||||||
// JWT-Token generieren
|
// JWT Access-Token generieren (kurze Lebensdauer)
|
||||||
const token = generateToken(user);
|
const accessToken = generateToken(user);
|
||||||
|
|
||||||
|
// Refresh-Token generieren (lange Lebensdauer)
|
||||||
|
const refreshToken = generateRefreshToken(user.id, ip, userAgent);
|
||||||
|
|
||||||
// CSRF-Token generieren
|
// CSRF-Token generieren
|
||||||
const csrfToken = getTokenForUser(user.id);
|
const csrfToken = getTokenForUser(user.id);
|
||||||
@ -128,7 +126,8 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token: accessToken,
|
||||||
|
refreshToken,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@ -147,13 +146,19 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
* Benutzer abmelden
|
* Benutzer abmelden und Refresh-Tokens widerrufen
|
||||||
*/
|
*/
|
||||||
router.post('/logout', authenticateToken, (req, res) => {
|
router.post('/logout', authenticateToken, (req, res) => {
|
||||||
// Bei JWT gibt es serverseitig nichts zu tun
|
try {
|
||||||
// Client muss Token löschen
|
// Alle Refresh-Tokens des Benutzers löschen
|
||||||
|
revokeAllRefreshTokens(req.user.id);
|
||||||
|
|
||||||
logger.info(`Logout: ${req.user.username}`);
|
logger.info(`Logout: ${req.user.username}`);
|
||||||
res.json({ message: 'Erfolgreich abgemeldet' });
|
res.json({ message: 'Erfolgreich abgemeldet' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Logout-Fehler:', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Logout fehlgeschlagen' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -200,26 +205,67 @@ router.get('/me', authenticateToken, (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/refresh
|
* POST /api/auth/refresh
|
||||||
* Token erneuern
|
* Token mit Refresh-Token erneuern
|
||||||
*/
|
*/
|
||||||
router.post('/refresh', authenticateToken, (req, res) => {
|
router.post('/refresh', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
const ip = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
// Fallback für alte Clients - mit Access Token authentifizieren
|
||||||
|
if (req.headers.authorization) {
|
||||||
|
return legacyRefresh(req, res);
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: 'Refresh-Token erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuen Access-Token mit Refresh-Token generieren
|
||||||
|
const accessToken = await refreshAccessToken(refreshToken, ip, userAgent);
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
|
||||||
|
// User-Daten für CSRF-Token abrufen
|
||||||
|
const decoded = require('jsonwebtoken').decode(accessToken);
|
||||||
|
const csrfToken = getTokenForUser(decoded.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token: accessToken,
|
||||||
|
csrfToken
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Token-Refresh Fehler:', { error: error.message });
|
||||||
|
res.status(401).json({ error: 'Token-Erneuerung fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy Refresh für Rückwärtskompatibilität
|
||||||
|
function legacyRefresh(req, res) {
|
||||||
|
// Prüfe Authorization Header
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const user = require('../middleware/auth').verifyToken(token);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Token ungültig' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const dbUser = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = generateToken(user);
|
const newToken = generateToken(dbUser);
|
||||||
const csrfToken = getTokenForUser(user.id);
|
const csrfToken = getTokenForUser(dbUser.id);
|
||||||
|
|
||||||
res.json({ token, csrfToken });
|
res.json({ token: newToken, csrfToken });
|
||||||
} catch (error) {
|
|
||||||
logger.error('Token-Refresh Fehler:', { error: error.message });
|
|
||||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/auth/password
|
* PUT /api/auth/password
|
||||||
|
|||||||
643
backend/routes/coding.js
Normale Datei
643
backend/routes/coding.js
Normale Datei
@ -0,0 +1,643 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Coding Routes
|
||||||
|
* ========================
|
||||||
|
* Verwaltung von Server-Anwendungen mit Claude/Codex Integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { getDb } = require('../database');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const gitService = require('../services/gitService');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob ein Pfad ein Server-Pfad (Linux) ist
|
||||||
|
*/
|
||||||
|
function isServerPath(localPath) {
|
||||||
|
return localPath && localPath.startsWith('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt CLAUDE.md in ein Verzeichnis
|
||||||
|
*/
|
||||||
|
function writeCLAUDEmd(directoryPath, content) {
|
||||||
|
if (!content || !directoryPath) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claudePath = path.join(directoryPath, 'CLAUDE.md');
|
||||||
|
fs.writeFileSync(claudePath, content, 'utf8');
|
||||||
|
logger.info(`CLAUDE.md geschrieben: ${claudePath}`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('CLAUDE.md schreiben fehlgeschlagen:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest CLAUDE.md aus einem Verzeichnis
|
||||||
|
*/
|
||||||
|
function readCLAUDEmd(directoryPath) {
|
||||||
|
if (!directoryPath) {
|
||||||
|
logger.info('readCLAUDEmd: No directoryPath provided');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const claudePath = path.join(directoryPath, 'CLAUDE.md');
|
||||||
|
logger.info(`readCLAUDEmd: Checking path ${claudePath}`);
|
||||||
|
|
||||||
|
if (fs.existsSync(claudePath)) {
|
||||||
|
const content = fs.readFileSync(claudePath, 'utf8');
|
||||||
|
logger.info(`readCLAUDEmd: Successfully read ${content.length} characters from ${claudePath}`);
|
||||||
|
return content;
|
||||||
|
} else {
|
||||||
|
logger.info(`readCLAUDEmd: File does not exist: ${claudePath}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('CLAUDE.md lesen fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/coding/directories
|
||||||
|
* Alle Coding-Verzeichnisse abrufen
|
||||||
|
*/
|
||||||
|
router.get('/directories', (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const directories = db.prepare(`
|
||||||
|
SELECT cd.*, u.display_name as creator_name
|
||||||
|
FROM coding_directories cd
|
||||||
|
LEFT JOIN users u ON cd.created_by = u.id
|
||||||
|
ORDER BY cd.position ASC, cd.name ASC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json(directories.map(dir => {
|
||||||
|
// CLAUDE.md aus dem Dateisystem lesen falls vorhanden
|
||||||
|
let claudeMdFromDisk = null;
|
||||||
|
if (isServerPath(dir.local_path)) {
|
||||||
|
claudeMdFromDisk = readCLAUDEmd(dir.local_path);
|
||||||
|
// Fallback: Wenn Pfad /home/claude-dev/TaskMate ist, versuche /app/taskmate-source
|
||||||
|
if (!claudeMdFromDisk && dir.local_path === '/home/claude-dev/TaskMate') {
|
||||||
|
logger.info('Trying fallback path for TaskMate: /app/taskmate-source');
|
||||||
|
claudeMdFromDisk = readCLAUDEmd('/app/taskmate-source');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dir.id,
|
||||||
|
name: dir.name,
|
||||||
|
localPath: dir.local_path,
|
||||||
|
description: dir.description,
|
||||||
|
color: dir.color,
|
||||||
|
claudeInstructions: dir.claude_instructions,
|
||||||
|
claudeMdFromDisk: claudeMdFromDisk,
|
||||||
|
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||||
|
giteaRepoUrl: dir.gitea_repo_url,
|
||||||
|
giteaRepoOwner: dir.gitea_repo_owner,
|
||||||
|
giteaRepoName: dir.gitea_repo_name,
|
||||||
|
defaultBranch: dir.default_branch,
|
||||||
|
lastSync: dir.last_sync,
|
||||||
|
position: dir.position,
|
||||||
|
createdAt: dir.created_at,
|
||||||
|
createdBy: dir.created_by,
|
||||||
|
creatorName: dir.creator_name
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Abrufen der Coding-Verzeichnisse:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/coding/directories
|
||||||
|
* Neues Coding-Verzeichnis erstellen
|
||||||
|
*/
|
||||||
|
router.post('/directories', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch } = req.body;
|
||||||
|
|
||||||
|
if (!name || !localPath) {
|
||||||
|
return res.status(400).json({ error: 'Name und Server-Pfad sind erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Prüfe ob Pfad bereits existiert
|
||||||
|
const existing = db.prepare('SELECT id FROM coding_directories WHERE local_path = ?').get(localPath);
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: 'Diese Anwendung ist bereits registriert' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Höchste Position ermitteln
|
||||||
|
const maxPos = db.prepare('SELECT COALESCE(MAX(position), -1) as max FROM coding_directories').get().max;
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO coding_directories (name, local_path, description, color, claude_instructions, gitea_repo_url, gitea_repo_owner, gitea_repo_name, default_branch, position, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
name,
|
||||||
|
localPath,
|
||||||
|
description || null,
|
||||||
|
color || '#4F46E5',
|
||||||
|
null, // claudeInstructions wird nicht mehr gespeichert
|
||||||
|
giteaRepoUrl || null,
|
||||||
|
giteaRepoOwner || null,
|
||||||
|
giteaRepoName || null,
|
||||||
|
defaultBranch || 'main',
|
||||||
|
maxPos + 1,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
|
||||||
|
// Ordner erstellen falls nicht vorhanden
|
||||||
|
let directoryCreated = false;
|
||||||
|
if (isServerPath(localPath) && !fs.existsSync(localPath)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(localPath, { recursive: true });
|
||||||
|
directoryCreated = true;
|
||||||
|
logger.info(`Anwendungsordner erstellt: ${localPath}`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Ordner erstellen fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLAUDE.md wird nicht mehr geschrieben - nur readonly
|
||||||
|
let claudeMdWritten = false;
|
||||||
|
|
||||||
|
logger.info(`Coding-Anwendung erstellt: ${name} (${localPath})`);
|
||||||
|
|
||||||
|
// CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige
|
||||||
|
const claudeMdFromDisk = isServerPath(directory.local_path) ? readCLAUDEmd(directory.local_path) : null;
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id: directory.id,
|
||||||
|
name: directory.name,
|
||||||
|
localPath: directory.local_path,
|
||||||
|
description: directory.description,
|
||||||
|
color: directory.color,
|
||||||
|
claudeInstructions: directory.claude_instructions,
|
||||||
|
claudeMdFromDisk: claudeMdFromDisk,
|
||||||
|
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||||
|
giteaRepoUrl: directory.gitea_repo_url,
|
||||||
|
giteaRepoOwner: directory.gitea_repo_owner,
|
||||||
|
giteaRepoName: directory.gitea_repo_name,
|
||||||
|
defaultBranch: directory.default_branch,
|
||||||
|
position: directory.position,
|
||||||
|
createdAt: directory.created_at,
|
||||||
|
directoryCreated,
|
||||||
|
claudeMdWritten
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Erstellen der Coding-Anwendung:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/coding/directories/:id
|
||||||
|
* Coding-Anwendung aktualisieren
|
||||||
|
*/
|
||||||
|
router.put('/directories/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch, position } = req.body;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob neuer Pfad bereits von anderem Eintrag verwendet wird
|
||||||
|
if (localPath && localPath !== existing.local_path) {
|
||||||
|
const pathExists = db.prepare('SELECT id FROM coding_directories WHERE local_path = ? AND id != ?').get(localPath, id);
|
||||||
|
if (pathExists) {
|
||||||
|
return res.status(400).json({ error: 'Dieser Server-Pfad ist bereits registriert' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE coding_directories SET
|
||||||
|
name = COALESCE(?, name),
|
||||||
|
local_path = COALESCE(?, local_path),
|
||||||
|
description = ?,
|
||||||
|
color = COALESCE(?, color),
|
||||||
|
claude_instructions = ?,
|
||||||
|
gitea_repo_url = ?,
|
||||||
|
gitea_repo_owner = ?,
|
||||||
|
gitea_repo_name = ?,
|
||||||
|
default_branch = COALESCE(?, default_branch),
|
||||||
|
position = COALESCE(?, position)
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
name || null,
|
||||||
|
localPath || null,
|
||||||
|
description !== undefined ? description : existing.description,
|
||||||
|
color || null,
|
||||||
|
null, // claudeInstructions wird nicht mehr aktualisiert
|
||||||
|
giteaRepoUrl !== undefined ? giteaRepoUrl : existing.gitea_repo_url,
|
||||||
|
giteaRepoOwner !== undefined ? giteaRepoOwner : existing.gitea_repo_owner,
|
||||||
|
giteaRepoName !== undefined ? giteaRepoName : existing.gitea_repo_name,
|
||||||
|
defaultBranch || null,
|
||||||
|
position !== undefined ? position : null,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
const finalPath = updated.local_path;
|
||||||
|
|
||||||
|
// Ordner erstellen falls nicht vorhanden
|
||||||
|
let directoryCreated = false;
|
||||||
|
if (isServerPath(finalPath) && !fs.existsSync(finalPath)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(finalPath, { recursive: true });
|
||||||
|
directoryCreated = true;
|
||||||
|
logger.info(`Anwendungsordner erstellt: ${finalPath}`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Ordner erstellen fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLAUDE.md wird nicht mehr geschrieben - nur readonly
|
||||||
|
let claudeMdWritten = false;
|
||||||
|
|
||||||
|
logger.info(`Coding-Anwendung aktualisiert: ${updated.name}`);
|
||||||
|
|
||||||
|
// CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige
|
||||||
|
const claudeMdFromDisk = isServerPath(updated.local_path) ? readCLAUDEmd(updated.local_path) : null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
localPath: updated.local_path,
|
||||||
|
description: updated.description,
|
||||||
|
color: updated.color,
|
||||||
|
claudeInstructions: updated.claude_instructions,
|
||||||
|
claudeMdFromDisk: claudeMdFromDisk,
|
||||||
|
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||||
|
giteaRepoUrl: updated.gitea_repo_url,
|
||||||
|
giteaRepoOwner: updated.gitea_repo_owner,
|
||||||
|
giteaRepoName: updated.gitea_repo_name,
|
||||||
|
defaultBranch: updated.default_branch,
|
||||||
|
position: updated.position,
|
||||||
|
createdAt: updated.created_at,
|
||||||
|
directoryCreated,
|
||||||
|
claudeMdWritten
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Aktualisieren der Coding-Anwendung:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/coding/directories/:id
|
||||||
|
* Coding-Anwendung löschen
|
||||||
|
*/
|
||||||
|
router.delete('/directories/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM coding_directories WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
logger.info(`Coding-Anwendung gelöscht: ${existing.name}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Anwendung gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Löschen der Coding-Anwendung:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/coding/directories/:id/status
|
||||||
|
* Git-Status eines Verzeichnisses abrufen
|
||||||
|
*/
|
||||||
|
router.get('/directories/:id/status', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPath = directory.local_path;
|
||||||
|
|
||||||
|
// Prüfe ob es ein Git-Repository ist
|
||||||
|
if (!gitService.isGitRepository(localPath)) {
|
||||||
|
return res.json({
|
||||||
|
isGitRepo: false,
|
||||||
|
message: 'Kein Git-Repository'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = gitService.getStatus(localPath);
|
||||||
|
|
||||||
|
if (!status.success) {
|
||||||
|
return res.status(500).json({ error: status.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
isGitRepo: true,
|
||||||
|
branch: status.branch,
|
||||||
|
hasChanges: status.hasChanges,
|
||||||
|
changes: status.changes,
|
||||||
|
ahead: status.ahead,
|
||||||
|
behind: status.behind,
|
||||||
|
isClean: status.isClean
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Abrufen des Git-Status:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/coding/directories/:id/fetch
|
||||||
|
* Git Fetch ausführen
|
||||||
|
*/
|
||||||
|
router.post('/directories/:id/fetch', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gitService.fetchRemote(directory.local_path);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last sync aktualisieren
|
||||||
|
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
logger.info(`Git fetch ausgeführt für: ${directory.name}`);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Fetch erfolgreich' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Git Fetch:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/coding/directories/:id/pull
|
||||||
|
* Git Pull ausführen
|
||||||
|
*/
|
||||||
|
router.post('/directories/:id/pull', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gitService.pullChanges(directory.local_path, { branch: directory.default_branch });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last sync aktualisieren
|
||||||
|
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
logger.info(`Git pull ausgeführt für: ${directory.name}`);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Pull erfolgreich', output: result.output });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Git Pull:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/coding/directories/:id/push
|
||||||
|
* Git Push ausführen
|
||||||
|
*/
|
||||||
|
router.post('/directories/:id/push', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { force } = req.body;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gitService.pushWithUpstream(directory.local_path, directory.default_branch, 'origin', force);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last sync aktualisieren
|
||||||
|
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
logger.info(`Git push ausgeführt für: ${directory.name}`);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Push erfolgreich', output: result.output });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Git Push:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/coding/directories/:id/commit
|
||||||
|
* Git Commit ausführen
|
||||||
|
*/
|
||||||
|
router.post('/directories/:id/commit', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { message } = req.body;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
if (!message || message.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'Commit-Nachricht erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage all changes
|
||||||
|
const stageResult = gitService.stageAll(directory.local_path);
|
||||||
|
if (!stageResult.success) {
|
||||||
|
return res.status(500).json({ error: stageResult.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit with author info
|
||||||
|
const author = {
|
||||||
|
name: req.user.display_name || req.user.username,
|
||||||
|
email: req.user.email || `${req.user.username}@taskmate.local`
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = gitService.commit(directory.local_path, message, author);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Git commit ausgeführt für: ${directory.name} - "${message}"`);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Commit erfolgreich', output: result.output });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Git Commit:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/coding/directories/:id/branches
|
||||||
|
* Branches abrufen
|
||||||
|
*/
|
||||||
|
router.get('/directories/:id/branches', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gitService.getBranches(directory.local_path);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ branches: result.branches });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Abrufen der Branches:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/coding/directories/:id/checkout
|
||||||
|
* Branch wechseln
|
||||||
|
*/
|
||||||
|
router.post('/directories/:id/checkout', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { branch } = req.body;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
if (!branch) {
|
||||||
|
return res.status(400).json({ error: 'Branch erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gitService.checkoutBranch(directory.local_path, branch);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Branch gewechselt für ${directory.name}: ${branch}`);
|
||||||
|
|
||||||
|
res.json({ success: true, message: `Gewechselt zu Branch: ${branch}` });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Branch-Wechsel:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/coding/validate-path
|
||||||
|
* Pfad validieren
|
||||||
|
*/
|
||||||
|
router.post('/validate-path', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { path: localPath } = req.body;
|
||||||
|
|
||||||
|
if (!localPath) {
|
||||||
|
return res.status(400).json({ error: 'Pfad erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur Server-Pfade können validiert werden
|
||||||
|
if (isServerPath(localPath)) {
|
||||||
|
const containerPath = gitService.windowsToContainerPath(localPath);
|
||||||
|
const exists = fs.existsSync(containerPath);
|
||||||
|
const isGitRepo = exists && gitService.isGitRepository(localPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
valid: true,
|
||||||
|
exists,
|
||||||
|
isGitRepo,
|
||||||
|
isServerPath: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Windows-Pfad kann nicht serverseitig validiert werden
|
||||||
|
res.json({
|
||||||
|
valid: true,
|
||||||
|
exists: null,
|
||||||
|
isGitRepo: null,
|
||||||
|
isServerPath: false,
|
||||||
|
message: 'Windows-Pfade können nicht serverseitig validiert werden'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler bei der Pfad-Validierung:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/coding/directories/:id/commits
|
||||||
|
* Commit-Historie abrufen
|
||||||
|
*/
|
||||||
|
router.get('/directories/:id/commits', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||||
|
if (!directory) {
|
||||||
|
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gitService.getCommitHistory(directory.local_path, limit);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ commits: result.commits });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fehler beim Abrufen der Commit-Historie:', error);
|
||||||
|
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@ -4,6 +4,9 @@
|
|||||||
* Node.js/Express Backend mit Socket.io für Echtzeit-Sync
|
* Node.js/Express Backend mit Socket.io für Echtzeit-Sync
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Umgebungsvariablen laden (muss ganz oben stehen!)
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
@ -42,6 +45,7 @@ const gitRoutes = require('./routes/git');
|
|||||||
const applicationsRoutes = require('./routes/applications');
|
const applicationsRoutes = require('./routes/applications');
|
||||||
const giteaRoutes = require('./routes/gitea');
|
const giteaRoutes = require('./routes/gitea');
|
||||||
const knowledgeRoutes = require('./routes/knowledge');
|
const knowledgeRoutes = require('./routes/knowledge');
|
||||||
|
const codingRoutes = require('./routes/coding');
|
||||||
|
|
||||||
// Express App erstellen
|
// Express App erstellen
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -59,17 +63,18 @@ const io = new Server(server, {
|
|||||||
// MIDDLEWARE
|
// MIDDLEWARE
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Sicherheits-Header
|
// Erweiterte Sicherheits-Header (CSP temporär deaktiviert für Login-Fix)
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: false, // Temporär deaktiviert
|
||||||
directives: {
|
hsts: {
|
||||||
defaultSrc: ["'self'"],
|
maxAge: 31536000, // 1 Jahr
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
includeSubDomains: true,
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
preload: true
|
||||||
imgSrc: ["'self'", "data:", "blob:"],
|
},
|
||||||
scriptSrc: ["'self'"],
|
noSniff: true,
|
||||||
connectSrc: ["'self'", "ws:", "wss:"]
|
xssFilter: true,
|
||||||
}
|
referrerPolicy: {
|
||||||
|
policy: "strict-origin-when-cross-origin"
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -86,6 +91,10 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
|||||||
// Cookie Parser
|
// Cookie Parser
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Input Sanitization (vor allen anderen Middlewares)
|
||||||
|
const { sanitizeMiddleware } = require('./middleware/validation');
|
||||||
|
app.use(sanitizeMiddleware);
|
||||||
|
|
||||||
// Request Logging
|
// Request Logging
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@ -148,6 +157,9 @@ app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
|
|||||||
// Knowledge-Routes (Wissensmanagement)
|
// Knowledge-Routes (Wissensmanagement)
|
||||||
app.use('/api/knowledge', authenticateToken, csrfProtection, knowledgeRoutes);
|
app.use('/api/knowledge', authenticateToken, csrfProtection, knowledgeRoutes);
|
||||||
|
|
||||||
|
// Coding-Routes (Entwicklungsverzeichnisse mit Claude/Codex)
|
||||||
|
app.use('/api/coding', authenticateToken, csrfProtection, codingRoutes);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SOCKET.IO
|
// SOCKET.IO
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@ -21,6 +21,11 @@ function windowsToContainerPath(windowsPath) {
|
|||||||
return windowsPath;
|
return windowsPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spezialfall: TaskMate-Verzeichnis ist als /app/taskmate-source gemountet
|
||||||
|
if (windowsPath === '/home/claude-dev/TaskMate') {
|
||||||
|
return '/app/taskmate-source';
|
||||||
|
}
|
||||||
|
|
||||||
// Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo")
|
// Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo")
|
||||||
const normalized = windowsPath.replace(/\\/g, '/');
|
const normalized = windowsPath.replace(/\\/g, '/');
|
||||||
const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/);
|
const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/);
|
||||||
@ -73,8 +78,12 @@ function isGitRepository(localPath) {
|
|||||||
const containerPath = windowsToContainerPath(localPath);
|
const containerPath = windowsToContainerPath(localPath);
|
||||||
try {
|
try {
|
||||||
const gitDir = path.join(containerPath, '.git');
|
const gitDir = path.join(containerPath, '.git');
|
||||||
return fs.existsSync(gitDir);
|
logger.info(`Git-Repository Check: ${localPath} -> ${containerPath} -> ${gitDir}`);
|
||||||
|
const exists = fs.existsSync(gitDir);
|
||||||
|
logger.info(`Git directory exists: ${exists}`);
|
||||||
|
return exists;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error(`Git-Repository Check failed: ${error.message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const logger = require('./logger');
|
const logger = require('./logger');
|
||||||
|
const { encryptFile, decryptFile, secureDelete } = require('./encryption');
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||||
const BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
const BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||||
@ -17,8 +18,9 @@ if (!fs.existsSync(BACKUP_DIR)) {
|
|||||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup erstellen
|
* Backup erstellen (mit einfacher Verschlüsselung)
|
||||||
*/
|
*/
|
||||||
function createBackup() {
|
function createBackup() {
|
||||||
try {
|
try {
|
||||||
@ -29,12 +31,27 @@ function createBackup() {
|
|||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
const backupName = `backup_${timestamp}.db`;
|
const backupName = `backup_${timestamp}.db`;
|
||||||
|
const encryptedName = `backup_${timestamp}.db.enc`;
|
||||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||||
|
const encryptedPath = path.join(BACKUP_DIR, encryptedName);
|
||||||
|
|
||||||
// Datenbank kopieren
|
// 1. Normales Backup erstellen (für Kompatibilität)
|
||||||
fs.copyFileSync(DB_FILE, backupPath);
|
fs.copyFileSync(DB_FILE, backupPath);
|
||||||
|
|
||||||
// WAL-Datei auch sichern falls vorhanden
|
// 2. Verschlüsseltes Backup erstellen (zusätzlich)
|
||||||
|
if (process.env.ENCRYPTION_KEY) {
|
||||||
|
try {
|
||||||
|
if (encryptFile(DB_FILE, encryptedPath)) {
|
||||||
|
logger.info(`Verschlüsseltes Backup erstellt: ${encryptedName}`);
|
||||||
|
} else {
|
||||||
|
logger.warn('Verschlüsselung fehlgeschlagen, nur normales Backup erstellt');
|
||||||
|
}
|
||||||
|
} catch (encError) {
|
||||||
|
logger.warn('Verschlüsselung fehlgeschlagen, nur normales Backup erstellt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. WAL-Datei sichern falls vorhanden
|
||||||
const walFile = DB_FILE + '-wal';
|
const walFile = DB_FILE + '-wal';
|
||||||
if (fs.existsSync(walFile)) {
|
if (fs.existsSync(walFile)) {
|
||||||
fs.copyFileSync(walFile, backupPath + '-wal');
|
fs.copyFileSync(walFile, backupPath + '-wal');
|
||||||
@ -53,12 +70,12 @@ function createBackup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alte Backups löschen
|
* Alte Backups löschen (verschlüsselte)
|
||||||
*/
|
*/
|
||||||
function cleanupOldBackups(keepCount = 30) {
|
function cleanupOldBackups(keepCount = 30) {
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(BACKUP_DIR)
|
const files = fs.readdirSync(BACKUP_DIR)
|
||||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
|
.filter(f => f.startsWith('backup_') && f.endsWith('.db.enc'))
|
||||||
.sort()
|
.sort()
|
||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
@ -66,15 +83,15 @@ function cleanupOldBackups(keepCount = 30) {
|
|||||||
|
|
||||||
toDelete.forEach(file => {
|
toDelete.forEach(file => {
|
||||||
const filePath = path.join(BACKUP_DIR, file);
|
const filePath = path.join(BACKUP_DIR, file);
|
||||||
fs.unlinkSync(filePath);
|
secureDelete(filePath);
|
||||||
|
|
||||||
// WAL-Datei auch löschen falls vorhanden
|
// Verschlüsselte WAL-Datei auch löschen falls vorhanden
|
||||||
const walPath = filePath + '-wal';
|
const walPath = filePath + '-wal';
|
||||||
if (fs.existsSync(walPath)) {
|
if (fs.existsSync(walPath)) {
|
||||||
fs.unlinkSync(walPath);
|
secureDelete(walPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Altes Backup gelöscht: ${file}`);
|
logger.info(`Altes Backup sicher gelöscht: ${file}`);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fehler beim Aufräumen alter Backups:', { error: error.message });
|
logger.error('Fehler beim Aufräumen alter Backups:', { error: error.message });
|
||||||
@ -82,32 +99,50 @@ function cleanupOldBackups(keepCount = 30) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup wiederherstellen
|
* Backup wiederherstellen (entschlüsselt)
|
||||||
*/
|
*/
|
||||||
function restoreBackup(backupName) {
|
function restoreBackup(backupName) {
|
||||||
try {
|
try {
|
||||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
const encryptedBackupPath = path.join(BACKUP_DIR, backupName);
|
||||||
|
|
||||||
if (!fs.existsSync(backupPath)) {
|
if (!fs.existsSync(encryptedBackupPath)) {
|
||||||
throw new Error(`Backup nicht gefunden: ${backupName}`);
|
throw new Error(`Backup nicht gefunden: ${backupName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aktuelles DB sichern bevor überschrieben wird
|
// Aktuelles DB verschlüsselt sichern bevor überschrieben wird
|
||||||
if (fs.existsSync(DB_FILE)) {
|
if (fs.existsSync(DB_FILE)) {
|
||||||
const safetyBackup = DB_FILE + '.before-restore';
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
fs.copyFileSync(DB_FILE, safetyBackup);
|
const safetyBackupPath = DB_FILE + `.before-restore-${timestamp}.enc`;
|
||||||
|
if (!encryptFile(DB_FILE, safetyBackupPath)) {
|
||||||
|
logger.warn('Sicherheitsbackup vor Wiederherstellung fehlgeschlagen');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backup wiederherstellen
|
// Temporäre entschlüsselte Datei
|
||||||
fs.copyFileSync(backupPath, DB_FILE);
|
const tempRestorePath = path.join(BACKUP_DIR, `temp_restore_${Date.now()}.db`);
|
||||||
|
|
||||||
|
// Backup entschlüsseln
|
||||||
|
if (!decryptFile(encryptedBackupPath, tempRestorePath)) {
|
||||||
|
throw new Error('Backup-Entschlüsselung fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entschlüsselte DB kopieren
|
||||||
|
fs.copyFileSync(tempRestorePath, DB_FILE);
|
||||||
|
|
||||||
// WAL-Datei auch wiederherstellen falls vorhanden
|
// WAL-Datei auch wiederherstellen falls vorhanden
|
||||||
const walBackup = backupPath + '-wal';
|
const encryptedWalBackup = encryptedBackupPath + '-wal';
|
||||||
if (fs.existsSync(walBackup)) {
|
if (fs.existsSync(encryptedWalBackup)) {
|
||||||
fs.copyFileSync(walBackup, DB_FILE + '-wal');
|
const tempWalPath = tempRestorePath + '-wal';
|
||||||
|
if (decryptFile(encryptedWalBackup, tempWalPath)) {
|
||||||
|
fs.copyFileSync(tempWalPath, DB_FILE + '-wal');
|
||||||
|
secureDelete(tempWalPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Backup wiederhergestellt: ${backupName}`);
|
// Temporäre entschlüsselte Dateien sicher löschen
|
||||||
|
secureDelete(tempRestorePath);
|
||||||
|
|
||||||
|
logger.info(`Verschlüsseltes Backup wiederhergestellt: ${backupName}`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Restore-Fehler:', { error: error.message });
|
logger.error('Restore-Fehler:', { error: error.message });
|
||||||
@ -116,12 +151,12 @@ function restoreBackup(backupName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste aller Backups
|
* Liste aller verschlüsselten Backups
|
||||||
*/
|
*/
|
||||||
function listBackups() {
|
function listBackups() {
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(BACKUP_DIR)
|
const files = fs.readdirSync(BACKUP_DIR)
|
||||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
|
.filter(f => f.startsWith('backup_') && f.endsWith('.db.enc'))
|
||||||
.map(f => {
|
.map(f => {
|
||||||
const filePath = path.join(BACKUP_DIR, f);
|
const filePath = path.join(BACKUP_DIR, f);
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
|
|||||||
237
backend/utils/encryption.js
Normale Datei
237
backend/utils/encryption.js
Normale Datei
@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Encryption Utilities
|
||||||
|
* ================================
|
||||||
|
* Verschlüsselung für Backups und sensitive Daten
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-cbc';
|
||||||
|
const KEY_LENGTH = 32; // 256 bits
|
||||||
|
const IV_LENGTH = 16; // 128 bits
|
||||||
|
const SALT_LENGTH = 32;
|
||||||
|
const TAG_LENGTH = 16;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encryption Key aus Umgebung oder generiert
|
||||||
|
*/
|
||||||
|
function getEncryptionKey() {
|
||||||
|
let key = process.env.ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
// Generiere neuen Key falls nicht vorhanden
|
||||||
|
key = crypto.randomBytes(KEY_LENGTH).toString('hex');
|
||||||
|
logger.warn('Encryption Key wurde automatisch generiert. Speichere ihn in der .env: ENCRYPTION_KEY=' + key);
|
||||||
|
return Buffer.from(key, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiere Key-Length
|
||||||
|
if (key.length !== KEY_LENGTH * 2) { // Hex-String ist doppelt so lang
|
||||||
|
throw new Error(`Encryption Key muss ${KEY_LENGTH * 2} Hex-Zeichen haben`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(key, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key aus Passwort ableiten (PBKDF2)
|
||||||
|
*/
|
||||||
|
function deriveKeyFromPassword(password, salt) {
|
||||||
|
return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datei verschlüsseln
|
||||||
|
*/
|
||||||
|
function encryptFile(inputPath, outputPath, password = null) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(inputPath);
|
||||||
|
|
||||||
|
// Salt und IV generieren
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
// Key ableiten
|
||||||
|
const key = password
|
||||||
|
? deriveKeyFromPassword(password, salt)
|
||||||
|
: getEncryptionKey();
|
||||||
|
|
||||||
|
// Verschlüsselung
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||||
|
|
||||||
|
// Header + Salt + IV + verschlüsselte Daten
|
||||||
|
const header = Buffer.from('TMENC001', 'ascii'); // TaskMate Encryption v1
|
||||||
|
const result = Buffer.concat([
|
||||||
|
header,
|
||||||
|
salt,
|
||||||
|
iv,
|
||||||
|
encrypted
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, result);
|
||||||
|
logger.info(`Datei verschlüsselt: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Verschlüsselung fehlgeschlagen: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datei entschlüsseln
|
||||||
|
*/
|
||||||
|
function decryptFile(inputPath, outputPath, password = null) {
|
||||||
|
try {
|
||||||
|
const encryptedData = fs.readFileSync(inputPath);
|
||||||
|
|
||||||
|
// Header prüfen
|
||||||
|
const header = encryptedData.subarray(0, 8);
|
||||||
|
if (header.toString('ascii') !== 'TMENC001') {
|
||||||
|
throw new Error('Ungültiges verschlüsseltes Datei-Format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Komponenten extrahieren
|
||||||
|
let offset = 8;
|
||||||
|
const salt = encryptedData.subarray(offset, offset + SALT_LENGTH);
|
||||||
|
offset += SALT_LENGTH;
|
||||||
|
|
||||||
|
const iv = encryptedData.subarray(offset, offset + IV_LENGTH);
|
||||||
|
offset += IV_LENGTH;
|
||||||
|
|
||||||
|
const encrypted = encryptedData.subarray(offset);
|
||||||
|
|
||||||
|
// Key ableiten
|
||||||
|
const key = password
|
||||||
|
? deriveKeyFromPassword(password, salt)
|
||||||
|
: getEncryptionKey();
|
||||||
|
|
||||||
|
// Entschlüsselung
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(encrypted),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, decrypted);
|
||||||
|
logger.info(`Datei entschlüsselt: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Entschlüsselung fehlgeschlagen: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String verschlüsseln (für Passwörter etc.)
|
||||||
|
*/
|
||||||
|
function encryptString(plaintext, password = null) {
|
||||||
|
try {
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
|
||||||
|
const key = password
|
||||||
|
? deriveKeyFromPassword(password, salt)
|
||||||
|
: getEncryptionKey();
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(Buffer.from(plaintext, 'utf8')),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Base64 kodiert zurückgeben
|
||||||
|
const result = Buffer.concat([salt, iv, encrypted]);
|
||||||
|
return result.toString('base64');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`String-Verschlüsselung fehlgeschlagen: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String entschlüsseln
|
||||||
|
*/
|
||||||
|
function decryptString(encryptedString, password = null) {
|
||||||
|
try {
|
||||||
|
const data = Buffer.from(encryptedString, 'base64');
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
const salt = data.subarray(offset, offset + SALT_LENGTH);
|
||||||
|
offset += SALT_LENGTH;
|
||||||
|
|
||||||
|
const iv = data.subarray(offset, offset + IV_LENGTH);
|
||||||
|
offset += IV_LENGTH;
|
||||||
|
|
||||||
|
const encrypted = data.subarray(offset);
|
||||||
|
|
||||||
|
const key = password
|
||||||
|
? deriveKeyFromPassword(password, salt)
|
||||||
|
: getEncryptionKey();
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(encrypted),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`String-Entschlüsselung fehlgeschlagen: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sicheres Löschen einer Datei (Überschreiben)
|
||||||
|
*/
|
||||||
|
function secureDelete(filePath) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
const fileSize = stats.size;
|
||||||
|
|
||||||
|
// Datei mehrfach mit Zufallsdaten überschreiben
|
||||||
|
const fd = fs.openSync(filePath, 'r+');
|
||||||
|
|
||||||
|
for (let pass = 0; pass < 3; pass++) {
|
||||||
|
const randomData = crypto.randomBytes(fileSize);
|
||||||
|
fs.writeSync(fd, randomData, 0, fileSize, 0);
|
||||||
|
fs.fsyncSync(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.closeSync(fd);
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
|
||||||
|
logger.info(`Datei sicher gelöscht: ${path.basename(filePath)}`);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Sicheres Löschen fehlgeschlagen: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
encryptFile,
|
||||||
|
decryptFile,
|
||||||
|
encryptString,
|
||||||
|
decryptString,
|
||||||
|
secureDelete,
|
||||||
|
getEncryptionKey
|
||||||
|
};
|
||||||
BIN
data/taskmate.db
BIN
data/taskmate.db
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
@ -31,6 +31,7 @@ services:
|
|||||||
- USER2_PASSWORD=${USER2_PASSWORD:-changeme456}
|
- USER2_PASSWORD=${USER2_PASSWORD:-changeme456}
|
||||||
- USER2_DISPLAYNAME=${USER2_DISPLAYNAME:-Benutzer 2}
|
- USER2_DISPLAYNAME=${USER2_DISPLAYNAME:-Benutzer 2}
|
||||||
- USER2_COLOR=${USER2_COLOR:-#FF9500}
|
- USER2_COLOR=${USER2_COLOR:-#FF9500}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@ -431,81 +431,163 @@
|
|||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upload Types */
|
/* =========================
|
||||||
.admin-upload-types {
|
Extension Settings (New)
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
.admin-upload-extensions {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-upload-types h3 {
|
.admin-upload-extensions h3 {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upload Category */
|
/* Extension Tags Container */
|
||||||
.upload-category {
|
.extension-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
margin-bottom: 0.75rem;
|
min-height: 50px;
|
||||||
overflow: hidden;
|
margin-bottom: 1rem;
|
||||||
transition: all var(--transition-fast);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-category:hover {
|
.extension-empty {
|
||||||
border-color: var(--border-default);
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-category.disabled {
|
/* Single Extension Tag */
|
||||||
opacity: 0.5;
|
.extension-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-category.disabled .upload-category-types {
|
.extension-tag-remove {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-category-header {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-category-toggle {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: center;
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-category-toggle input[type="checkbox"] {
|
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
accent-color: var(--primary);
|
padding: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-category-title {
|
.extension-tag-remove:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-tag-remove svg {
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add Extension Group */
|
||||||
|
.extension-add-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-add-group label {
|
||||||
|
display: block;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-category-types {
|
.extension-input-row {
|
||||||
padding: 0.75rem 1rem;
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-input:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--shadow-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suggestions */
|
||||||
|
.extension-suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-suggestions-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-suggestions-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-type-tag {
|
.extension-suggestion {
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
background: var(--primary-light);
|
background: var(--bg-tertiary);
|
||||||
color: var(--primary);
|
border: 1px dashed var(--border-default);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
|
color: var(--text-secondary);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-suggestion:hover {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-no-suggestions {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upload Actions */
|
/* Upload Actions */
|
||||||
@ -515,6 +597,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
|
/* Passwort-Input Gruppe */
|
||||||
|
.password-input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-group input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-group .btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-group .btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.admin-header {
|
.admin-header {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|||||||
555
frontend/css/coding.css
Normale Datei
555
frontend/css/coding.css
Normale Datei
@ -0,0 +1,555 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Coding Tab Styles
|
||||||
|
* ============================
|
||||||
|
* Styling für die Coding-Verzeichnis-Verwaltung
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* View Container */
|
||||||
|
.view-coding {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.coding-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-header-centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid Layout */
|
||||||
|
.coding-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kachel */
|
||||||
|
.coding-tile {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Farbbalken oben */
|
||||||
|
.coding-tile-color {
|
||||||
|
height: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tile Header */
|
||||||
|
.coding-tile-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1rem 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-menu {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-menu:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tile Content */
|
||||||
|
.coding-tile-content {
|
||||||
|
padding: 0 1rem;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-name {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-path {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-description {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLAUDE.md Badge */
|
||||||
|
.coding-tile-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #F59E0B, #D97706);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git Status */
|
||||||
|
.coding-tile-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-branch-badge {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge.loading {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge.clean {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #10B981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge.dirty {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #F59E0B;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge.error {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #EF4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge.ahead {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3B82F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge.behind {
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
color: #8B5CF6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons */
|
||||||
|
.coding-tile-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-claude {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(135deg, #F59E0B, #D97706);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-claude:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-claude:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-codex {
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(135deg, #10B981, #059669);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-codex:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-codex:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Git Actions */
|
||||||
|
.coding-tile-git {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-git .btn {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.coding-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-empty .empty-icon {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-empty .empty-icon svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-empty h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-empty p {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Command Modal */
|
||||||
|
.command-box {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-box code {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
word-break: break-all;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color Presets */
|
||||||
|
.color-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preset {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preset:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preset.selected {
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-custom {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-custom::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-picker-custom::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gitea Section in Modal */
|
||||||
|
.coding-gitea-section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-gitea-section summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-gitea-section summary:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.view-coding {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-header h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-header .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-git {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-git .btn {
|
||||||
|
flex: 1 1 calc(50% - 0.25rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-box {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-box code {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.coding-empty {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coding-tile-git .btn {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLAUDE.md Textarea im Modal */
|
||||||
|
#coding-claude-instructions {
|
||||||
|
font-family: var(--font-mono, 'Consolas', 'Monaco', monospace);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hint unter Labels */
|
||||||
|
.label-hint {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLAUDE.md Tabs */
|
||||||
|
.claude-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-tab {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-tab:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-tab.active {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLAUDE.md Link */
|
||||||
|
.claude-link-container {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-link {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-link:hover:not(:disabled) {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-link:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-text {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-link:disabled .claude-text {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CLAUDE.md Modal */
|
||||||
|
.claude-md-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 70vh;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claude-md-display {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-family: var(--font-mono, 'Consolas', 'Monaco', monospace);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
@ -413,6 +413,14 @@
|
|||||||
gap: var(--spacing-2);
|
gap: var(--spacing-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Avatar Container für mehrere Avatare */
|
||||||
|
.list-cell-assignee .avatar-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.list-cell-assignee .avatar {
|
.list-cell-assignee .avatar {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@ -424,6 +432,12 @@
|
|||||||
font-weight: var(--font-semibold);
|
font-weight: var(--font-semibold);
|
||||||
color: white;
|
color: white;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-cell-assignee .avatar:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-cell-assignee select {
|
.list-cell-assignee select {
|
||||||
@ -440,6 +454,28 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide assignee dropdown - show only avatars */
|
||||||
|
.list-cell-assignee .assignee-select {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty avatar placeholder */
|
||||||
|
.list-cell-assignee .avatar-empty {
|
||||||
|
background: var(--border-color) !important;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show dropdown when editing */
|
||||||
|
.list-cell-assignee.editing .assignee-select {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-cell-assignee.editing .avatar-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
.list-empty {
|
.list-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
472
frontend/css/mobile.css
Normale Datei
472
frontend/css/mobile.css
Normale Datei
@ -0,0 +1,472 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Mobile Styles
|
||||||
|
* ========================
|
||||||
|
* Touch-optimierte Mobile-Erfahrung
|
||||||
|
* Nur auf mobilen Breakpoints angewendet
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
DESKTOP: Mobile-Elemente verstecken
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.hamburger-btn,
|
||||||
|
.mobile-menu,
|
||||||
|
.mobile-menu-overlay,
|
||||||
|
.swipe-indicator {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MOBILE STYLES (max-width: 768px)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
HAMBURGER BUTTON
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.hamburger-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: calc(var(--z-modal) + 10);
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line {
|
||||||
|
display: block;
|
||||||
|
width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line + .hamburger-line {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger zu X Animation */
|
||||||
|
.hamburger-btn.active .hamburger-line:nth-child(1) {
|
||||||
|
transform: translateY(8px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.active .hamburger-line:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.active .hamburger-line:nth-child(3) {
|
||||||
|
transform: translateY(-8px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MOBILE SLIDE-IN MENU
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.mobile-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 280px;
|
||||||
|
max-width: 85vw;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
.mobile-menu-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--overlay-bg);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
z-index: calc(var(--z-modal) - 1);
|
||||||
|
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-overlay.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Header */
|
||||||
|
.mobile-menu-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--font-bold);
|
||||||
|
color: var(--primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-close {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background 0.2s;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-close:hover,
|
||||||
|
.mobile-menu-close:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Sections */
|
||||||
|
.mobile-menu-section {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Project Select */
|
||||||
|
.mobile-project-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation */
|
||||||
|
.mobile-menu-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
padding: var(--spacing-3) var(--spacing-4);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item:hover,
|
||||||
|
.mobile-nav-item:active {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-nav-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Section */
|
||||||
|
.mobile-menu-user {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-3);
|
||||||
|
margin-bottom: var(--spacing-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-name {
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-user-role {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: var(--font-medium);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn:hover,
|
||||||
|
.mobile-menu-btn:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn-danger {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn-danger:hover,
|
||||||
|
.mobile-menu-btn-danger:active {
|
||||||
|
background: var(--error-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
HEADER ANPASSUNGEN
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Desktop-Navigation verstecken */
|
||||||
|
.header-center .view-tabs {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left .project-selector {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Layout anpassen */
|
||||||
|
.header-left {
|
||||||
|
gap: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TOUCH DRAG & DROP FEEDBACK
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.task-card.touch-dragging {
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
opacity: 0.95;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card.touch-drag-placeholder {
|
||||||
|
opacity: 0.3;
|
||||||
|
border: 2px dashed var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-body.touch-drag-over {
|
||||||
|
background: var(--primary-light);
|
||||||
|
border: 2px dashed var(--primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
SWIPE INDIKATOREN
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.swipe-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--overlay-bg);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
z-index: var(--z-tooltip);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-indicator.left {
|
||||||
|
left: 0;
|
||||||
|
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-indicator.right {
|
||||||
|
right: 0;
|
||||||
|
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-indicator.visible {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipe-indicator svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BOARD VIEW - HORIZONTAL SCROLLING
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.view-board .board {
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-board .column {
|
||||||
|
scroll-snap-align: start;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
PREVENT TEXT SELECTION DURING GESTURES
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.is-swiping,
|
||||||
|
.is-swiping *,
|
||||||
|
.is-touch-dragging,
|
||||||
|
.is-touch-dragging * {
|
||||||
|
user-select: none !important;
|
||||||
|
-webkit-user-select: none !important;
|
||||||
|
-webkit-touch-callout: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
BODY SCROLL LOCK
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
body.mobile-menu-open {
|
||||||
|
overflow: hidden;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
TOUCH-FREUNDLICHE ELEMENTE
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
/* Groessere Touch-Targets */
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 70px;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task-Karten */
|
||||||
|
.task-card {
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
EXTRA SMALL MOBILE (max 480px)
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.mobile-menu {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line + .hamburger-line {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.active .hamburger-line:nth-child(1) {
|
||||||
|
transform: translateY(7px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn.active .hamburger-line:nth-child(3) {
|
||||||
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,8 +25,10 @@
|
|||||||
<link rel="stylesheet" href="css/proposals.css">
|
<link rel="stylesheet" href="css/proposals.css">
|
||||||
<link rel="stylesheet" href="css/notifications.css">
|
<link rel="stylesheet" href="css/notifications.css">
|
||||||
<link rel="stylesheet" href="css/gitea.css">
|
<link rel="stylesheet" href="css/gitea.css">
|
||||||
|
<link rel="stylesheet" href="css/coding.css">
|
||||||
<link rel="stylesheet" href="css/knowledge.css">
|
<link rel="stylesheet" href="css/knowledge.css">
|
||||||
<link rel="stylesheet" href="css/responsive.css">
|
<link rel="stylesheet" href="css/responsive.css">
|
||||||
|
<link rel="stylesheet" href="css/mobile.css">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="assets/icons/task.svg">
|
<link rel="icon" type="image/svg+xml" href="assets/icons/task.svg">
|
||||||
@ -102,98 +104,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dateitypen nach Kategorien -->
|
<!-- Erlaubte Dateiendungen -->
|
||||||
<div class="admin-upload-types">
|
<div class="admin-upload-extensions">
|
||||||
<h3>Erlaubte Dateiformate</h3>
|
<h3>Erlaubte Dateiendungen</h3>
|
||||||
|
|
||||||
<!-- Bildformate -->
|
<!-- Aktive Endungen als Tags -->
|
||||||
<div class="upload-category" data-category="images">
|
<div id="extension-tags" class="extension-tags">
|
||||||
<div class="upload-category-header">
|
<!-- Tags werden dynamisch gerendert -->
|
||||||
<label class="upload-category-toggle">
|
|
||||||
<input type="checkbox" id="upload-cat-images" checked>
|
|
||||||
<span class="upload-category-title">Bildformate</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-category-types">
|
|
||||||
<span class="upload-type-tag" data-type="image/jpeg">JPEG</span>
|
<!-- Neue Endung hinzufügen -->
|
||||||
<span class="upload-type-tag" data-type="image/png">PNG</span>
|
<div class="extension-add-group">
|
||||||
<span class="upload-type-tag" data-type="image/gif">GIF</span>
|
<label for="extension-input">Neue Endung hinzufügen</label>
|
||||||
<span class="upload-type-tag" data-type="image/webp">WebP</span>
|
<div class="extension-input-row">
|
||||||
<span class="upload-type-tag" data-type="image/svg+xml">SVG</span>
|
<input type="text" id="extension-input" class="extension-input" placeholder="z.B. xlsx" maxlength="10">
|
||||||
|
<button type="button" id="btn-add-extension" class="btn btn-secondary">+ Hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dokumentformate -->
|
<!-- Vorschläge -->
|
||||||
<div class="upload-category" data-category="documents">
|
<div class="extension-suggestions">
|
||||||
<div class="upload-category-header">
|
<span class="extension-suggestions-label">Vorschläge:</span>
|
||||||
<label class="upload-category-toggle">
|
<div id="extension-suggestions-list" class="extension-suggestions-list">
|
||||||
<input type="checkbox" id="upload-cat-documents" checked>
|
<!-- Vorschläge werden dynamisch gerendert -->
|
||||||
<span class="upload-category-title">Dokumentformate</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="upload-category-types">
|
|
||||||
<span class="upload-type-tag" data-type="application/pdf">PDF</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Office-Formate -->
|
|
||||||
<div class="upload-category" data-category="office">
|
|
||||||
<div class="upload-category-header">
|
|
||||||
<label class="upload-category-toggle">
|
|
||||||
<input type="checkbox" id="upload-cat-office" checked>
|
|
||||||
<span class="upload-category-title">Office-Formate</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="upload-category-types">
|
|
||||||
<span class="upload-type-tag" data-type="application/msword">DOC</span>
|
|
||||||
<span class="upload-type-tag" data-type="application/vnd.openxmlformats-officedocument.wordprocessingml.document">DOCX</span>
|
|
||||||
<span class="upload-type-tag" data-type="application/vnd.ms-excel">XLS</span>
|
|
||||||
<span class="upload-type-tag" data-type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">XLSX</span>
|
|
||||||
<span class="upload-type-tag" data-type="application/vnd.ms-powerpoint">PPT</span>
|
|
||||||
<span class="upload-type-tag" data-type="application/vnd.openxmlformats-officedocument.presentationml.presentation">PPTX</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Textformate -->
|
|
||||||
<div class="upload-category" data-category="text">
|
|
||||||
<div class="upload-category-header">
|
|
||||||
<label class="upload-category-toggle">
|
|
||||||
<input type="checkbox" id="upload-cat-text" checked>
|
|
||||||
<span class="upload-category-title">Textformate</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="upload-category-types">
|
|
||||||
<span class="upload-type-tag" data-type="text/plain">TXT</span>
|
|
||||||
<span class="upload-type-tag" data-type="text/csv">CSV</span>
|
|
||||||
<span class="upload-type-tag" data-type="text/markdown">Markdown</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Archivformate -->
|
|
||||||
<div class="upload-category" data-category="archives">
|
|
||||||
<div class="upload-category-header">
|
|
||||||
<label class="upload-category-toggle">
|
|
||||||
<input type="checkbox" id="upload-cat-archives" checked>
|
|
||||||
<span class="upload-category-title">Archivformate</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="upload-category-types">
|
|
||||||
<span class="upload-type-tag" data-type="application/zip">ZIP</span>
|
|
||||||
<span class="upload-type-tag" data-type="application/x-rar-compressed">RAR</span>
|
|
||||||
<span class="upload-type-tag" data-type="application/x-7z-compressed">7Z</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Datenformate -->
|
|
||||||
<div class="upload-category" data-category="data">
|
|
||||||
<div class="upload-category-header">
|
|
||||||
<label class="upload-category-toggle">
|
|
||||||
<input type="checkbox" id="upload-cat-data" checked>
|
|
||||||
<span class="upload-category-title">Datenformate</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="upload-category-types">
|
|
||||||
<span class="upload-type-tag" data-type="application/json">JSON</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -212,6 +145,13 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
|
<!-- Hamburger Menu Button (Mobile) -->
|
||||||
|
<button id="hamburger-btn" class="hamburger-btn" aria-label="Menu" aria-expanded="false">
|
||||||
|
<span class="hamburger-line"></span>
|
||||||
|
<span class="hamburger-line"></span>
|
||||||
|
<span class="hamburger-line"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<h1 class="logo">TaskMate</h1>
|
<h1 class="logo">TaskMate</h1>
|
||||||
|
|
||||||
<!-- Project Selector -->
|
<!-- Project Selector -->
|
||||||
@ -235,7 +175,7 @@
|
|||||||
<button class="view-tab" data-view="list">Liste</button>
|
<button class="view-tab" data-view="list">Liste</button>
|
||||||
<button class="view-tab" data-view="calendar">Kalender</button>
|
<button class="view-tab" data-view="calendar">Kalender</button>
|
||||||
<button class="view-tab" data-view="proposals">Genehmigung</button>
|
<button class="view-tab" data-view="proposals">Genehmigung</button>
|
||||||
<button class="view-tab" data-view="gitea">Gitea</button>
|
<button class="view-tab" data-view="coding">Coding</button>
|
||||||
<button class="view-tab" data-view="knowledge">Wissen</button>
|
<button class="view-tab" data-view="knowledge">Wissen</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -509,383 +449,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gitea View -->
|
<!-- Coding View -->
|
||||||
<div id="view-gitea" class="view view-gitea hidden">
|
<div id="view-coding" class="view view-coding hidden">
|
||||||
|
|
||||||
<!-- Modus-Schalter -->
|
|
||||||
<div id="gitea-mode-switch" class="gitea-mode-switch">
|
|
||||||
<button class="gitea-mode-btn active" data-mode="server">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><rect x="2" y="2" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><rect x="2" y="14" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></svg>
|
|
||||||
Server-Anwendung
|
|
||||||
</button>
|
|
||||||
<button class="gitea-mode-btn" data-mode="project">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
Projekt-Repository
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server-Modus Ansicht -->
|
|
||||||
<div id="gitea-server-mode" class="gitea-section">
|
|
||||||
|
|
||||||
<!-- Server Repository Info Header -->
|
|
||||||
<div class="gitea-repo-header">
|
|
||||||
<div class="repo-info">
|
|
||||||
<h2 id="server-repo-name">
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24"><rect x="2" y="2" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><rect x="2" y="14" width="20" height="8" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><circle cx="6" cy="6" r="1" fill="currentColor"/><circle cx="6" cy="18" r="1" fill="currentColor"/></svg>
|
|
||||||
<span>TaskMate Server</span>
|
|
||||||
</h2>
|
|
||||||
<a id="server-repo-url" class="repo-url" href="#" target="_blank"></a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server-Pfad Anzeige -->
|
|
||||||
<div class="gitea-local-path">
|
|
||||||
<span class="path-label">Server-Pfad:</span>
|
|
||||||
<code id="server-local-path-display">/home/claude-dev/TaskMate</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server Git-Status Panel -->
|
|
||||||
<div id="server-status-section" class="gitea-status-panel">
|
|
||||||
<div class="status-grid">
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="status-label">Branch</span>
|
|
||||||
<div class="branch-select-group">
|
|
||||||
<select id="server-branch-select" class="branch-select">
|
|
||||||
<!-- Branches dynamisch -->
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="status-label">Status</span>
|
|
||||||
<span id="server-status-indicator" class="status-badge">Prüfe...</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="status-label">Änderungen</span>
|
|
||||||
<span id="server-changes-count" class="changes-count">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server Git-Operationen -->
|
|
||||||
<div id="server-operations-section" class="gitea-operations-panel">
|
|
||||||
<h3>Git-Operationen</h3>
|
|
||||||
<div class="operations-grid">
|
|
||||||
<button id="btn-server-fetch" class="btn btn-secondary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" stroke="currentColor" stroke-width="2" fill="none"/><path d="M21 21v-5h-5" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
Fetch
|
|
||||||
</button>
|
|
||||||
<button id="btn-server-pull" class="btn btn-secondary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 5v14M19 12l-7 7-7-7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
Pull
|
|
||||||
</button>
|
|
||||||
<button id="btn-server-push" class="btn btn-primary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
Push
|
|
||||||
</button>
|
|
||||||
<button id="btn-server-commit" class="btn btn-secondary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 2v6M12 16v6M2 12h6M16 12h6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
Commit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server Änderungen-Liste -->
|
|
||||||
<div id="server-changes-section" class="gitea-changes-panel hidden">
|
|
||||||
<h3>Geänderte Dateien</h3>
|
|
||||||
<div id="server-changes-list" class="changes-list">
|
|
||||||
<!-- Dynamisch gefüllt -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Server Commit-Historie -->
|
|
||||||
<div id="server-commits-section" class="gitea-commits-panel">
|
|
||||||
<div class="commits-header">
|
|
||||||
<h3>Letzte Commits</h3>
|
|
||||||
<button id="btn-server-clear-commits" class="btn btn-small btn-secondary" title="Alle aus Anzeige entfernen">
|
|
||||||
Alle ausblenden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="server-commits-list" class="commits-list">
|
|
||||||
<!-- Dynamisch gefüllt -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Projekt-Modus: Browser-Upload Ansicht -->
|
|
||||||
<div id="gitea-browser-upload" class="gitea-section hidden">
|
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="gitea-config-header">
|
<div class="coding-header coding-header-centered">
|
||||||
<h2>
|
<button id="add-coding-directory-btn" class="btn btn-primary">
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
Lokales Verzeichnis hochladen
|
|
||||||
</h2>
|
|
||||||
<p>Wählen Sie ein Verzeichnis von Ihrem Computer und pushen Sie es direkt ins Gitea.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Browser-Kompatibilität Hinweis -->
|
|
||||||
<div id="browser-upload-compat" class="browser-compat-notice hidden">
|
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
|
|
||||||
<span>Die Verzeichnis-Auswahl funktioniert nur in Chrome, Edge oder Opera. In anderen Browsern können Sie Dateien per Drag & Drop hochladen.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schritt 1: Repository auswählen -->
|
|
||||||
<div class="upload-step">
|
|
||||||
<div class="step-header">
|
|
||||||
<span class="step-number">1</span>
|
|
||||||
<span class="step-title">Ziel-Repository auswählen</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="gitea-repo-select-group">
|
|
||||||
<select id="browser-upload-repo-select" class="form-control">
|
|
||||||
<option value="">-- Repository wählen --</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" id="btn-refresh-upload-repos" class="btn btn-icon" title="Repositories aktualisieren">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" stroke="currentColor" stroke-width="2" fill="none"/><path d="M21 21v-5h-5" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="browser-upload-branch">Ziel-Branch</label>
|
|
||||||
<input type="text" id="browser-upload-branch" class="form-control" value="main" placeholder="main">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schritt 2: Verzeichnis auswählen -->
|
|
||||||
<div class="upload-step">
|
|
||||||
<div class="step-header">
|
|
||||||
<span class="step-number">2</span>
|
|
||||||
<span class="step-title">Verzeichnis auswählen</span>
|
|
||||||
</div>
|
|
||||||
<div class="directory-picker">
|
|
||||||
<button type="button" id="btn-select-directory" class="btn btn-secondary btn-lg">
|
|
||||||
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
Verzeichnis auswählen
|
|
||||||
</button>
|
|
||||||
<div id="drop-zone" class="drop-zone hidden">
|
|
||||||
<svg viewBox="0 0 24 24" width="48" height="48"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
<p>Oder Verzeichnis hierher ziehen</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schritt 3: Datei-Vorschau (erscheint nach Auswahl) -->
|
|
||||||
<div id="upload-preview-section" class="upload-step hidden">
|
|
||||||
<div class="step-header">
|
|
||||||
<span class="step-number">3</span>
|
|
||||||
<span class="step-title">Ausgewählte Dateien</span>
|
|
||||||
<span id="upload-file-count" class="file-count">0 Dateien</span>
|
|
||||||
</div>
|
|
||||||
<div id="upload-files-list" class="upload-files-list">
|
|
||||||
<!-- Dynamisch gefüllt -->
|
|
||||||
</div>
|
|
||||||
<div class="excluded-info">
|
|
||||||
<small>Automatisch ausgeschlossen: .git, node_modules, __pycache__, .env, *.log</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schritt 4: Commit und Push -->
|
|
||||||
<div id="upload-commit-section" class="upload-step hidden">
|
|
||||||
<div class="step-header">
|
|
||||||
<span class="step-number">4</span>
|
|
||||||
<span class="step-title">Commit erstellen und pushen</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="browser-upload-commit-message">Commit-Nachricht</label>
|
|
||||||
<textarea id="browser-upload-commit-message" class="form-control" rows="2" placeholder="Beschreiben Sie Ihre Änderungen..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="upload-actions">
|
|
||||||
<button type="button" id="btn-cancel-upload" class="btn btn-secondary">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button type="button" id="btn-execute-upload" class="btn btn-primary">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
Commit & Push
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Progress Bar -->
|
|
||||||
<div id="upload-progress-container" class="upload-progress hidden">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div id="upload-progress-bar" class="progress-fill"></div>
|
|
||||||
</div>
|
|
||||||
<span id="upload-progress-text">0%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Projekt-Modus: Kein Projekt (altes Element, jetzt versteckt) -->
|
|
||||||
<div id="gitea-no-project" class="gitea-empty-state hidden">
|
|
||||||
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
<h3>Kein Projekt ausgewählt</h3>
|
|
||||||
<p>Wählen Sie ein Projekt aus, um die Git-Integration zu konfigurieren.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Konfiguration (wenn nicht verknüpft) -->
|
|
||||||
<div id="gitea-config-section" class="gitea-section hidden">
|
|
||||||
<div class="gitea-config-header">
|
|
||||||
<h2>Repository-Konfiguration</h2>
|
|
||||||
<p>Verknüpfen Sie dieses Projekt mit einem Git-Repository</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gitea-Verbindungsstatus -->
|
|
||||||
<div id="gitea-connection-status" class="gitea-connection-status">
|
|
||||||
<span class="status-indicator"></span>
|
|
||||||
<span class="status-text">Prüfe Verbindung...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="gitea-config-form" class="gitea-config-form">
|
|
||||||
<!-- Repository-Auswahl -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="gitea-repo-select">Bestehendes Repository auswählen</label>
|
|
||||||
<div class="gitea-repo-select-group">
|
|
||||||
<select id="gitea-repo-select" class="form-control">
|
|
||||||
<option value="">-- Repository wählen --</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" id="btn-refresh-repos" class="btn btn-icon" title="Repositories aktualisieren">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" stroke="currentColor" stroke-width="2" fill="none"/><path d="M21 21v-5h-5" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gitea-divider">
|
|
||||||
<span>oder</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Neues Repository erstellen -->
|
|
||||||
<div class="form-group">
|
|
||||||
<button type="button" id="btn-create-repo" class="btn btn-secondary btn-block">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
|
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/></svg>
|
||||||
Neues Repository erstellen
|
Anwendung hinzufügen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gitea-divider"></div>
|
<!-- Grid für Kacheln -->
|
||||||
|
<div id="coding-grid" class="coding-grid">
|
||||||
<!-- Lokaler Pfad -->
|
<!-- Kacheln werden per JS gerendert -->
|
||||||
<div class="form-group">
|
|
||||||
<label for="local-path-input">Lokaler Pfad (wo Claude Code arbeitet)</label>
|
|
||||||
<input type="text" id="local-path-input" class="form-control"
|
|
||||||
placeholder="z.B. D:\Projekte\MeinProjekt">
|
|
||||||
<span id="path-validation-result" class="form-hint"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default Branch -->
|
<!-- Empty State -->
|
||||||
<div class="form-group">
|
<div id="coding-empty" class="coding-empty hidden">
|
||||||
<label for="default-branch-input">Standard-Branch</label>
|
<div class="empty-icon">
|
||||||
<input type="text" id="default-branch-input" class="form-control"
|
<svg viewBox="0 0 24 24" width="64" height="64"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
value="main" placeholder="main">
|
|
||||||
</div>
|
</div>
|
||||||
|
<h3>Keine Anwendungen</h3>
|
||||||
<div class="form-actions">
|
<p>Füge deine erste Server-Anwendung hinzu, um mit Claude oder Codex zu arbeiten.</p>
|
||||||
<button type="submit" class="btn btn-primary" id="btn-save-config">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" stroke="currentColor" stroke-width="2" fill="none"/><path d="M17 21v-8H7v8M7 3v5h8" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
Konfiguration speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hauptansicht (wenn verknüpft) -->
|
|
||||||
<div id="gitea-main-section" class="gitea-section hidden">
|
|
||||||
|
|
||||||
<!-- Repository-Info-Header -->
|
|
||||||
<div class="gitea-repo-header">
|
|
||||||
<div class="repo-info">
|
|
||||||
<h2 id="gitea-repo-name">
|
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
<span>Repository</span>
|
|
||||||
</h2>
|
|
||||||
<a id="gitea-repo-url" class="repo-url" href="#" target="_blank"></a>
|
|
||||||
</div>
|
|
||||||
<div class="repo-actions">
|
|
||||||
<button id="btn-edit-config" class="btn btn-icon" title="Konfiguration bearbeiten">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" fill="none"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
</button>
|
|
||||||
<button id="btn-remove-config" class="btn btn-icon btn-danger-hover" title="Konfiguration entfernen">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lokaler Pfad Anzeige -->
|
|
||||||
<div class="gitea-local-path">
|
|
||||||
<span class="path-label">Lokaler Pfad:</span>
|
|
||||||
<code id="gitea-local-path-display"></code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Git-Status Panel -->
|
|
||||||
<div id="gitea-status-section" class="gitea-status-panel">
|
|
||||||
<div class="status-grid">
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="status-label">Branch</span>
|
|
||||||
<div class="branch-select-group">
|
|
||||||
<select id="branch-select" class="branch-select">
|
|
||||||
<!-- Branches dynamisch -->
|
|
||||||
</select>
|
|
||||||
<button id="btn-rename-branch" class="btn btn-small btn-icon" title="Branch umbenennen">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="status-label">Status</span>
|
|
||||||
<span id="git-status-indicator" class="status-badge">Prüfe...</span>
|
|
||||||
</div>
|
|
||||||
<div class="status-item">
|
|
||||||
<span class="status-label">Änderungen</span>
|
|
||||||
<span id="git-changes-count" class="changes-count">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Git-Operationen -->
|
|
||||||
<div id="gitea-operations-section" class="gitea-operations-panel">
|
|
||||||
<h3>Git-Operationen</h3>
|
|
||||||
<div class="operations-grid">
|
|
||||||
<button id="btn-git-fetch" class="btn btn-secondary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 3v5h5" stroke="currentColor" stroke-width="2" fill="none"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" stroke="currentColor" stroke-width="2" fill="none"/><path d="M21 21v-5h-5" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
Fetch
|
|
||||||
</button>
|
|
||||||
<button id="btn-git-pull" class="btn btn-secondary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 5v14M19 12l-7 7-7-7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
Pull
|
|
||||||
</button>
|
|
||||||
<button id="btn-git-push" class="btn btn-primary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
||||||
Push
|
|
||||||
</button>
|
|
||||||
<button id="btn-git-commit" class="btn btn-secondary operation-btn">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" fill="none"/><path d="M12 2v6M12 16v6M2 12h6M16 12h6" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
|
||||||
Commit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Änderungen-Liste -->
|
|
||||||
<div id="gitea-changes-section" class="gitea-changes-panel hidden">
|
|
||||||
<h3>Geänderte Dateien</h3>
|
|
||||||
<div id="git-changes-list" class="changes-list">
|
|
||||||
<!-- Dynamisch gefüllt -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Commit-Historie -->
|
|
||||||
<div id="gitea-commits-section" class="gitea-commits-panel">
|
|
||||||
<div class="commits-header">
|
|
||||||
<h3>Letzte Commits</h3>
|
|
||||||
<button id="btn-clear-commits" class="btn btn-small btn-secondary" title="Alle aus Anzeige entfernen">
|
|
||||||
Alle ausblenden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="git-commits-list" class="commits-list">
|
|
||||||
<!-- Dynamisch gefüllt -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Knowledge View (Wissensmanagement) -->
|
<!-- Knowledge View (Wissensmanagement) -->
|
||||||
@ -1389,7 +975,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="user-password">Passwort <span id="password-hint" class="form-hint">(automatisch generiert)</span></label>
|
<label for="user-password">Passwort <span id="password-hint" class="form-hint">(automatisch generiert)</span></label>
|
||||||
|
<div class="password-input-group">
|
||||||
<input type="text" id="user-password" minlength="8" readonly>
|
<input type="text" id="user-password" minlength="8" readonly>
|
||||||
|
<button type="button" id="edit-password-btn" class="btn btn-secondary btn-sm" title="Passwort bearbeiten">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="generate-password-btn" class="btn btn-secondary btn-sm" title="Neues Passwort generieren">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M1 4v6h6" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="user-role">Rolle</label>
|
<label for="user-role">Rolle</label>
|
||||||
@ -1908,9 +1508,152 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Coding Directory Modal -->
|
||||||
|
<div id="coding-modal" class="modal hidden">
|
||||||
|
<div class="modal-content modal-medium">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="coding-modal-title">Anwendung hinzufügen</h3>
|
||||||
|
<button class="modal-close" aria-label="Schliessen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="coding-name">Anwendungsname *</label>
|
||||||
|
<input type="text" id="coding-name" class="form-control" placeholder="z.B. TaskMate" required>
|
||||||
|
<small class="form-hint" id="coding-path-hint">Ordner: /home/claude-dev/<span id="coding-path-preview">...</span></small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="coding-description">Beschreibung</label>
|
||||||
|
<textarea id="coding-description" class="form-control" rows="2" placeholder="Optionale Beschreibung..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Farbe</label>
|
||||||
|
<div class="color-presets" id="coding-color-presets">
|
||||||
|
<!-- Farb-Buttons werden per JS gerendert -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CLAUDE.md</label>
|
||||||
|
<div class="claude-link-container">
|
||||||
|
<button type="button" id="coding-claude-link" class="claude-link" disabled>
|
||||||
|
<span class="claude-icon">📄</span>
|
||||||
|
<span class="claude-text">Keine CLAUDE.md vorhanden</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details class="coding-gitea-section">
|
||||||
|
<summary>Gitea-Repository verknüpfen (optional)</summary>
|
||||||
|
<div class="form-group" style="margin-top: 1rem;">
|
||||||
|
<label for="coding-gitea-repo">Repository</label>
|
||||||
|
<select id="coding-gitea-repo" class="form-control">
|
||||||
|
<option value="">-- Kein Repository --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="coding-branch">Standard-Branch</label>
|
||||||
|
<input type="text" id="coding-branch" class="form-control" value="main" placeholder="main">
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="coding-delete-btn" class="btn btn-danger hidden">Löschen</button>
|
||||||
|
<button class="btn btn-secondary modal-cancel">Abbrechen</button>
|
||||||
|
<button id="coding-save-btn" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coding Command Modal -->
|
||||||
|
<div id="coding-command-modal" class="modal hidden">
|
||||||
|
<div class="modal-content modal-small">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Befehl ausführen</h3>
|
||||||
|
<button class="modal-close" aria-label="Schliessen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="coding-command-hint">Führe diesen Befehl in WSL aus:</p>
|
||||||
|
<div class="command-box">
|
||||||
|
<code id="coding-command-text"></code>
|
||||||
|
<button id="coding-copy-command" class="btn btn-sm btn-secondary">Kopieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast Container -->
|
<!-- Toast Container -->
|
||||||
<div id="toast-container" class="toast-container"></div>
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Mobile Navigation Menu -->
|
||||||
|
<nav id="mobile-menu" class="mobile-menu" aria-hidden="true">
|
||||||
|
<div class="mobile-menu-header">
|
||||||
|
<h2 class="mobile-menu-title">TaskMate</h2>
|
||||||
|
<button id="mobile-menu-close" class="mobile-menu-close" aria-label="Menu schliessen">
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-menu-section">
|
||||||
|
<label class="mobile-menu-label">Projekt</label>
|
||||||
|
<select id="mobile-project-select" class="mobile-project-select">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-menu-section">
|
||||||
|
<label class="mobile-menu-label">Ansicht</label>
|
||||||
|
<div class="mobile-menu-nav">
|
||||||
|
<button class="mobile-nav-item active" data-view="board">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><rect x="3" y="3" width="7" height="9" rx="1" stroke="currentColor" stroke-width="2" fill="none"/><rect x="14" y="3" width="7" height="5" rx="1" stroke="currentColor" stroke-width="2" fill="none"/><rect x="14" y="12" width="7" height="9" rx="1" stroke="currentColor" stroke-width="2" fill="none"/><rect x="3" y="16" width="7" height="5" rx="1" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
|
<span>Board</span>
|
||||||
|
</button>
|
||||||
|
<button class="mobile-nav-item" data-view="list">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
<span>Liste</span>
|
||||||
|
</button>
|
||||||
|
<button class="mobile-nav-item" data-view="calendar">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" fill="none"/><line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2"/><line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2"/></svg>
|
||||||
|
<span>Kalender</span>
|
||||||
|
</button>
|
||||||
|
<button class="mobile-nav-item" data-view="proposals">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M9 11l3 3L22 4" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
|
<span>Genehmigung</span>
|
||||||
|
</button>
|
||||||
|
<button class="mobile-nav-item" data-view="coding">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><polyline points="16 18 22 12 16 6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><polyline points="8 6 2 12 8 18" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<span>Coding</span>
|
||||||
|
</button>
|
||||||
|
<button class="mobile-nav-item" data-view="knowledge">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" stroke="currentColor" stroke-width="2" fill="none"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" stroke="currentColor" stroke-width="2" fill="none"/></svg>
|
||||||
|
<span>Wissen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-menu-section mobile-menu-user">
|
||||||
|
<div class="mobile-user-info">
|
||||||
|
<span id="mobile-user-avatar" class="mobile-user-avatar">U</span>
|
||||||
|
<div class="mobile-user-details">
|
||||||
|
<span id="mobile-user-name" class="mobile-user-name">Benutzer</span>
|
||||||
|
<span id="mobile-user-role" class="mobile-user-role">Angemeldet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="mobile-admin-btn" class="mobile-menu-btn hidden">Admin-Bereich</button>
|
||||||
|
<button id="mobile-logout-btn" class="mobile-menu-btn mobile-menu-btn-danger">Abmelden</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile Menu Overlay -->
|
||||||
|
<div id="mobile-menu-overlay" class="mobile-menu-overlay"></div>
|
||||||
|
|
||||||
|
<!-- Swipe Indicators -->
|
||||||
|
<div id="swipe-indicator-left" class="swipe-indicator left">
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
<div id="swipe-indicator-right" class="swipe-indicator right">
|
||||||
|
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Onboarding Tour -->
|
<!-- Onboarding Tour -->
|
||||||
<div id="onboarding-overlay" class="onboarding-overlay hidden">
|
<div id="onboarding-overlay" class="onboarding-overlay hidden">
|
||||||
<div id="onboarding-tooltip" class="onboarding-tooltip">
|
<div id="onboarding-tooltip" class="onboarding-tooltip">
|
||||||
@ -1934,6 +1677,21 @@
|
|||||||
<img id="lightbox-image" src="" alt="">
|
<img id="lightbox-image" src="" alt="">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- CLAUDE.md Modal -->
|
||||||
|
<div id="claude-md-modal" class="modal hidden">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>CLAUDE.md (Nur-Lesen)</h3>
|
||||||
|
<button class="modal-close" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="claude-md-viewer">
|
||||||
|
<pre id="claude-md-content" class="claude-md-display"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Socket.io Client -->
|
<!-- Socket.io Client -->
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,17 @@ class AdminManager {
|
|||||||
this.users = [];
|
this.users = [];
|
||||||
this.currentEditUser = null;
|
this.currentEditUser = null;
|
||||||
this.uploadSettings = null;
|
this.uploadSettings = null;
|
||||||
|
this.allowedExtensions = ['pdf', 'docx', 'txt'];
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
|
||||||
|
// Vorschläge für häufige Dateiendungen
|
||||||
|
this.extensionSuggestions = [
|
||||||
|
'xlsx', 'pptx', 'doc', 'xls', 'ppt', // Office
|
||||||
|
'png', 'jpg', 'gif', 'svg', 'webp', // Bilder
|
||||||
|
'csv', 'json', 'xml', 'md', // Daten
|
||||||
|
'zip', 'rar', '7z', // Archive
|
||||||
|
'odt', 'ods', 'rtf' // OpenDocument
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@ -52,7 +62,10 @@ class AdminManager {
|
|||||||
// Upload Settings Elements
|
// Upload Settings Elements
|
||||||
this.uploadMaxSizeInput = $('#upload-max-size');
|
this.uploadMaxSizeInput = $('#upload-max-size');
|
||||||
this.saveUploadSettingsBtn = $('#btn-save-upload-settings');
|
this.saveUploadSettingsBtn = $('#btn-save-upload-settings');
|
||||||
this.uploadCategories = $$('.upload-category');
|
this.extensionTagsContainer = $('#extension-tags');
|
||||||
|
this.extensionInput = $('#extension-input');
|
||||||
|
this.addExtensionBtn = $('#btn-add-extension');
|
||||||
|
this.extensionSuggestionsList = $('#extension-suggestions-list');
|
||||||
|
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
@ -88,13 +101,20 @@ class AdminManager {
|
|||||||
// Upload Settings - Save Button
|
// Upload Settings - Save Button
|
||||||
this.saveUploadSettingsBtn?.addEventListener('click', () => this.saveUploadSettings());
|
this.saveUploadSettingsBtn?.addEventListener('click', () => this.saveUploadSettings());
|
||||||
|
|
||||||
// Upload Settings - Category Toggles
|
// Upload Settings - Add Extension
|
||||||
this.uploadCategories?.forEach(category => {
|
this.addExtensionBtn?.addEventListener('click', () => this.addExtensionFromInput());
|
||||||
const checkbox = category.querySelector('input[type="checkbox"]');
|
|
||||||
checkbox?.addEventListener('change', () => {
|
// Enter-Taste im Input-Feld
|
||||||
this.toggleUploadCategory(category, checkbox.checked);
|
this.extensionInput?.addEventListener('keypress', (e) => {
|
||||||
});
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addExtensionFromInput();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Password-Buttons
|
||||||
|
$('#edit-password-btn')?.addEventListener('click', () => this.togglePasswordEdit());
|
||||||
|
$('#generate-password-btn')?.addEventListener('click', () => this.generatePassword());
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUsers() {
|
async loadUsers() {
|
||||||
@ -222,8 +242,12 @@ class AdminManager {
|
|||||||
this.emailInput.value = user.email || '';
|
this.emailInput.value = user.email || '';
|
||||||
this.emailInput.disabled = false;
|
this.emailInput.disabled = false;
|
||||||
|
|
||||||
// Passwort-Feld bei Bearbeitung ausblenden
|
// Passwort-Feld für Bearbeitung vorbereiten
|
||||||
this.passwordInput.closest('.form-group').style.display = 'none';
|
this.passwordInput.closest('.form-group').style.display = 'block';
|
||||||
|
this.passwordInput.value = '';
|
||||||
|
this.passwordInput.placeholder = 'Neues Passwort (leer lassen = unverändert)';
|
||||||
|
this.passwordInput.readOnly = true;
|
||||||
|
this.passwordHint.textContent = '(optional - leer lassen für unverändert)';
|
||||||
|
|
||||||
this.roleSelect.value = user.role || 'user';
|
this.roleSelect.value = user.role || 'user';
|
||||||
|
|
||||||
@ -381,6 +405,7 @@ class AdminManager {
|
|||||||
async loadUploadSettings() {
|
async loadUploadSettings() {
|
||||||
try {
|
try {
|
||||||
this.uploadSettings = await api.getUploadSettings();
|
this.uploadSettings = await api.getUploadSettings();
|
||||||
|
this.allowedExtensions = this.uploadSettings.allowedExtensions || ['pdf', 'docx', 'txt'];
|
||||||
this.renderUploadSettings();
|
this.renderUploadSettings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading upload settings:', error);
|
console.error('Error loading upload settings:', error);
|
||||||
@ -395,36 +420,108 @@ class AdminManager {
|
|||||||
this.uploadMaxSizeInput.value = this.uploadSettings.maxFileSizeMB || 15;
|
this.uploadMaxSizeInput.value = this.uploadSettings.maxFileSizeMB || 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kategorien setzen
|
// Extension-Tags rendern
|
||||||
const categoryMap = {
|
this.renderExtensionTags();
|
||||||
'images': 'upload-cat-images',
|
|
||||||
'documents': 'upload-cat-documents',
|
|
||||||
'office': 'upload-cat-office',
|
|
||||||
'text': 'upload-cat-text',
|
|
||||||
'archives': 'upload-cat-archives',
|
|
||||||
'data': 'upload-cat-data'
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.entries(categoryMap).forEach(([category, checkboxId]) => {
|
// Vorschläge rendern
|
||||||
const checkbox = $(`#${checkboxId}`);
|
this.renderExtensionSuggestions();
|
||||||
const categoryEl = $(`.upload-category[data-category="${category}"]`);
|
|
||||||
|
|
||||||
if (checkbox && this.uploadSettings.allowedTypes?.[category]) {
|
|
||||||
const isEnabled = this.uploadSettings.allowedTypes[category].enabled;
|
|
||||||
checkbox.checked = isEnabled;
|
|
||||||
this.toggleUploadCategory(categoryEl, isEnabled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderExtensionTags() {
|
||||||
|
if (!this.extensionTagsContainer) return;
|
||||||
|
|
||||||
|
if (this.allowedExtensions.length === 0) {
|
||||||
|
this.extensionTagsContainer.innerHTML = '<span class="extension-empty">Keine Endungen definiert</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.extensionTagsContainer.innerHTML = this.allowedExtensions.map(ext => `
|
||||||
|
<span class="extension-tag" data-extension="${ext}">
|
||||||
|
.${ext}
|
||||||
|
<button type="button" class="extension-tag-remove" data-remove="${ext}" title="Entfernen">
|
||||||
|
<svg viewBox="0 0 24 24" width="12" height="12"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Remove-Buttons Event Listener
|
||||||
|
this.extensionTagsContainer.querySelectorAll('.extension-tag-remove').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ext = btn.dataset.remove;
|
||||||
|
this.removeExtension(ext);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleUploadCategory(categoryEl, enabled) {
|
renderExtensionSuggestions() {
|
||||||
if (!categoryEl) return;
|
if (!this.extensionSuggestionsList) return;
|
||||||
|
|
||||||
if (enabled) {
|
// Nur Vorschläge anzeigen, die noch nicht aktiv sind
|
||||||
categoryEl.classList.remove('disabled');
|
const availableSuggestions = this.extensionSuggestions.filter(
|
||||||
} else {
|
ext => !this.allowedExtensions.includes(ext)
|
||||||
categoryEl.classList.add('disabled');
|
);
|
||||||
|
|
||||||
|
if (availableSuggestions.length === 0) {
|
||||||
|
this.extensionSuggestionsList.innerHTML = '<span class="extension-no-suggestions">Alle Vorschläge bereits hinzugefügt</span>';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.extensionSuggestionsList.innerHTML = availableSuggestions.map(ext => `
|
||||||
|
<button type="button" class="extension-suggestion" data-suggestion="${ext}">+ ${ext}</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Suggestion-Buttons Event Listener
|
||||||
|
this.extensionSuggestionsList.querySelectorAll('.extension-suggestion').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const ext = btn.dataset.suggestion;
|
||||||
|
this.addExtension(ext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addExtensionFromInput() {
|
||||||
|
const input = this.extensionInput?.value?.trim().toLowerCase();
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
// Punkt am Anfang entfernen falls vorhanden
|
||||||
|
const ext = input.replace(/^\./, '');
|
||||||
|
|
||||||
|
if (this.addExtension(ext)) {
|
||||||
|
this.extensionInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addExtension(ext) {
|
||||||
|
// Validierung: nur alphanumerisch, 1-10 Zeichen
|
||||||
|
if (!/^[a-z0-9]{1,10}$/.test(ext)) {
|
||||||
|
this.showToast('Ungültige Dateiendung (nur Buchstaben/Zahlen, max. 10 Zeichen)', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob bereits vorhanden
|
||||||
|
if (this.allowedExtensions.includes(ext)) {
|
||||||
|
this.showToast(`Endung .${ext} bereits vorhanden`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinzufügen
|
||||||
|
this.allowedExtensions.push(ext);
|
||||||
|
this.renderExtensionTags();
|
||||||
|
this.renderExtensionSuggestions();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeExtension(ext) {
|
||||||
|
// Prüfen ob mindestens eine Endung übrig bleibt
|
||||||
|
if (this.allowedExtensions.length <= 1) {
|
||||||
|
this.showToast('Mindestens eine Dateiendung muss erlaubt sein', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.allowedExtensions = this.allowedExtensions.filter(e => e !== ext);
|
||||||
|
this.renderExtensionTags();
|
||||||
|
this.renderExtensionSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveUploadSettings() {
|
async saveUploadSettings() {
|
||||||
@ -437,51 +534,17 @@ class AdminManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kategorien sammeln
|
if (this.allowedExtensions.length === 0) {
|
||||||
const allowedTypes = {
|
this.showToast('Mindestens eine Dateiendung muss erlaubt sein', 'error');
|
||||||
images: {
|
|
||||||
enabled: $('#upload-cat-images')?.checked ?? true,
|
|
||||||
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
|
||||||
},
|
|
||||||
documents: {
|
|
||||||
enabled: $('#upload-cat-documents')?.checked ?? true,
|
|
||||||
types: ['application/pdf']
|
|
||||||
},
|
|
||||||
office: {
|
|
||||||
enabled: $('#upload-cat-office')?.checked ?? true,
|
|
||||||
types: [
|
|
||||||
'application/msword',
|
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
||||||
'application/vnd.ms-excel',
|
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'application/vnd.ms-powerpoint',
|
|
||||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
enabled: $('#upload-cat-text')?.checked ?? true,
|
|
||||||
types: ['text/plain', 'text/csv', 'text/markdown']
|
|
||||||
},
|
|
||||||
archives: {
|
|
||||||
enabled: $('#upload-cat-archives')?.checked ?? true,
|
|
||||||
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
enabled: $('#upload-cat-data')?.checked ?? true,
|
|
||||||
types: ['application/json']
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prüfen ob mindestens eine Kategorie aktiviert ist
|
|
||||||
const hasEnabledCategory = Object.values(allowedTypes).some(cat => cat.enabled);
|
|
||||||
if (!hasEnabledCategory) {
|
|
||||||
this.showToast('Mindestens eine Dateikategorie muss aktiviert sein', 'error');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.updateUploadSettings({ maxFileSizeMB, allowedTypes });
|
await api.updateUploadSettings({
|
||||||
|
maxFileSizeMB,
|
||||||
|
allowedExtensions: this.allowedExtensions
|
||||||
|
});
|
||||||
|
|
||||||
this.uploadSettings = { maxFileSizeMB, allowedTypes };
|
this.uploadSettings = { maxFileSizeMB, allowedExtensions: this.allowedExtensions };
|
||||||
this.showToast('Upload-Einstellungen gespeichert', 'success');
|
this.showToast('Upload-Einstellungen gespeichert', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving upload settings:', error);
|
console.error('Error saving upload settings:', error);
|
||||||
@ -489,6 +552,72 @@ class AdminManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwort-Bearbeitung umschalten
|
||||||
|
*/
|
||||||
|
togglePasswordEdit() {
|
||||||
|
const passwordInput = $('#user-password');
|
||||||
|
const editBtn = $('#edit-password-btn');
|
||||||
|
const hint = $('#password-hint');
|
||||||
|
|
||||||
|
if (!passwordInput || !editBtn) return;
|
||||||
|
|
||||||
|
if (passwordInput.readOnly) {
|
||||||
|
// Bearbeitung aktivieren
|
||||||
|
passwordInput.readOnly = false;
|
||||||
|
passwordInput.focus();
|
||||||
|
passwordInput.select();
|
||||||
|
editBtn.innerHTML = `
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
editBtn.title = "Bearbeitung bestätigen";
|
||||||
|
hint.textContent = "(bearbeiten)";
|
||||||
|
} else {
|
||||||
|
// Bearbeitung beenden
|
||||||
|
passwordInput.readOnly = true;
|
||||||
|
editBtn.innerHTML = `
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
editBtn.title = "Passwort bearbeiten";
|
||||||
|
hint.textContent = this.currentEditUser ? "(geändert)" : "(automatisch generiert)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neues Passwort generieren
|
||||||
|
*/
|
||||||
|
generatePassword() {
|
||||||
|
const passwordInput = $('#user-password');
|
||||||
|
const hint = $('#password-hint');
|
||||||
|
|
||||||
|
if (!passwordInput) return;
|
||||||
|
|
||||||
|
// Starkes Passwort generieren (12 Zeichen)
|
||||||
|
const charset = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%&*';
|
||||||
|
let password = '';
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
password += charset.charAt(Math.floor(Math.random() * charset.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordInput.value = password;
|
||||||
|
passwordInput.readOnly = false;
|
||||||
|
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent = "(neu generiert)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passwort kurz markieren
|
||||||
|
passwordInput.focus();
|
||||||
|
passwordInput.select();
|
||||||
|
|
||||||
|
this.showToast('Neues Passwort generiert', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
this.adminScreen?.classList.add('active');
|
this.adminScreen?.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,25 @@ class ApiClient {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.baseUrl = '/api';
|
this.baseUrl = '/api';
|
||||||
this.token = null;
|
this.token = null;
|
||||||
|
this.refreshToken = null;
|
||||||
this.csrfToken = null;
|
this.csrfToken = null;
|
||||||
this.refreshingToken = false;
|
this.refreshingToken = false;
|
||||||
this.requestQueue = [];
|
this.requestQueue = [];
|
||||||
|
this.refreshTimer = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Token aus Storage laden
|
||||||
|
this.token = localStorage.getItem('auth_token');
|
||||||
|
this.refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
this.csrfToken = sessionStorage.getItem('csrf_token');
|
||||||
|
console.log('[API] init() - Token loaded:', this.token ? this.token.substring(0, 20) + '...' : 'NULL');
|
||||||
|
|
||||||
|
// Starte Timer wenn Token und Refresh-Token vorhanden sind
|
||||||
|
if (this.token && this.refreshToken) {
|
||||||
|
this.startTokenRefreshTimer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token Management
|
// Token Management
|
||||||
@ -18,10 +34,22 @@ class ApiClient {
|
|||||||
this.token = token;
|
this.token = token;
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem('auth_token', token);
|
localStorage.setItem('auth_token', token);
|
||||||
|
// Starte proaktiven Token-Refresh Timer (nach 10 Minuten)
|
||||||
|
this.startTokenRefreshTimer();
|
||||||
} else {
|
} else {
|
||||||
this.token = null;
|
this.token = null;
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
localStorage.removeItem('current_user');
|
localStorage.removeItem('current_user');
|
||||||
|
this.clearTokenRefreshTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefreshToken(token) {
|
||||||
|
this.refreshToken = token;
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('refresh_token', token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +77,94 @@ class ApiClient {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh Access Token using Refresh Token
|
||||||
|
async refreshAccessToken() {
|
||||||
|
if (this.refreshingToken) {
|
||||||
|
// Warte auf laufenden Refresh
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const checkRefresh = () => {
|
||||||
|
if (!this.refreshingToken) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(checkRefresh, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkRefresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshingToken = true;
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('Kein Refresh-Token vorhanden');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API] Refreshing access token...');
|
||||||
|
const response = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ refreshToken })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Refresh failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.setToken(data.token);
|
||||||
|
if (data.csrfToken) {
|
||||||
|
this.setCsrfToken(data.csrfToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[API] Token refresh successful');
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:token-refreshed', {
|
||||||
|
detail: { token: data.token }
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[API] Token refresh error:', error.message);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.refreshingToken = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle authentication failure
|
||||||
|
handleAuthFailure() {
|
||||||
|
console.log('[API] Authentication failed - clearing tokens');
|
||||||
|
this.setToken(null);
|
||||||
|
this.setRefreshToken(null);
|
||||||
|
this.setCsrfToken(null);
|
||||||
|
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proaktiver Token-Refresh Timer
|
||||||
|
startTokenRefreshTimer() {
|
||||||
|
this.clearTokenRefreshTimer();
|
||||||
|
// Refresh nach 10 Minuten (Token läuft nach 15 Minuten ab)
|
||||||
|
this.refreshTimer = setTimeout(async () => {
|
||||||
|
if (this.refreshToken && !this.refreshingToken) {
|
||||||
|
try {
|
||||||
|
console.log('[API] Proactive token refresh...');
|
||||||
|
await this.refreshAccessToken();
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[API] Proactive refresh failed:', error.message);
|
||||||
|
// Bei Fehler nicht automatisch ausloggen, warten bis Token wirklich abläuft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000); // 10 Minuten
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTokenRefreshTimer() {
|
||||||
|
if (this.refreshTimer) {
|
||||||
|
clearTimeout(this.refreshTimer);
|
||||||
|
this.refreshTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Base Request Method
|
// Base Request Method
|
||||||
async request(endpoint, options = {}) {
|
async request(endpoint, options = {}) {
|
||||||
const url = `${this.baseUrl}${endpoint}`;
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
@ -58,6 +174,11 @@ class ApiClient {
|
|||||||
...options.headers
|
...options.headers
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sicherstellen, dass Token aktuell ist
|
||||||
|
if (!this.token && localStorage.getItem('auth_token')) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
// Add auth token
|
// Add auth token
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
console.log('[API] Request:', endpoint, 'Token:', token ? token.substring(0, 20) + '...' : 'NULL');
|
console.log('[API] Request:', endpoint, 'Token:', token ? token.substring(0, 20) + '...' : 'NULL');
|
||||||
@ -103,22 +224,25 @@ class ApiClient {
|
|||||||
|
|
||||||
// Handle 401 Unauthorized
|
// Handle 401 Unauthorized
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
// Token der für diesen Request verwendet wurde
|
|
||||||
const requestToken = token;
|
|
||||||
const currentToken = localStorage.getItem('auth_token');
|
|
||||||
|
|
||||||
console.log('[API] 401 received for:', endpoint);
|
console.log('[API] 401 received for:', endpoint);
|
||||||
console.log('[API] Request token:', requestToken ? requestToken.substring(0, 20) + '...' : 'NULL');
|
|
||||||
console.log('[API] Current token:', currentToken ? currentToken.substring(0, 20) + '...' : 'NULL');
|
|
||||||
|
|
||||||
// Nur ausloggen wenn der Token der gleiche ist (kein neuer Login in der Zwischenzeit)
|
// Versuche Token mit Refresh-Token zu erneuern
|
||||||
if (!currentToken || currentToken === requestToken) {
|
if (this.refreshToken && !this.refreshingToken && !options._tokenRefreshAttempted) {
|
||||||
console.log('[API] Token invalid, triggering logout');
|
console.log('[API] Attempting token refresh...');
|
||||||
this.setToken(null);
|
try {
|
||||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
await this.refreshAccessToken();
|
||||||
} else {
|
// Wiederhole original Request mit neuem Token
|
||||||
console.log('[API] 401 ignored - new login occurred while request was in flight');
|
return this.request(endpoint, { ...options, _tokenRefreshAttempted: true });
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.log('[API] Token refresh failed:', refreshError.message);
|
||||||
|
// Fallback zum Logout
|
||||||
|
this.handleAuthFailure();
|
||||||
|
throw new ApiError('Sitzung abgelaufen', 401);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein Refresh-Token oder Refresh bereits versucht
|
||||||
|
this.handleAuthFailure();
|
||||||
throw new ApiError('Sitzung abgelaufen', 401);
|
throw new ApiError('Sitzung abgelaufen', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -297,6 +421,12 @@ class ApiClient {
|
|||||||
const response = await this.post('/auth/login', { username, password });
|
const response = await this.post('/auth/login', { username, password });
|
||||||
console.log('[API] login() response:', response ? 'OK' : 'NULL', 'token:', response?.token ? 'EXISTS' : 'MISSING');
|
console.log('[API] login() response:', response ? 'OK' : 'NULL', 'token:', response?.token ? 'EXISTS' : 'MISSING');
|
||||||
this.setToken(response.token);
|
this.setToken(response.token);
|
||||||
|
|
||||||
|
// Store refresh token if provided (new auth system)
|
||||||
|
if (response.refreshToken) {
|
||||||
|
this.setRefreshToken(response.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Store CSRF token from login response
|
// Store CSRF token from login response
|
||||||
if (response.csrfToken) {
|
if (response.csrfToken) {
|
||||||
this.setCsrfToken(response.csrfToken);
|
this.setCsrfToken(response.csrfToken);
|
||||||
@ -309,6 +439,7 @@ class ApiClient {
|
|||||||
await this.post('/auth/logout', {});
|
await this.post('/auth/logout', {});
|
||||||
} finally {
|
} finally {
|
||||||
this.setToken(null);
|
this.setToken(null);
|
||||||
|
this.setRefreshToken(null);
|
||||||
this.setCsrfToken(null);
|
this.setCsrfToken(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1071,6 +1202,62 @@ class ApiClient {
|
|||||||
async searchKnowledge(query) {
|
async searchKnowledge(query) {
|
||||||
return this.get(`/knowledge/search?q=${encodeURIComponent(query)}`);
|
return this.get(`/knowledge/search?q=${encodeURIComponent(query)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// CODING
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
async getCodingDirectories() {
|
||||||
|
return this.get('/coding/directories');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCodingDirectory(data) {
|
||||||
|
return this.post('/coding/directories', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCodingDirectory(id, data) {
|
||||||
|
return this.put(`/coding/directories/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCodingDirectory(id) {
|
||||||
|
return this.delete(`/coding/directories/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCodingDirectoryStatus(id) {
|
||||||
|
return this.get(`/coding/directories/${id}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async codingGitFetch(id) {
|
||||||
|
return this.post(`/coding/directories/${id}/fetch`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async codingGitPull(id) {
|
||||||
|
return this.post(`/coding/directories/${id}/pull`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async codingGitPush(id, force = false) {
|
||||||
|
return this.post(`/coding/directories/${id}/push`, { force });
|
||||||
|
}
|
||||||
|
|
||||||
|
async codingGitCommit(id, message) {
|
||||||
|
return this.post(`/coding/directories/${id}/commit`, { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCodingDirectoryBranches(id) {
|
||||||
|
return this.get(`/coding/directories/${id}/branches`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async codingGitCheckout(id, branch) {
|
||||||
|
return this.post(`/coding/directories/${id}/checkout`, { branch });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCodingDirectoryCommits(id, limit = 20) {
|
||||||
|
return this.get(`/coding/directories/${id}/commits?limit=${limit}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateCodingPath(path) {
|
||||||
|
return this.post('/coding/validate-path', { path });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom API Error Class
|
// Custom API Error Class
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import proposalsManager from './proposals.js';
|
|||||||
import notificationManager from './notifications.js';
|
import notificationManager from './notifications.js';
|
||||||
import giteaManager from './gitea.js';
|
import giteaManager from './gitea.js';
|
||||||
import knowledgeManager from './knowledge.js';
|
import knowledgeManager from './knowledge.js';
|
||||||
|
import codingManager from './coding.js';
|
||||||
|
import mobileManager from './mobile.js';
|
||||||
import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js';
|
import { $, $$, debounce, getFromStorage, setToStorage } from './utils.js';
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
@ -80,11 +82,20 @@ class App {
|
|||||||
// Initialize gitea manager
|
// Initialize gitea manager
|
||||||
await giteaManager.init();
|
await giteaManager.init();
|
||||||
|
|
||||||
|
// Initialize coding manager
|
||||||
|
await codingManager.init();
|
||||||
|
|
||||||
// Initialize knowledge manager
|
// Initialize knowledge manager
|
||||||
await knowledgeManager.init();
|
await knowledgeManager.init();
|
||||||
|
|
||||||
|
// Initialize mobile features
|
||||||
|
mobileManager.init();
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
this.updateUserMenu();
|
this.updateUserMenu();
|
||||||
|
|
||||||
|
// Dispatch event for mobile menu
|
||||||
|
document.dispatchEvent(new CustomEvent('projects:loaded'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async initializeAdminApp() {
|
async initializeAdminApp() {
|
||||||
@ -321,6 +332,32 @@ class App {
|
|||||||
window.addEventListener('online', () => this.handleOnline());
|
window.addEventListener('online', () => this.handleOnline());
|
||||||
window.addEventListener('offline', () => this.handleOffline());
|
window.addEventListener('offline', () => this.handleOffline());
|
||||||
|
|
||||||
|
// Mobile events
|
||||||
|
document.addEventListener('project:selected', (e) => {
|
||||||
|
const projectId = e.detail?.projectId;
|
||||||
|
if (projectId) {
|
||||||
|
this.loadProject(projectId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('auth:logout', () => {
|
||||||
|
authManager.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('admin:open', () => {
|
||||||
|
// Redirect to admin screen for admins
|
||||||
|
if (authManager.isAdmin()) {
|
||||||
|
this.showAdminScreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('task:move', async (e) => {
|
||||||
|
const { taskId, columnId, position } = e.detail;
|
||||||
|
if (taskId && columnId !== undefined) {
|
||||||
|
await boardManager.moveTask(taskId, columnId, position);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Close modal on overlay click
|
// Close modal on overlay click
|
||||||
$('.modal-overlay')?.addEventListener('click', () => {
|
$('.modal-overlay')?.addEventListener('click', () => {
|
||||||
// Check if task-modal is open - let it handle its own close (with auto-save)
|
// Check if task-modal is open - let it handle its own close (with auto-save)
|
||||||
@ -617,11 +654,11 @@ class App {
|
|||||||
proposalsManager.resetToActiveView();
|
proposalsManager.resetToActiveView();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide gitea manager
|
// Show/hide coding manager
|
||||||
if (view === 'gitea') {
|
if (view === 'coding') {
|
||||||
giteaManager.show();
|
codingManager.show();
|
||||||
} else {
|
} else {
|
||||||
giteaManager.hide();
|
codingManager.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide knowledge manager
|
// Show/hide knowledge manager
|
||||||
@ -978,6 +1015,9 @@ class App {
|
|||||||
|
|
||||||
const userRole = $('#user-role');
|
const userRole = $('#user-role');
|
||||||
if (userRole) userRole.textContent = user.role === 'admin' ? 'Administrator' : 'Benutzer';
|
if (userRole) userRole.textContent = user.role === 'admin' ? 'Administrator' : 'Benutzer';
|
||||||
|
|
||||||
|
// Notify mobile menu
|
||||||
|
document.dispatchEvent(new CustomEvent('user:updated'));
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleUserMenu() {
|
toggleUserMenu() {
|
||||||
|
|||||||
777
frontend/js/coding.js
Normale Datei
777
frontend/js/coding.js
Normale Datei
@ -0,0 +1,777 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Coding Manager
|
||||||
|
* =========================
|
||||||
|
* Verwaltung von Server-Anwendungen mit Claude/Codex Integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import api from './api.js';
|
||||||
|
import { escapeHtml } from './utils.js';
|
||||||
|
|
||||||
|
// Toast-Funktion (verwendet das globale Toast-Event)
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||||
|
detail: { message, type }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basis-Pfad für alle Anwendungen auf dem Server
|
||||||
|
const BASE_PATH = '/home/claude-dev';
|
||||||
|
|
||||||
|
// Farb-Presets für Anwendungen
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
'#4F46E5', // Indigo
|
||||||
|
'#7C3AED', // Violet
|
||||||
|
'#EC4899', // Pink
|
||||||
|
'#EF4444', // Red
|
||||||
|
'#F59E0B', // Amber
|
||||||
|
'#10B981', // Emerald
|
||||||
|
'#06B6D4', // Cyan
|
||||||
|
'#3B82F6', // Blue
|
||||||
|
'#8B5CF6', // Purple
|
||||||
|
'#6366F1' // Indigo Light
|
||||||
|
];
|
||||||
|
|
||||||
|
class CodingManager {
|
||||||
|
constructor() {
|
||||||
|
this.initialized = false;
|
||||||
|
this.directories = [];
|
||||||
|
this.refreshInterval = null;
|
||||||
|
this.editingDirectory = null;
|
||||||
|
this.giteaRepos = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager initialisieren
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
console.log('[CodingManager] Initialisiert');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-Listener binden
|
||||||
|
*/
|
||||||
|
bindEvents() {
|
||||||
|
// Add-Button
|
||||||
|
const addBtn = document.getElementById('add-coding-directory-btn');
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', () => this.openModal());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal Events
|
||||||
|
const modal = document.getElementById('coding-modal');
|
||||||
|
if (modal) {
|
||||||
|
// Close-Button
|
||||||
|
modal.querySelector('.modal-close')?.addEventListener('click', () => this.closeModal());
|
||||||
|
modal.querySelector('.modal-cancel')?.addEventListener('click', () => this.closeModal());
|
||||||
|
|
||||||
|
// Save-Button
|
||||||
|
document.getElementById('coding-save-btn')?.addEventListener('click', () => this.handleSave());
|
||||||
|
|
||||||
|
// Delete-Button
|
||||||
|
document.getElementById('coding-delete-btn')?.addEventListener('click', () => this.handleDelete());
|
||||||
|
|
||||||
|
// Backdrop-Click
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) this.closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Farb-Presets
|
||||||
|
this.renderColorPresets();
|
||||||
|
|
||||||
|
// Name-Eingabe für Pfad-Preview
|
||||||
|
const nameInput = document.getElementById('coding-name');
|
||||||
|
if (nameInput) {
|
||||||
|
nameInput.addEventListener('input', () => this.updatePathPreview());
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLAUDE.md Link Event
|
||||||
|
const claudeLink = document.getElementById('coding-claude-link');
|
||||||
|
if (claudeLink) {
|
||||||
|
claudeLink.addEventListener('click', () => this.openClaudeModal());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command-Modal Events
|
||||||
|
const cmdModal = document.getElementById('coding-command-modal');
|
||||||
|
if (cmdModal) {
|
||||||
|
cmdModal.querySelector('.modal-close')?.addEventListener('click', () => this.closeCommandModal());
|
||||||
|
cmdModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === cmdModal) this.closeCommandModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('coding-copy-command')?.addEventListener('click', () => this.copyCommand());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gitea-Repo Dropdown laden bei Details-Toggle
|
||||||
|
const giteaSection = document.querySelector('.coding-gitea-section');
|
||||||
|
if (giteaSection) {
|
||||||
|
giteaSection.addEventListener('toggle', (e) => {
|
||||||
|
if (e.target.open) {
|
||||||
|
this.loadGiteaRepos();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLAUDE.md Modal Events
|
||||||
|
const claudeModal = document.getElementById('claude-md-modal');
|
||||||
|
if (claudeModal) {
|
||||||
|
// Close-Button
|
||||||
|
claudeModal.querySelector('.modal-close')?.addEventListener('click', () => this.closeClaudeModal());
|
||||||
|
|
||||||
|
// Backdrop-Click
|
||||||
|
claudeModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === claudeModal) this.closeClaudeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC-Taste
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && !claudeModal.classList.contains('hidden')) {
|
||||||
|
this.closeClaudeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Farb-Presets rendern
|
||||||
|
*/
|
||||||
|
renderColorPresets() {
|
||||||
|
const container = document.getElementById('coding-color-presets');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = COLOR_PRESETS.map(color => `
|
||||||
|
<button type="button" class="color-preset" data-color="${color}" style="background-color: ${color};" title="${color}"></button>
|
||||||
|
`).join('') + `
|
||||||
|
<input type="color" id="coding-color-custom" class="color-picker-custom" value="#4F46E5" title="Eigene Farbe">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Event-Listener für Presets
|
||||||
|
container.querySelectorAll('.color-preset').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
container.querySelectorAll('.color-preset').forEach(b => b.classList.remove('selected'));
|
||||||
|
btn.classList.add('selected');
|
||||||
|
document.getElementById('coding-color-custom').value = btn.dataset.color;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom Color Input
|
||||||
|
document.getElementById('coding-color-custom')?.addEventListener('input', (e) => {
|
||||||
|
container.querySelectorAll('.color-preset').forEach(b => b.classList.remove('selected'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pfad-Preview aktualisieren
|
||||||
|
*/
|
||||||
|
updatePathPreview() {
|
||||||
|
const nameInput = document.getElementById('coding-name');
|
||||||
|
const preview = document.getElementById('coding-path-preview');
|
||||||
|
if (!nameInput || !preview) return;
|
||||||
|
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
preview.textContent = name || '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchClaudeTab entfernt - CLAUDE.md ist jetzt nur readonly
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLAUDE.md Link aktualisieren
|
||||||
|
*/
|
||||||
|
updateClaudeLink(content, projectName) {
|
||||||
|
const link = document.getElementById('coding-claude-link');
|
||||||
|
const textSpan = link?.querySelector('.claude-text');
|
||||||
|
|
||||||
|
// Debug entfernt
|
||||||
|
|
||||||
|
if (!link || !textSpan) {
|
||||||
|
console.error('CLAUDE.md link elements not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content für Modal speichern
|
||||||
|
this.currentClaudeContent = content;
|
||||||
|
this.currentProjectName = projectName;
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
link.disabled = false;
|
||||||
|
textSpan.textContent = `CLAUDE.md anzeigen (${Math.round(content.length / 1024)}KB)`;
|
||||||
|
} else {
|
||||||
|
link.disabled = true;
|
||||||
|
textSpan.textContent = 'Keine CLAUDE.md vorhanden';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLAUDE.md Modal öffnen
|
||||||
|
*/
|
||||||
|
openClaudeModal() {
|
||||||
|
if (!this.currentClaudeContent) {
|
||||||
|
console.warn('No CLAUDE.md content to display');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('claude-md-modal');
|
||||||
|
const overlay = document.querySelector('.modal-overlay');
|
||||||
|
const content = document.getElementById('claude-md-content');
|
||||||
|
const title = modal?.querySelector('.modal-header h3');
|
||||||
|
|
||||||
|
if (!modal || !content) {
|
||||||
|
console.error('CLAUDE.md modal elements not found!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titel setzen
|
||||||
|
if (title && this.currentProjectName) {
|
||||||
|
title.textContent = `CLAUDE.md - ${this.currentProjectName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content setzen
|
||||||
|
content.textContent = this.currentClaudeContent;
|
||||||
|
|
||||||
|
// Modal anzeigen
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('visible');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal opened
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLAUDE.md Modal schließen
|
||||||
|
*/
|
||||||
|
closeClaudeModal() {
|
||||||
|
const modal = document.getElementById('claude-md-modal');
|
||||||
|
const overlay = document.querySelector('.modal-overlay');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('visible');
|
||||||
|
setTimeout(() => modal.classList.add('hidden'), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
setTimeout(() => overlay.classList.add('hidden'), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal closed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea-Repositories laden
|
||||||
|
*/
|
||||||
|
async loadGiteaRepos() {
|
||||||
|
try {
|
||||||
|
const select = document.getElementById('coding-gitea-repo');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
// Lade-Indikator
|
||||||
|
select.innerHTML = '<option value="">Laden...</option>';
|
||||||
|
|
||||||
|
const result = await api.getGiteaRepositories();
|
||||||
|
console.log('Gitea API Response:', result);
|
||||||
|
this.giteaRepos = result?.repositories || [];
|
||||||
|
|
||||||
|
select.innerHTML = '<option value="">-- Kein Repository --</option>' +
|
||||||
|
this.giteaRepos.map(repo => `
|
||||||
|
<option value="${repo.cloneUrl}" data-owner="${repo.owner || ''}" data-name="${repo.name}">
|
||||||
|
${escapeHtml(repo.fullName)}
|
||||||
|
</option>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Wenn Editing, vorhandenen Wert setzen
|
||||||
|
if (this.editingDirectory?.giteaRepoUrl) {
|
||||||
|
select.value = this.editingDirectory.giteaRepoUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Gitea-Repos:', error);
|
||||||
|
const select = document.getElementById('coding-gitea-repo');
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = '<option value="">Fehler beim Laden</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anwendungen laden
|
||||||
|
*/
|
||||||
|
async loadDirectories() {
|
||||||
|
try {
|
||||||
|
this.directories = await api.getCodingDirectories();
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Anwendungen:', error);
|
||||||
|
showToast('Fehler beim Laden der Anwendungen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View rendern
|
||||||
|
*/
|
||||||
|
render() {
|
||||||
|
const grid = document.getElementById('coding-grid');
|
||||||
|
const empty = document.getElementById('coding-empty');
|
||||||
|
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
if (this.directories.length === 0) {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
grid.classList.add('hidden');
|
||||||
|
empty?.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty?.classList.add('hidden');
|
||||||
|
grid.classList.remove('hidden');
|
||||||
|
|
||||||
|
grid.innerHTML = this.directories.map(dir => this.renderTile(dir)).join('');
|
||||||
|
|
||||||
|
// Event-Listener für Tiles
|
||||||
|
this.bindTileEvents();
|
||||||
|
|
||||||
|
// Git-Status für jede Anwendung laden
|
||||||
|
this.directories.forEach(dir => this.updateTileStatus(dir.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelne Kachel rendern
|
||||||
|
*/
|
||||||
|
renderTile(directory) {
|
||||||
|
const hasGitea = !!directory.giteaRepoUrl;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="coding-tile" data-id="${directory.id}">
|
||||||
|
<div class="coding-tile-color" style="background-color: ${directory.color || '#4F46E5'}"></div>
|
||||||
|
|
||||||
|
<div class="coding-tile-header">
|
||||||
|
<span class="coding-tile-icon">📁</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="coding-tile-content">
|
||||||
|
<div class="coding-tile-name">${escapeHtml(directory.name)}</div>
|
||||||
|
<div class="coding-tile-path">${escapeHtml(directory.localPath)}</div>
|
||||||
|
${directory.description ? `<div class="coding-tile-description">${escapeHtml(directory.description)}</div>` : ''}
|
||||||
|
${directory.hasCLAUDEmd ? '<div class="coding-tile-badge">CLAUDE.md</div>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="coding-tile-status" id="coding-status-${directory.id}">
|
||||||
|
<span class="git-status-badge loading">Lade...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="coding-tile-actions">
|
||||||
|
<button class="btn-claude" data-id="${directory.id}" data-path="${escapeHtml(directory.localPath)}" title="SSH-Befehl für Claude kopieren">
|
||||||
|
Claude starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${hasGitea ? `
|
||||||
|
<div class="coding-tile-git">
|
||||||
|
<button class="btn btn-sm btn-secondary coding-git-fetch" data-id="${directory.id}">Fetch</button>
|
||||||
|
<button class="btn btn-sm btn-secondary coding-git-pull" data-id="${directory.id}">Pull</button>
|
||||||
|
<button class="btn btn-sm btn-secondary coding-git-push" data-id="${directory.id}">Push</button>
|
||||||
|
<button class="btn btn-sm btn-secondary coding-git-commit" data-id="${directory.id}">Commit</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event-Listener für Tiles binden
|
||||||
|
*/
|
||||||
|
bindTileEvents() {
|
||||||
|
// Kachel-Klick für Modal
|
||||||
|
document.querySelectorAll('.coding-tile').forEach(tile => {
|
||||||
|
tile.addEventListener('click', (e) => {
|
||||||
|
// Nicht triggern wenn Button-Kind geklickt wird
|
||||||
|
if (e.target.closest('button')) return;
|
||||||
|
|
||||||
|
const id = parseInt(tile.dataset.id);
|
||||||
|
const dir = this.directories.find(d => d.id === id);
|
||||||
|
if (dir) this.openModal(dir);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claude-Buttons
|
||||||
|
document.querySelectorAll('.btn-claude').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.launchClaude(btn.dataset.path));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Git-Buttons
|
||||||
|
document.querySelectorAll('.coding-git-fetch').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.gitFetch(parseInt(btn.dataset.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.coding-git-pull').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.gitPull(parseInt(btn.dataset.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.coding-git-push').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.gitPush(parseInt(btn.dataset.id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.coding-git-commit').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.promptCommit(parseInt(btn.dataset.id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git-Status für eine Kachel aktualisieren
|
||||||
|
*/
|
||||||
|
async updateTileStatus(id) {
|
||||||
|
const statusEl = document.getElementById(`coding-status-${id}`);
|
||||||
|
if (!statusEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await api.getCodingDirectoryStatus(id);
|
||||||
|
|
||||||
|
if (!status.isGitRepo) {
|
||||||
|
statusEl.innerHTML = '<span class="git-status-badge">Kein Git-Repo</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass = status.isClean ? 'clean' : 'dirty';
|
||||||
|
const statusText = status.isClean ? 'Clean' : `${status.changes?.length || 0} Änderungen`;
|
||||||
|
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<span class="git-branch-badge">${escapeHtml(status.branch)}</span>
|
||||||
|
<span class="git-status-badge ${statusClass}">${statusText}</span>
|
||||||
|
${status.ahead > 0 ? `<span class="git-status-badge ahead">↑${status.ahead}</span>` : ''}
|
||||||
|
${status.behind > 0 ? `<span class="git-status-badge behind">↓${status.behind}</span>` : ''}
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
statusEl.innerHTML = '<span class="git-status-badge error">Fehler</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claude Code starten - SSH-Befehl kopieren
|
||||||
|
*/
|
||||||
|
async launchClaude(path) {
|
||||||
|
const command = `ssh claude-dev@91.99.192.14 -t "cd ${path} && claude"`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
this.showCommandModal(
|
||||||
|
command,
|
||||||
|
'Befehl kopiert! Öffne Terminal/CMD und füge ein (Strg+V). Passwort: z0E1Al}q2H?Yqd!O'
|
||||||
|
);
|
||||||
|
showToast('SSH-Befehl kopiert!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback wenn Clipboard nicht verfügbar
|
||||||
|
this.showCommandModal(
|
||||||
|
command,
|
||||||
|
'Kopiere diesen Befehl und füge ihn im Terminal ein. Passwort: z0E1Al}q2H?Yqd!O'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command-Modal anzeigen
|
||||||
|
*/
|
||||||
|
showCommandModal(command, hint) {
|
||||||
|
const modal = document.getElementById('coding-command-modal');
|
||||||
|
const hintEl = document.getElementById('coding-command-hint');
|
||||||
|
const textEl = document.getElementById('coding-command-text');
|
||||||
|
|
||||||
|
if (!modal || !textEl) return;
|
||||||
|
|
||||||
|
hintEl.textContent = hint || 'Führe diesen Befehl aus:';
|
||||||
|
textEl.textContent = command;
|
||||||
|
this.currentCommand = command;
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command-Modal schließen
|
||||||
|
*/
|
||||||
|
closeCommandModal() {
|
||||||
|
const modal = document.getElementById('coding-command-modal');
|
||||||
|
if (modal) modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Befehl in Zwischenablage kopieren
|
||||||
|
*/
|
||||||
|
async copyCommand() {
|
||||||
|
if (!this.currentCommand) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.currentCommand);
|
||||||
|
showToast('Befehl kopiert!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Kopieren fehlgeschlagen:', error);
|
||||||
|
showToast('Kopieren fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git Fetch
|
||||||
|
*/
|
||||||
|
async gitFetch(id) {
|
||||||
|
try {
|
||||||
|
showToast('Fetch läuft...', 'info');
|
||||||
|
await api.codingGitFetch(id);
|
||||||
|
showToast('Fetch erfolgreich', 'success');
|
||||||
|
this.updateTileStatus(id);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Fetch fehlgeschlagen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git Pull
|
||||||
|
*/
|
||||||
|
async gitPull(id) {
|
||||||
|
try {
|
||||||
|
showToast('Pull läuft...', 'info');
|
||||||
|
await api.codingGitPull(id);
|
||||||
|
showToast('Pull erfolgreich', 'success');
|
||||||
|
this.updateTileStatus(id);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Pull fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git Push
|
||||||
|
*/
|
||||||
|
async gitPush(id) {
|
||||||
|
try {
|
||||||
|
showToast('Push läuft...', 'info');
|
||||||
|
await api.codingGitPush(id);
|
||||||
|
showToast('Push erfolgreich', 'success');
|
||||||
|
this.updateTileStatus(id);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Push fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit-Dialog anzeigen
|
||||||
|
*/
|
||||||
|
promptCommit(id) {
|
||||||
|
const message = prompt('Commit-Nachricht eingeben:');
|
||||||
|
if (message && message.trim()) {
|
||||||
|
this.gitCommit(id, message.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git Commit
|
||||||
|
*/
|
||||||
|
async gitCommit(id, message) {
|
||||||
|
try {
|
||||||
|
showToast('Commit läuft...', 'info');
|
||||||
|
await api.codingGitCommit(id, message);
|
||||||
|
showToast('Commit erfolgreich', 'success');
|
||||||
|
this.updateTileStatus(id);
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Commit fehlgeschlagen: ' + (error.message || 'Unbekannter Fehler'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal öffnen
|
||||||
|
*/
|
||||||
|
openModal(directory = null) {
|
||||||
|
this.editingDirectory = directory;
|
||||||
|
|
||||||
|
const modal = document.getElementById('coding-modal');
|
||||||
|
const overlay = document.querySelector('.modal-overlay');
|
||||||
|
const title = document.getElementById('coding-modal-title');
|
||||||
|
const deleteBtn = document.getElementById('coding-delete-btn');
|
||||||
|
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// Titel setzen
|
||||||
|
title.textContent = directory ? 'Anwendung bearbeiten' : 'Anwendung hinzufügen';
|
||||||
|
|
||||||
|
// Delete-Button anzeigen/verstecken
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.classList.toggle('hidden', !directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Felder füllen
|
||||||
|
document.getElementById('coding-name').value = directory?.name || '';
|
||||||
|
document.getElementById('coding-description').value = directory?.description || '';
|
||||||
|
document.getElementById('coding-branch').value = directory?.defaultBranch || 'main';
|
||||||
|
|
||||||
|
// CLAUDE.md: Nur aus Dateisystem anzeigen
|
||||||
|
const claudeContent = directory?.claudeMdFromDisk || '';
|
||||||
|
this.updateClaudeLink(claudeContent, directory?.name);
|
||||||
|
|
||||||
|
// Pfad-Preview aktualisieren
|
||||||
|
this.updatePathPreview();
|
||||||
|
|
||||||
|
// Farbe setzen
|
||||||
|
const color = directory?.color || '#4F46E5';
|
||||||
|
document.getElementById('coding-color-custom').value = color;
|
||||||
|
document.querySelectorAll('.color-preset').forEach(btn => {
|
||||||
|
btn.classList.toggle('selected', btn.dataset.color === color);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gitea-Sektion zurücksetzen
|
||||||
|
const giteaSection = document.querySelector('.coding-gitea-section');
|
||||||
|
if (giteaSection) {
|
||||||
|
giteaSection.open = !!directory?.giteaRepoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repos laden wenn nötig
|
||||||
|
if (directory?.giteaRepoUrl) {
|
||||||
|
this.loadGiteaRepos();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal und Overlay anzeigen
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('visible');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal schließen
|
||||||
|
*/
|
||||||
|
closeModal() {
|
||||||
|
const modal = document.getElementById('coding-modal');
|
||||||
|
const overlay = document.querySelector('.modal-overlay');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('visible');
|
||||||
|
setTimeout(() => modal.classList.add('hidden'), 200);
|
||||||
|
this.editingDirectory = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
setTimeout(() => overlay.classList.add('hidden'), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichern-Handler
|
||||||
|
*/
|
||||||
|
async handleSave() {
|
||||||
|
const name = document.getElementById('coding-name').value.trim();
|
||||||
|
const description = document.getElementById('coding-description').value.trim();
|
||||||
|
// CLAUDE.md wird nicht mehr gespeichert - nur readonly
|
||||||
|
const defaultBranch = document.getElementById('coding-branch').value.trim() || 'main';
|
||||||
|
|
||||||
|
// Pfad automatisch aus Name generieren
|
||||||
|
const localPath = `${BASE_PATH}/${name}`;
|
||||||
|
|
||||||
|
// Farbe ermitteln
|
||||||
|
const selectedPreset = document.querySelector('.color-preset.selected');
|
||||||
|
const color = selectedPreset?.dataset.color || document.getElementById('coding-color-custom').value;
|
||||||
|
|
||||||
|
// Gitea-Daten
|
||||||
|
const giteaSelect = document.getElementById('coding-gitea-repo');
|
||||||
|
const giteaRepoUrl = giteaSelect?.value || null;
|
||||||
|
const giteaRepoOwner = giteaSelect?.selectedOptions[0]?.dataset.owner || null;
|
||||||
|
const giteaRepoName = giteaSelect?.selectedOptions[0]?.dataset.name || null;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast('Anwendungsname ist erforderlich', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
localPath,
|
||||||
|
description,
|
||||||
|
color,
|
||||||
|
// claudeInstructions entfernt - CLAUDE.md ist readonly
|
||||||
|
giteaRepoUrl,
|
||||||
|
giteaRepoOwner,
|
||||||
|
giteaRepoName,
|
||||||
|
defaultBranch
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.editingDirectory) {
|
||||||
|
await api.updateCodingDirectory(this.editingDirectory.id, data);
|
||||||
|
showToast('Anwendung aktualisiert', 'success');
|
||||||
|
} else {
|
||||||
|
await api.createCodingDirectory(data);
|
||||||
|
showToast('Anwendung hinzugefügt', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closeModal();
|
||||||
|
await this.loadDirectories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Speichern:', error);
|
||||||
|
showToast(error.message || 'Fehler beim Speichern', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löschen-Handler
|
||||||
|
*/
|
||||||
|
async handleDelete() {
|
||||||
|
if (!this.editingDirectory) return;
|
||||||
|
|
||||||
|
if (!confirm(`Anwendung "${this.editingDirectory.name}" wirklich löschen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteCodingDirectory(this.editingDirectory.id);
|
||||||
|
showToast('Anwendung gelöscht', 'success');
|
||||||
|
this.closeModal();
|
||||||
|
await this.loadDirectories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen:', error);
|
||||||
|
showToast('Fehler beim Löschen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-Refresh starten
|
||||||
|
*/
|
||||||
|
startAutoRefresh() {
|
||||||
|
this.stopAutoRefresh();
|
||||||
|
// Alle 30 Sekunden aktualisieren
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this.directories.forEach(dir => this.updateTileStatus(dir.id));
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-Refresh stoppen
|
||||||
|
*/
|
||||||
|
stopAutoRefresh() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval);
|
||||||
|
this.refreshInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View anzeigen
|
||||||
|
*/
|
||||||
|
async show() {
|
||||||
|
await this.loadDirectories();
|
||||||
|
this.startAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View verstecken
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
this.stopAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton-Instanz erstellen und exportieren
|
||||||
|
const codingManager = new CodingManager();
|
||||||
|
export default codingManager;
|
||||||
@ -86,6 +86,13 @@ class ListViewManager {
|
|||||||
this.contentElement.addEventListener('click', (e) => this.handleContentClick(e));
|
this.contentElement.addEventListener('click', (e) => this.handleContentClick(e));
|
||||||
this.contentElement.addEventListener('change', (e) => this.handleContentChange(e));
|
this.contentElement.addEventListener('change', (e) => this.handleContentChange(e));
|
||||||
this.contentElement.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
|
this.contentElement.addEventListener('dblclick', (e) => this.handleDoubleClick(e));
|
||||||
|
|
||||||
|
// Stop editing when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (this.editingCell && !this.editingCell.contains(e.target)) {
|
||||||
|
this.stopEditing();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -407,19 +414,53 @@ class ListViewManager {
|
|||||||
const users = store.get('users');
|
const users = store.get('users');
|
||||||
const cell = createElement('div', { className: 'list-cell list-cell-assignee list-cell-editable' });
|
const cell = createElement('div', { className: 'list-cell list-cell-assignee list-cell-editable' });
|
||||||
|
|
||||||
const assignedUser = users.find(u => u.id === task.assignedTo);
|
// Sammle alle zugewiesenen Benutzer aus assignees Array
|
||||||
|
const assignedUserIds = new Set();
|
||||||
|
|
||||||
// Avatar
|
// Verwende das assignees Array vom Backend
|
||||||
if (assignedUser) {
|
if (task.assignees && Array.isArray(task.assignees)) {
|
||||||
const avatar = createElement('div', {
|
task.assignees.forEach(assignee => {
|
||||||
className: 'avatar',
|
if (assignee && assignee.id) {
|
||||||
style: { backgroundColor: assignedUser.color || '#6366F1' }
|
assignedUserIds.add(assignee.id);
|
||||||
}, [getInitials(assignedUser.displayName)]);
|
}
|
||||||
cell.appendChild(avatar);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// User dropdown
|
// Fallback: Füge assigned_to hinzu falls assignees leer ist
|
||||||
|
if (assignedUserIds.size === 0 && task.assignedTo) {
|
||||||
|
assignedUserIds.add(task.assignedTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container für mehrere Avatare
|
||||||
|
const avatarContainer = createElement('div', { className: 'avatar-container' });
|
||||||
|
|
||||||
|
if (assignedUserIds.size > 0) {
|
||||||
|
// Erstelle Avatar für jeden zugewiesenen Benutzer
|
||||||
|
Array.from(assignedUserIds).forEach(userId => {
|
||||||
|
const user = users.find(u => u.id === userId);
|
||||||
|
if (user) {
|
||||||
|
const avatar = createElement('div', {
|
||||||
|
className: 'avatar',
|
||||||
|
style: { backgroundColor: user.color || '#6366F1' },
|
||||||
|
title: user.displayName // Tooltip zeigt Name beim Hover
|
||||||
|
}, [getInitials(user.displayName)]);
|
||||||
|
avatarContainer.appendChild(avatar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Placeholder für "nicht zugewiesen"
|
||||||
|
const placeholder = createElement('div', {
|
||||||
|
className: 'avatar avatar-empty',
|
||||||
|
title: 'Nicht zugewiesen'
|
||||||
|
}, ['?']);
|
||||||
|
avatarContainer.appendChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.appendChild(avatarContainer);
|
||||||
|
|
||||||
|
// User dropdown (versteckt, nur für Bearbeitung)
|
||||||
const select = createElement('select', {
|
const select = createElement('select', {
|
||||||
|
className: 'assignee-select hidden',
|
||||||
dataset: { field: 'assignedTo', taskId: task.id }
|
dataset: { field: 'assignedTo', taskId: task.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -445,6 +486,24 @@ class ListViewManager {
|
|||||||
// =====================
|
// =====================
|
||||||
|
|
||||||
handleContentClick(e) {
|
handleContentClick(e) {
|
||||||
|
// Handle avatar click for assignee editing
|
||||||
|
if (e.target.classList.contains('avatar') || e.target.classList.contains('avatar-empty')) {
|
||||||
|
const cell = e.target.closest('.list-cell-assignee');
|
||||||
|
if (cell) {
|
||||||
|
this.startEditingAssignee(cell);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle click on avatar container (wenn man neben Avatar klickt)
|
||||||
|
if (e.target.classList.contains('avatar-container')) {
|
||||||
|
const cell = e.target.closest('.list-cell-assignee');
|
||||||
|
if (cell) {
|
||||||
|
this.startEditingAssignee(cell);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const target = e.target.closest('[data-action]');
|
const target = e.target.closest('[data-action]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
@ -456,6 +515,35 @@ class ListViewManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start editing assignee
|
||||||
|
*/
|
||||||
|
startEditingAssignee(cell) {
|
||||||
|
// Stop any current editing
|
||||||
|
this.stopEditing();
|
||||||
|
|
||||||
|
// Add editing class to show dropdown and hide avatar
|
||||||
|
cell.classList.add('editing');
|
||||||
|
|
||||||
|
// Focus the select element
|
||||||
|
const select = cell.querySelector('.assignee-select');
|
||||||
|
if (select) {
|
||||||
|
select.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editingCell = cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop editing
|
||||||
|
*/
|
||||||
|
stopEditing() {
|
||||||
|
if (this.editingCell) {
|
||||||
|
this.editingCell.classList.remove('editing');
|
||||||
|
this.editingCell = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleContentChange(e) {
|
handleContentChange(e) {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
const field = target.dataset.field;
|
const field = target.dataset.field;
|
||||||
@ -463,6 +551,11 @@ class ListViewManager {
|
|||||||
|
|
||||||
if (field && taskId) {
|
if (field && taskId) {
|
||||||
this.updateTaskField(parseInt(taskId), field, target.value);
|
this.updateTaskField(parseInt(taskId), field, target.value);
|
||||||
|
|
||||||
|
// Stop editing after change for assignee field
|
||||||
|
if (field === 'assignedTo') {
|
||||||
|
this.stopEditing();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
696
frontend/js/mobile.js
Normale Datei
696
frontend/js/mobile.js
Normale Datei
@ -0,0 +1,696 @@
|
|||||||
|
/**
|
||||||
|
* TASKMATE - Mobile Module
|
||||||
|
* ========================
|
||||||
|
* Touch-Gesten, Hamburger-Menu, Swipe-Navigation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $, $$ } from './utils.js';
|
||||||
|
|
||||||
|
class MobileManager {
|
||||||
|
constructor() {
|
||||||
|
// State
|
||||||
|
this.isMenuOpen = false;
|
||||||
|
this.isMobile = false;
|
||||||
|
this.currentView = 'board';
|
||||||
|
|
||||||
|
// Swipe state
|
||||||
|
this.touchStartX = 0;
|
||||||
|
this.touchStartY = 0;
|
||||||
|
this.touchCurrentX = 0;
|
||||||
|
this.touchCurrentY = 0;
|
||||||
|
this.touchStartTime = 0;
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDirection = null;
|
||||||
|
|
||||||
|
// Touch drag & drop state
|
||||||
|
this.touchDraggedElement = null;
|
||||||
|
this.touchDragPlaceholder = null;
|
||||||
|
this.touchDragStartX = 0;
|
||||||
|
this.touchDragStartY = 0;
|
||||||
|
this.touchDragOffsetX = 0;
|
||||||
|
this.touchDragOffsetY = 0;
|
||||||
|
this.touchDragScrollInterval = null;
|
||||||
|
this.longPressTimer = null;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
this.SWIPE_THRESHOLD = 50;
|
||||||
|
this.SWIPE_VELOCITY_THRESHOLD = 0.3;
|
||||||
|
this.MOBILE_BREAKPOINT = 768;
|
||||||
|
this.LONG_PRESS_DURATION = 300;
|
||||||
|
|
||||||
|
// View order for swipe navigation
|
||||||
|
this.viewOrder = ['board', 'list', 'calendar', 'proposals', 'gitea', 'knowledge'];
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
this.hamburgerBtn = null;
|
||||||
|
this.mobileMenu = null;
|
||||||
|
this.mobileOverlay = null;
|
||||||
|
this.mainContent = null;
|
||||||
|
this.swipeIndicatorLeft = null;
|
||||||
|
this.swipeIndicatorRight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize mobile features
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Check if mobile
|
||||||
|
this.checkMobile();
|
||||||
|
window.addEventListener('resize', () => this.checkMobile());
|
||||||
|
|
||||||
|
// Cache DOM elements
|
||||||
|
this.hamburgerBtn = $('#hamburger-btn');
|
||||||
|
this.mobileMenu = $('#mobile-menu');
|
||||||
|
this.mobileOverlay = $('#mobile-menu-overlay');
|
||||||
|
this.mainContent = $('.main-content');
|
||||||
|
this.swipeIndicatorLeft = $('#swipe-indicator-left');
|
||||||
|
this.swipeIndicatorRight = $('#swipe-indicator-right');
|
||||||
|
|
||||||
|
// Bind events
|
||||||
|
this.bindMenuEvents();
|
||||||
|
this.bindSwipeEvents();
|
||||||
|
this.bindTouchDragEvents();
|
||||||
|
|
||||||
|
// Listen for view changes
|
||||||
|
document.addEventListener('view:changed', (e) => {
|
||||||
|
this.currentView = e.detail?.view || 'board';
|
||||||
|
this.updateActiveNavItem(this.currentView);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for project changes
|
||||||
|
document.addEventListener('projects:loaded', () => {
|
||||||
|
this.populateMobileProjectSelect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for user updates
|
||||||
|
document.addEventListener('user:updated', () => {
|
||||||
|
this.updateUserInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Mobile] Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current viewport is mobile
|
||||||
|
*/
|
||||||
|
checkMobile() {
|
||||||
|
this.isMobile = window.innerWidth <= this.MOBILE_BREAKPOINT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// HAMBURGER MENU
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind menu events
|
||||||
|
*/
|
||||||
|
bindMenuEvents() {
|
||||||
|
// Hamburger button
|
||||||
|
this.hamburgerBtn?.addEventListener('click', () => this.toggleMenu());
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
$('#mobile-menu-close')?.addEventListener('click', () => this.closeMenu());
|
||||||
|
|
||||||
|
// Overlay click
|
||||||
|
this.mobileOverlay?.addEventListener('click', () => this.closeMenu());
|
||||||
|
|
||||||
|
// Navigation items
|
||||||
|
$$('.mobile-nav-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const view = item.dataset.view;
|
||||||
|
this.switchView(view);
|
||||||
|
this.closeMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Project selector
|
||||||
|
$('#mobile-project-select')?.addEventListener('change', (e) => {
|
||||||
|
const projectId = parseInt(e.target.value);
|
||||||
|
if (projectId) {
|
||||||
|
document.dispatchEvent(new CustomEvent('project:selected', {
|
||||||
|
detail: { projectId }
|
||||||
|
}));
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin button
|
||||||
|
$('#mobile-admin-btn')?.addEventListener('click', () => {
|
||||||
|
this.closeMenu();
|
||||||
|
document.dispatchEvent(new CustomEvent('admin:open'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout button
|
||||||
|
$('#mobile-logout-btn')?.addEventListener('click', () => {
|
||||||
|
this.closeMenu();
|
||||||
|
document.dispatchEvent(new CustomEvent('auth:logout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key to close
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && this.isMenuOpen) {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle menu open/close
|
||||||
|
*/
|
||||||
|
toggleMenu() {
|
||||||
|
if (this.isMenuOpen) {
|
||||||
|
this.closeMenu();
|
||||||
|
} else {
|
||||||
|
this.openMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open mobile menu
|
||||||
|
*/
|
||||||
|
openMenu() {
|
||||||
|
this.isMenuOpen = true;
|
||||||
|
this.hamburgerBtn?.classList.add('active');
|
||||||
|
this.hamburgerBtn?.setAttribute('aria-expanded', 'true');
|
||||||
|
this.mobileMenu?.classList.add('open');
|
||||||
|
this.mobileMenu?.setAttribute('aria-hidden', 'false');
|
||||||
|
this.mobileOverlay?.classList.add('visible');
|
||||||
|
document.body.classList.add('mobile-menu-open');
|
||||||
|
|
||||||
|
// Update user info when menu opens
|
||||||
|
this.updateUserInfo();
|
||||||
|
this.populateMobileProjectSelect();
|
||||||
|
|
||||||
|
// Focus close button
|
||||||
|
setTimeout(() => {
|
||||||
|
$('#mobile-menu-close')?.focus();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close mobile menu
|
||||||
|
*/
|
||||||
|
closeMenu() {
|
||||||
|
this.isMenuOpen = false;
|
||||||
|
this.hamburgerBtn?.classList.remove('active');
|
||||||
|
this.hamburgerBtn?.setAttribute('aria-expanded', 'false');
|
||||||
|
this.mobileMenu?.classList.remove('open');
|
||||||
|
this.mobileMenu?.setAttribute('aria-hidden', 'true');
|
||||||
|
this.mobileOverlay?.classList.remove('visible');
|
||||||
|
document.body.classList.remove('mobile-menu-open');
|
||||||
|
|
||||||
|
// Return focus
|
||||||
|
this.hamburgerBtn?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different view
|
||||||
|
*/
|
||||||
|
switchView(view) {
|
||||||
|
if (!this.viewOrder.includes(view)) return;
|
||||||
|
|
||||||
|
this.currentView = view;
|
||||||
|
|
||||||
|
// Update desktop tabs
|
||||||
|
$$('.view-tab').forEach(tab => {
|
||||||
|
tab.classList.toggle('active', tab.dataset.view === view);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide views
|
||||||
|
$$('.view').forEach(v => {
|
||||||
|
const viewName = v.id.replace('view-', '');
|
||||||
|
const isActive = viewName === view;
|
||||||
|
v.classList.toggle('active', isActive);
|
||||||
|
v.classList.toggle('hidden', !isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update mobile nav
|
||||||
|
this.updateActiveNavItem(view);
|
||||||
|
|
||||||
|
// Dispatch event for other modules
|
||||||
|
document.dispatchEvent(new CustomEvent('view:changed', {
|
||||||
|
detail: { view }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update active nav item in mobile menu
|
||||||
|
*/
|
||||||
|
updateActiveNavItem(view) {
|
||||||
|
$$('.mobile-nav-item').forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.view === view);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate project select dropdown
|
||||||
|
*/
|
||||||
|
populateMobileProjectSelect() {
|
||||||
|
const select = $('#mobile-project-select');
|
||||||
|
const desktopSelect = $('#project-select');
|
||||||
|
if (!select || !desktopSelect) return;
|
||||||
|
|
||||||
|
// Copy options from desktop select
|
||||||
|
select.innerHTML = desktopSelect.innerHTML;
|
||||||
|
select.value = desktopSelect.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user info in mobile menu
|
||||||
|
*/
|
||||||
|
updateUserInfo() {
|
||||||
|
const avatar = $('#mobile-user-avatar');
|
||||||
|
const name = $('#mobile-user-name');
|
||||||
|
const role = $('#mobile-user-role');
|
||||||
|
const adminBtn = $('#mobile-admin-btn');
|
||||||
|
|
||||||
|
// Get user info from desktop user dropdown
|
||||||
|
const desktopAvatar = $('#user-avatar');
|
||||||
|
const desktopDropdown = $('.user-dropdown');
|
||||||
|
|
||||||
|
if (avatar && desktopAvatar) {
|
||||||
|
avatar.textContent = desktopAvatar.textContent;
|
||||||
|
avatar.style.backgroundColor = desktopAvatar.style.backgroundColor || 'var(--primary)';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
const usernameEl = desktopDropdown?.querySelector('.user-info strong');
|
||||||
|
name.textContent = usernameEl?.textContent || 'Benutzer';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
const roleEl = desktopDropdown?.querySelector('.user-info span:not(strong)');
|
||||||
|
role.textContent = roleEl?.textContent || 'Angemeldet';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show admin button for admins
|
||||||
|
if (adminBtn) {
|
||||||
|
const isAdmin = role?.textContent?.toLowerCase().includes('admin');
|
||||||
|
adminBtn.classList.toggle('hidden', !isAdmin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// SWIPE NAVIGATION
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind swipe events
|
||||||
|
*/
|
||||||
|
bindSwipeEvents() {
|
||||||
|
if (!this.mainContent) return;
|
||||||
|
|
||||||
|
this.mainContent.addEventListener('touchstart', (e) => this.handleSwipeStart(e), { passive: true });
|
||||||
|
this.mainContent.addEventListener('touchmove', (e) => this.handleSwipeMove(e), { passive: false });
|
||||||
|
this.mainContent.addEventListener('touchend', (e) => this.handleSwipeEnd(e), { passive: true });
|
||||||
|
this.mainContent.addEventListener('touchcancel', () => this.resetSwipe(), { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle swipe start
|
||||||
|
*/
|
||||||
|
handleSwipeStart(e) {
|
||||||
|
if (!this.isMobile) return;
|
||||||
|
|
||||||
|
// Don't swipe if menu is open
|
||||||
|
if (this.isMenuOpen) return;
|
||||||
|
|
||||||
|
// Don't swipe if modal is open
|
||||||
|
if ($('.modal-overlay:not(.hidden)')) return;
|
||||||
|
|
||||||
|
// Don't swipe on scrollable elements
|
||||||
|
const target = e.target;
|
||||||
|
if (target.closest('.column-body') ||
|
||||||
|
target.closest('.modal') ||
|
||||||
|
target.closest('.calendar-grid') ||
|
||||||
|
target.closest('.knowledge-entry-list') ||
|
||||||
|
target.closest('.list-table') ||
|
||||||
|
target.closest('input') ||
|
||||||
|
target.closest('textarea') ||
|
||||||
|
target.closest('select')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only single touch
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
|
||||||
|
this.touchStartX = e.touches[0].clientX;
|
||||||
|
this.touchStartY = e.touches[0].clientY;
|
||||||
|
this.touchStartTime = Date.now();
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDirection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle swipe move
|
||||||
|
*/
|
||||||
|
handleSwipeMove(e) {
|
||||||
|
if (!this.isMobile || this.touchStartX === 0) return;
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.touchCurrentX = touch.clientX;
|
||||||
|
this.touchCurrentY = touch.clientY;
|
||||||
|
|
||||||
|
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||||
|
const deltaY = this.touchCurrentY - this.touchStartY;
|
||||||
|
|
||||||
|
// Determine direction on first significant movement
|
||||||
|
if (!this.swipeDirection && (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10)) {
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY) * 1.5) {
|
||||||
|
this.swipeDirection = 'horizontal';
|
||||||
|
this.isSwiping = true;
|
||||||
|
document.body.classList.add('is-swiping');
|
||||||
|
} else {
|
||||||
|
this.swipeDirection = 'vertical';
|
||||||
|
this.resetSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.swipeDirection !== 'horizontal') return;
|
||||||
|
|
||||||
|
// Prevent scroll
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Show indicators
|
||||||
|
const currentIndex = this.viewOrder.indexOf(this.currentView);
|
||||||
|
|
||||||
|
if (deltaX > this.SWIPE_THRESHOLD && currentIndex > 0) {
|
||||||
|
this.swipeIndicatorLeft?.classList.add('visible');
|
||||||
|
this.swipeIndicatorRight?.classList.remove('visible');
|
||||||
|
} else if (deltaX < -this.SWIPE_THRESHOLD && currentIndex < this.viewOrder.length - 1) {
|
||||||
|
this.swipeIndicatorRight?.classList.add('visible');
|
||||||
|
this.swipeIndicatorLeft?.classList.remove('visible');
|
||||||
|
} else {
|
||||||
|
this.swipeIndicatorLeft?.classList.remove('visible');
|
||||||
|
this.swipeIndicatorRight?.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle swipe end
|
||||||
|
*/
|
||||||
|
handleSwipeEnd() {
|
||||||
|
if (!this.isSwiping || this.swipeDirection !== 'horizontal') {
|
||||||
|
this.resetSwipe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = this.touchCurrentX - this.touchStartX;
|
||||||
|
const deltaTime = Date.now() - this.touchStartTime;
|
||||||
|
const velocity = Math.abs(deltaX) / deltaTime;
|
||||||
|
|
||||||
|
// Valid swipe?
|
||||||
|
const isValidSwipe = Math.abs(deltaX) > this.SWIPE_THRESHOLD || velocity > this.SWIPE_VELOCITY_THRESHOLD;
|
||||||
|
|
||||||
|
if (isValidSwipe) {
|
||||||
|
const currentIndex = this.viewOrder.indexOf(this.currentView);
|
||||||
|
|
||||||
|
if (deltaX > 0 && currentIndex > 0) {
|
||||||
|
// Swipe right - previous view
|
||||||
|
this.switchView(this.viewOrder[currentIndex - 1]);
|
||||||
|
} else if (deltaX < 0 && currentIndex < this.viewOrder.length - 1) {
|
||||||
|
// Swipe left - next view
|
||||||
|
this.switchView(this.viewOrder[currentIndex + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resetSwipe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset swipe state
|
||||||
|
*/
|
||||||
|
resetSwipe() {
|
||||||
|
this.touchStartX = 0;
|
||||||
|
this.touchStartY = 0;
|
||||||
|
this.touchCurrentX = 0;
|
||||||
|
this.touchCurrentY = 0;
|
||||||
|
this.touchStartTime = 0;
|
||||||
|
this.isSwiping = false;
|
||||||
|
this.swipeDirection = null;
|
||||||
|
document.body.classList.remove('is-swiping');
|
||||||
|
this.swipeIndicatorLeft?.classList.remove('visible');
|
||||||
|
this.swipeIndicatorRight?.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// TOUCH DRAG & DROP
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind touch drag events
|
||||||
|
*/
|
||||||
|
bindTouchDragEvents() {
|
||||||
|
const board = $('#board');
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
board.addEventListener('touchstart', (e) => this.handleTouchDragStart(e), { passive: false });
|
||||||
|
board.addEventListener('touchmove', (e) => this.handleTouchDragMove(e), { passive: false });
|
||||||
|
board.addEventListener('touchend', (e) => this.handleTouchDragEnd(e), { passive: true });
|
||||||
|
board.addEventListener('touchcancel', () => this.cancelTouchDrag(), { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch drag start
|
||||||
|
*/
|
||||||
|
handleTouchDragStart(e) {
|
||||||
|
if (!this.isMobile) return;
|
||||||
|
|
||||||
|
const taskCard = e.target.closest('.task-card');
|
||||||
|
if (!taskCard) return;
|
||||||
|
|
||||||
|
// Cancel if multi-touch
|
||||||
|
if (e.touches.length > 1) {
|
||||||
|
this.cancelTouchDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
this.touchDragStartX = touch.clientX;
|
||||||
|
this.touchDragStartY = touch.clientY;
|
||||||
|
|
||||||
|
// Long press to start drag
|
||||||
|
this.longPressTimer = setTimeout(() => {
|
||||||
|
this.startTouchDrag(taskCard, touch);
|
||||||
|
}, this.LONG_PRESS_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start touch drag
|
||||||
|
*/
|
||||||
|
startTouchDrag(taskCard, touch) {
|
||||||
|
this.touchDraggedElement = taskCard;
|
||||||
|
const rect = taskCard.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate offset
|
||||||
|
this.touchDragOffsetX = touch.clientX - rect.left;
|
||||||
|
this.touchDragOffsetY = touch.clientY - rect.top;
|
||||||
|
|
||||||
|
// Create placeholder
|
||||||
|
this.touchDragPlaceholder = document.createElement('div');
|
||||||
|
this.touchDragPlaceholder.className = 'task-card touch-drag-placeholder';
|
||||||
|
this.touchDragPlaceholder.style.height = rect.height + 'px';
|
||||||
|
taskCard.parentNode.insertBefore(this.touchDragPlaceholder, taskCard);
|
||||||
|
|
||||||
|
// Style dragged element
|
||||||
|
taskCard.classList.add('touch-dragging');
|
||||||
|
taskCard.style.position = 'fixed';
|
||||||
|
taskCard.style.left = rect.left + 'px';
|
||||||
|
taskCard.style.top = rect.top + 'px';
|
||||||
|
taskCard.style.width = rect.width + 'px';
|
||||||
|
taskCard.style.zIndex = '1000';
|
||||||
|
|
||||||
|
document.body.classList.add('is-touch-dragging');
|
||||||
|
|
||||||
|
// Haptic feedback
|
||||||
|
if (navigator.vibrate) {
|
||||||
|
navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch drag move
|
||||||
|
*/
|
||||||
|
handleTouchDragMove(e) {
|
||||||
|
// Cancel long press if finger moved
|
||||||
|
if (this.longPressTimer && !this.touchDraggedElement) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const deltaX = Math.abs(touch.clientX - this.touchDragStartX);
|
||||||
|
const deltaY = Math.abs(touch.clientY - this.touchDragStartY);
|
||||||
|
if (deltaX > 10 || deltaY > 10) {
|
||||||
|
clearTimeout(this.longPressTimer);
|
||||||
|
this.longPressTimer = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.touchDraggedElement) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const taskCard = this.touchDraggedElement;
|
||||||
|
|
||||||
|
// Move element
|
||||||
|
taskCard.style.left = (touch.clientX - this.touchDragOffsetX) + 'px';
|
||||||
|
taskCard.style.top = (touch.clientY - this.touchDragOffsetY) + 'px';
|
||||||
|
|
||||||
|
// Find drop target
|
||||||
|
taskCard.style.pointerEvents = 'none';
|
||||||
|
const elemBelow = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
taskCard.style.pointerEvents = '';
|
||||||
|
|
||||||
|
const columnBody = elemBelow?.closest('.column-body');
|
||||||
|
|
||||||
|
// Remove previous indicators
|
||||||
|
$$('.column-body.touch-drag-over').forEach(el => el.classList.remove('touch-drag-over'));
|
||||||
|
|
||||||
|
if (columnBody) {
|
||||||
|
columnBody.classList.add('touch-drag-over');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
this.autoScrollWhileDragging(touch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-scroll while dragging near edges
|
||||||
|
*/
|
||||||
|
autoScrollWhileDragging(touch) {
|
||||||
|
const board = $('#board');
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
const boardRect = board.getBoundingClientRect();
|
||||||
|
const scrollThreshold = 50;
|
||||||
|
const scrollSpeed = 8;
|
||||||
|
|
||||||
|
// Clear existing interval
|
||||||
|
if (this.touchDragScrollInterval) {
|
||||||
|
clearInterval(this.touchDragScrollInterval);
|
||||||
|
this.touchDragScrollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll left
|
||||||
|
if (touch.clientX < boardRect.left + scrollThreshold) {
|
||||||
|
this.touchDragScrollInterval = setInterval(() => {
|
||||||
|
board.scrollLeft -= scrollSpeed;
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
// Scroll right
|
||||||
|
else if (touch.clientX > boardRect.right - scrollThreshold) {
|
||||||
|
this.touchDragScrollInterval = setInterval(() => {
|
||||||
|
board.scrollLeft += scrollSpeed;
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle touch drag end
|
||||||
|
*/
|
||||||
|
handleTouchDragEnd(e) {
|
||||||
|
// Clear long press timer
|
||||||
|
if (this.longPressTimer) {
|
||||||
|
clearTimeout(this.longPressTimer);
|
||||||
|
this.longPressTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.touchDraggedElement) return;
|
||||||
|
|
||||||
|
const touch = e.changedTouches[0];
|
||||||
|
|
||||||
|
// Find drop target
|
||||||
|
this.touchDraggedElement.style.pointerEvents = 'none';
|
||||||
|
const elemBelow = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||||
|
this.touchDraggedElement.style.pointerEvents = '';
|
||||||
|
|
||||||
|
const columnBody = elemBelow?.closest('.column-body');
|
||||||
|
|
||||||
|
if (columnBody) {
|
||||||
|
const columnId = parseInt(columnBody.closest('.column').dataset.columnId);
|
||||||
|
const taskId = parseInt(this.touchDraggedElement.dataset.taskId);
|
||||||
|
const position = this.calculateDropPosition(columnBody, touch.clientY);
|
||||||
|
|
||||||
|
// Dispatch move event
|
||||||
|
document.dispatchEvent(new CustomEvent('task:move', {
|
||||||
|
detail: { taskId, columnId, position }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cleanupTouchDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate drop position in column
|
||||||
|
*/
|
||||||
|
calculateDropPosition(columnBody, mouseY) {
|
||||||
|
const taskCards = Array.from(columnBody.querySelectorAll('.task-card:not(.touch-dragging):not(.touch-drag-placeholder)'));
|
||||||
|
let position = taskCards.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < taskCards.length; i++) {
|
||||||
|
const rect = taskCards[i].getBoundingClientRect();
|
||||||
|
if (mouseY < rect.top + rect.height / 2) {
|
||||||
|
position = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel touch drag
|
||||||
|
*/
|
||||||
|
cancelTouchDrag() {
|
||||||
|
if (this.longPressTimer) {
|
||||||
|
clearTimeout(this.longPressTimer);
|
||||||
|
this.longPressTimer = null;
|
||||||
|
}
|
||||||
|
this.cleanupTouchDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup after touch drag
|
||||||
|
*/
|
||||||
|
cleanupTouchDrag() {
|
||||||
|
// Clear scroll interval
|
||||||
|
if (this.touchDragScrollInterval) {
|
||||||
|
clearInterval(this.touchDragScrollInterval);
|
||||||
|
this.touchDragScrollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset dragged element
|
||||||
|
if (this.touchDraggedElement) {
|
||||||
|
this.touchDraggedElement.classList.remove('touch-dragging');
|
||||||
|
this.touchDraggedElement.style.position = '';
|
||||||
|
this.touchDraggedElement.style.left = '';
|
||||||
|
this.touchDraggedElement.style.top = '';
|
||||||
|
this.touchDraggedElement.style.width = '';
|
||||||
|
this.touchDraggedElement.style.zIndex = '';
|
||||||
|
this.touchDraggedElement.style.transform = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove placeholder
|
||||||
|
if (this.touchDragPlaceholder) {
|
||||||
|
this.touchDragPlaceholder.remove();
|
||||||
|
this.touchDragPlaceholder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove indicators
|
||||||
|
$$('.column-body.touch-drag-over').forEach(el => el.classList.remove('touch-drag-over'));
|
||||||
|
|
||||||
|
document.body.classList.remove('is-touch-dragging');
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
this.touchDraggedElement = null;
|
||||||
|
this.touchDragStartX = 0;
|
||||||
|
this.touchDragStartY = 0;
|
||||||
|
this.touchDragOffsetX = 0;
|
||||||
|
this.touchDragOffsetY = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export singleton
|
||||||
|
const mobileManager = new MobileManager();
|
||||||
|
|
||||||
|
export default mobileManager;
|
||||||
@ -122,6 +122,13 @@ class NotificationManager {
|
|||||||
updateBadge(count) {
|
updateBadge(count) {
|
||||||
this.unreadCount = count;
|
this.unreadCount = count;
|
||||||
|
|
||||||
|
// Sicherstellen, dass badge-Element existiert
|
||||||
|
if (!this.badge) {
|
||||||
|
this.badge = document.getElementById('notification-badge');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.badge) return; // Wenn immer noch nicht gefunden, abbrechen
|
||||||
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
this.badge.textContent = count > 99 ? '99+' : count;
|
this.badge.textContent = count > 99 ? '99+' : count;
|
||||||
this.badge.classList.remove('hidden');
|
this.badge.classList.remove('hidden');
|
||||||
@ -441,6 +448,12 @@ class NotificationManager {
|
|||||||
this.unreadCount = 0;
|
this.unreadCount = 0;
|
||||||
this.isDropdownOpen = false;
|
this.isDropdownOpen = false;
|
||||||
this.closeDropdown();
|
this.closeDropdown();
|
||||||
|
|
||||||
|
// Elements neu binden falls nötig
|
||||||
|
if (!this.badge || !this.bellContainer) {
|
||||||
|
this.bindElements();
|
||||||
|
}
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* Offline support and caching
|
* Offline support and caching
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_VERSION = '152';
|
const CACHE_VERSION = '189';
|
||||||
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
const CACHE_NAME = 'taskmate-v' + CACHE_VERSION;
|
||||||
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
const STATIC_CACHE_NAME = 'taskmate-static-v' + CACHE_VERSION;
|
||||||
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
const DYNAMIC_CACHE_NAME = 'taskmate-dynamic-v' + CACHE_VERSION;
|
||||||
@ -39,12 +39,16 @@ const STATIC_ASSETS = [
|
|||||||
'/js/notifications.js',
|
'/js/notifications.js',
|
||||||
'/js/gitea.js',
|
'/js/gitea.js',
|
||||||
'/js/knowledge.js',
|
'/js/knowledge.js',
|
||||||
|
'/js/coding.js',
|
||||||
|
'/js/mobile.js',
|
||||||
'/css/list.css',
|
'/css/list.css',
|
||||||
|
'/css/mobile.css',
|
||||||
'/css/admin.css',
|
'/css/admin.css',
|
||||||
'/css/proposals.css',
|
'/css/proposals.css',
|
||||||
'/css/notifications.css',
|
'/css/notifications.css',
|
||||||
'/css/gitea.css',
|
'/css/gitea.css',
|
||||||
'/css/knowledge.css'
|
'/css/knowledge.css',
|
||||||
|
'/css/coding.css'
|
||||||
];
|
];
|
||||||
|
|
||||||
// API routes to cache
|
// API routes to cache
|
||||||
|
|||||||
24778
logs/app.log
24778
logs/app.log
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
87
query_users.js
Normale Datei
87
query_users.js
Normale Datei
@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script zum Abfragen der Benutzer aus der SQLite-Datenbank
|
||||||
|
* Verwendung: node query_users.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Datenbank-Pfad - angepasst für Docker-Container
|
||||||
|
const DB_PATH = process.env.DB_PATH || './data/taskmate.db';
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Verbinde zur Datenbank:', DB_PATH);
|
||||||
|
|
||||||
|
// Datenbank öffnen
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
// Alle Benutzer abfragen
|
||||||
|
const users = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
color,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
repositories_base_path,
|
||||||
|
created_at,
|
||||||
|
last_login,
|
||||||
|
failed_attempts,
|
||||||
|
locked_until
|
||||||
|
FROM users
|
||||||
|
ORDER BY id
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
console.log('\n=== BENUTZER IN DER DATENBANK ===\n');
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
console.log('Keine Benutzer gefunden!');
|
||||||
|
} else {
|
||||||
|
users.forEach(user => {
|
||||||
|
console.log(`ID: ${user.id}`);
|
||||||
|
console.log(`Benutzername: ${user.username}`);
|
||||||
|
console.log(`Anzeigename: ${user.display_name}`);
|
||||||
|
console.log(`Farbe: ${user.color}`);
|
||||||
|
console.log(`Rolle: ${user.role || 'user'}`);
|
||||||
|
console.log(`E-Mail: ${user.email || 'nicht gesetzt'}`);
|
||||||
|
console.log(`Repository-Basispfad: ${user.repositories_base_path || 'nicht gesetzt'}`);
|
||||||
|
console.log(`Erstellt am: ${user.created_at}`);
|
||||||
|
console.log(`Letzter Login: ${user.last_login || 'noch nie'}`);
|
||||||
|
console.log(`Fehlgeschlagene Versuche: ${user.failed_attempts}`);
|
||||||
|
console.log(`Gesperrt bis: ${user.locked_until || 'nicht gesperrt'}`);
|
||||||
|
console.log('-----------------------------------');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nGesamt: ${users.length} Benutzer gefunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe auch Login-Audit für weitere Informationen
|
||||||
|
const recentAttempts = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
la.timestamp,
|
||||||
|
la.ip_address,
|
||||||
|
la.success,
|
||||||
|
la.user_agent,
|
||||||
|
u.username
|
||||||
|
FROM login_audit la
|
||||||
|
LEFT JOIN users u ON la.user_id = u.id
|
||||||
|
ORDER BY la.timestamp DESC
|
||||||
|
LIMIT 10
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
if (recentAttempts.length > 0) {
|
||||||
|
console.log('\n=== LETZTE LOGIN-VERSUCHE ===\n');
|
||||||
|
recentAttempts.forEach(attempt => {
|
||||||
|
console.log(`${attempt.timestamp}: ${attempt.username || 'Unbekannt'} - ${attempt.success ? 'ERFOLGREICH' : 'FEHLGESCHLAGEN'} - IP: ${attempt.ip_address}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datenbank schließen
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Abfragen der Datenbank:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren