Datenbank bereinigt / Gitea-Integration gefixt

Dieser Commit ist enthalten in:
hendrik_gebhardt@gmx.de
2026-01-04 00:24:11 +00:00
committet von Server Deploy
Ursprung 395598c2b0
Commit c21be47428
37 geänderte Dateien mit 30993 neuen und 809 gelöschten Zeilen

Datei anzeigen

@ -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:*)"
] ]
} }
} }

Datei anzeigen

@ -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.

Datei anzeigen

@ -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 &amp;, < zu &lt;, 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)
================================================================================ ================================================================================

508
CLAUDE.md
Datei anzeigen

@ -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
```javascript
// Richtig: Lokale Formatierung
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
// Falsch: UTC-Konvertierung # Container neu bauen (bei Dependency-Änderungen)
const dateStr = date.toISOString().split('T')[0]; // NICHT VERWENDEN! docker build -t taskmate . && docker restart taskmate
```
## Echtzeit-Aktualisierung (KRITISCH) # Logs anzeigen
- ALLE Nutzeranpassungen müssen SOFORT und ÜBERALL in der Anwendung sichtbar sein docker logs taskmate -f
- Der Nutzer darf NIEMALS den Browser aktualisieren müssen (F5), um Änderungen zu sehen
- Beispiele für Änderungen, die sofort überall wirken müssen:
- Spaltenfarbe ändern → Board, Kalender, Wochenstreifen sofort aktualisieren
- Aufgabe erstellen/bearbeiten/löschen → alle Ansichten sofort aktualisieren
- Labels, Benutzer, Projekte ändern → überall sofort sichtbar
- 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 # Health Check
- Du sollst den Dockercontainer eigenständig - sofern erforderlich - neu starten/neu bauen, dass Änderungen wirksam werden curl http://localhost:3000/api/health
- Erreichbarkeit der Anwendung über https://taskmate.aegis-sight.de (keine automatische Browser-Öffnung) ```
### 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
// 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 month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
// ❌ FALSCH - UTC-Konvertierung
const dateStr = date.toISOString().split('T')[0]; // NIEMALS!
```
### Echtzeit-Updates implementieren
```javascript
// PFLICHT für ALLE Komponenten!
// 1. Store-Subscriptions
store.subscribe('tasks', () => renderTasks());
store.subscribe('columns', () => updateColumns());
store.subscribe('labels', () => refreshLabels());
// 2. Event-Listener
window.addEventListener('app:refresh', () => {
// 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.

Datei anzeigen

@ -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++

Datei anzeigen

@ -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

Datei anzeigen

@ -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,

Datei anzeigen

@ -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
}; };

Datei anzeigen

@ -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: [], '&amp;': '&',
allowedAttributes: {} '&lt;': '<',
}).trim(); '&gt;': '>',
'&quot;': '"',
'&#039;': "'",
'&#x27;': "'",
'&apos;': "'"
};
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 &amp;, 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,7 +103,15 @@ 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);
sanitized[key] = sanitizeObject(value, { allowHtml });
// 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 });
}
} }
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 {

Datei anzeigen

@ -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
Datei anzeigen

@ -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);
}

Datei anzeigen

@ -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;

Datei anzeigen

@ -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
logger.info(`Logout: ${req.user.username}`); revokeAllRefreshTokens(req.user.id);
res.json({ message: 'Erfolgreich abgemeldet' });
logger.info(`Logout: ${req.user.username}`);
res.json({ message: 'Erfolgreich abgemeldet' });
} catch (error) {
logger.error('Logout-Fehler:', { error: error.message });
res.status(500).json({ error: 'Logout fehlgeschlagen' });
}
}); });
/** /**
@ -200,27 +205,68 @@ 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 db = getDb(); const { refreshToken } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); const ip = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' }); 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' });
} }
const token = generateToken(user); // Neuen Access-Token mit Refresh-Token generieren
const csrfToken = getTokenForUser(user.id); const accessToken = await refreshAccessToken(refreshToken, ip, userAgent);
const db = getDb();
// User-Daten für CSRF-Token abrufen
const decoded = require('jsonwebtoken').decode(accessToken);
const csrfToken = getTokenForUser(decoded.id);
res.json({ token, csrfToken }); res.json({
token: accessToken,
csrfToken
});
} catch (error) { } catch (error) {
logger.error('Token-Refresh Fehler:', { error: error.message }); logger.error('Token-Refresh Fehler:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' }); 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) {
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' });
}
const newToken = generateToken(dbUser);
const csrfToken = getTokenForUser(dbUser.id);
res.json({ token: newToken, csrfToken });
}
/** /**
* PUT /api/auth/password * PUT /api/auth/password
* Passwort ändern * Passwort ändern

643
backend/routes/coding.js Normale Datei
Datei anzeigen

@ -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;

Datei anzeigen

@ -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
// ============================================================================= // =============================================================================

Datei anzeigen

@ -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;
} }
} }

Datei anzeigen

@ -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
Datei anzeigen

@ -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äre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Binäre Datei nicht angezeigt.

Datei anzeigen

@ -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

Datei anzeigen

@ -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
Datei anzeigen

@ -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;
}

Datei anzeigen

@ -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
Datei anzeigen

@ -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);
}
}

Datei anzeigen

@ -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"> </div>
<input type="checkbox" id="upload-cat-images" checked>
<span class="upload-category-title">Bildformate</span> <!-- Neue Endung hinzufügen -->
</label> <div class="extension-add-group">
</div> <label for="extension-input">Neue Endung hinzufügen</label>
<div class="upload-category-types"> <div class="extension-input-row">
<span class="upload-type-tag" data-type="image/jpeg">JPEG</span> <input type="text" id="extension-input" class="extension-input" placeholder="z.B. xlsx" maxlength="10">
<span class="upload-type-tag" data-type="image/png">PNG</span> <button type="button" id="btn-add-extension" class="btn btn-secondary">+ Hinzufügen</button>
<span class="upload-type-tag" data-type="image/gif">GIF</span>
<span class="upload-type-tag" data-type="image/webp">WebP</span>
<span class="upload-type-tag" data-type="image/svg+xml">SVG</span>
</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">
<!-- Header -->
<!-- Modus-Schalter --> <div class="coding-header coding-header-centered">
<div id="gitea-mode-switch" class="gitea-mode-switch"> <button id="add-coding-directory-btn" class="btn btn-primary">
<button class="gitea-mode-btn active" data-mode="server"> <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"><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> Anwendung hinzufügen
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> </button>
</div> </div>
<!-- Server-Modus Ansicht --> <!-- Grid für Kacheln -->
<div id="gitea-server-mode" class="gitea-section"> <div id="coding-grid" class="coding-grid">
<!-- Kacheln werden per JS gerendert -->
<!-- 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> </div>
<!-- Projekt-Modus: Browser-Upload Ansicht --> <!-- Empty State -->
<div id="gitea-browser-upload" class="gitea-section hidden"> <div id="coding-empty" class="coding-empty hidden">
<div class="empty-icon">
<!-- Header --> <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>
<div class="gitea-config-header">
<h2>
<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> </div>
<h3>Keine Anwendungen</h3>
<!-- Browser-Kompatibilität Hinweis --> <p>Füge deine erste Server-Anwendung hinzu, um mit Claude oder Codex zu arbeiten.</p>
<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> </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>
Neues Repository erstellen
</button>
</div>
<div class="gitea-divider"></div>
<!-- Lokaler Pfad -->
<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>
<!-- Default Branch -->
<div class="form-group">
<label for="default-branch-input">Standard-Branch</label>
<input type="text" id="default-branch-input" class="form-control"
value="main" placeholder="main">
</div>
<div class="form-actions">
<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>
</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>
<input type="text" id="user-password" minlength="8" readonly> <div class="password-input-group">
<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">&times;</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">&times;</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">&times;</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>

Datei anzeigen

@ -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]) { renderExtensionTags() {
const isEnabled = this.uploadSettings.allowedTypes[category].enabled; if (!this.extensionTagsContainer) return;
checkbox.checked = isEnabled;
this.toggleUploadCategory(categoryEl, isEnabled); 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');
} }

Datei anzeigen

@ -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'); // Versuche Token mit Refresh-Token zu erneuern
if (this.refreshToken && !this.refreshingToken && !options._tokenRefreshAttempted) {
// Nur ausloggen wenn der Token der gleiche ist (kein neuer Login in der Zwischenzeit) console.log('[API] Attempting token refresh...');
if (!currentToken || currentToken === requestToken) { try {
console.log('[API] Token invalid, triggering logout'); await this.refreshAccessToken();
this.setToken(null); // Wiederhole original Request mit neuem Token
window.dispatchEvent(new CustomEvent('auth:logout')); return this.request(endpoint, { ...options, _tokenRefreshAttempted: true });
} else { } catch (refreshError) {
console.log('[API] 401 ignored - new login occurred while request was in flight'); 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

Datei anzeigen

@ -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
Datei anzeigen

@ -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;

Datei anzeigen

@ -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
if (assignedUser) { // Verwende das assignees Array vom Backend
const avatar = createElement('div', { if (task.assignees && Array.isArray(task.assignees)) {
className: 'avatar', task.assignees.forEach(assignee => {
style: { backgroundColor: assignedUser.color || '#6366F1' } if (assignee && assignee.id) {
}, [getInitials(assignedUser.displayName)]); assignedUserIds.add(assignee.id);
cell.appendChild(avatar); }
});
}
// Fallback: Füge assigned_to hinzu falls assignees leer ist
if (assignedUserIds.size === 0 && task.assignedTo) {
assignedUserIds.add(task.assignedTo);
} }
// User dropdown // 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
Datei anzeigen

@ -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;

Datei anzeigen

@ -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();
} }
} }

Datei anzeigen

@ -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

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

87
query_users.js Normale Datei
Datei anzeigen

@ -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);
}