diff --git a/backend/app/FEHLER_BEHOBEN.md b/backend/app/FEHLER_BEHOBEN.md index 7e82a892..2da52e5a 100644 --- a/backend/app/FEHLER_BEHOBEN.md +++ b/backend/app/FEHLER_BEHOBEN.md @@ -1,3 +1,98 @@ +## ✅ 30.05.2025 21:15 - SQLite WAL-Dateien beim Programmende bereinigen + +### Problem + +Nach dem Beenden des Programms blieben zwei SQLite-Datenbankdateien zurück: +- `myp.db-shm` (Shared Memory) +- `myp.db-wal` (Write-Ahead Log) + +Diese Dateien sind Teil des SQLite WAL-Modus (Write-Ahead Logging) und sollten normalerweise beim ordnungsgemäßen Herunterfahren automatisch aufgeräumt werden. + +### Root-Cause-Analyse + +**SQLite WAL-Mode Problem:** +- Die Datenbank ist im WAL-Mode konfiguriert (`PRAGMA journal_mode=WAL`) +- WAL-Mode erstellt `.wal` und `.shm` Dateien für bessere Performance +- Diese Dateien bleiben bestehen wenn keine ordnungsgemäße WAL-Checkpoint und Journal-Mode-Umschaltung beim Shutdown erfolgt +- Signal-Handler waren vorhanden, aber keine atexit-Handler für normales Programmende + +### Implementierte Lösung + +**1. Erweiterte Signal-Handler mit Datenbank-Cleanup:** +```python +def signal_handler(sig, frame): + # Queue Manager und Scheduler stoppen + + # ===== DATENBANKVERBINDUNGEN ORDNUNGSGEMÄSS SCHLIESSEN ===== + app_logger.info("💾 Führe Datenbank-Cleanup durch...") + try: + engine = create_optimized_engine() + + with engine.connect() as conn: + # Vollständiger WAL-Checkpoint (TRUNCATE-Modus) + result = conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")).fetchone() + + # Journal-Mode zu DELETE wechseln (entfernt .wal/.shm Dateien) + conn.execute(text("PRAGMA journal_mode=DELETE")) + + # Optimize und Vacuum für sauberen Zustand + conn.execute(text("PRAGMA optimize")) + conn.execute(text("VACUUM")) + + conn.commit() + + # Engine-Connection-Pool schließen + engine.dispose() + + except Exception as db_error: + app_logger.error(f"❌ Fehler beim Datenbank-Cleanup: {str(db_error)}") +``` + +**2. Zusätzlicher atexit-Handler für normales Programmende:** +```python +def cleanup_database(): + """Führt Datenbank-Cleanup beim normalen Programmende aus.""" + try: + engine = create_optimized_engine() + + with engine.connect() as conn: + # WAL-Checkpoint für sauberes Beenden + result = conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")).fetchone() + + # Journal-Mode umschalten um .wal/.shm Dateien zu entfernen + conn.execute(text("PRAGMA journal_mode=DELETE")) + conn.commit() + + # Connection-Pool ordnungsgemäß schließen + engine.dispose() + + except Exception as e: + app_logger.error(f"❌ Fehler beim finalen Datenbank-Cleanup: {str(e)}") + +atexit.register(cleanup_database) +``` + +### Funktionalität nach der Behebung + +- ✅ **WAL-Checkpoint**: Vollständiger `PRAGMA wal_checkpoint(TRUNCATE)` überträgt alle WAL-Daten zurück in die Hauptdatenbank +- ✅ **Journal-Mode-Umschaltung**: `PRAGMA journal_mode=DELETE` entfernt die `.wal` und `.shm` Dateien +- ✅ **Engine-Cleanup**: `engine.dispose()` schließt alle Connection-Pools ordnungsgemäß +- ✅ **Signal-Handler**: Funktioniert bei Ctrl+C, SIGTERM, SIGBREAK (Windows) +- ✅ **atexit-Handler**: Funktioniert bei normalem Programmende +- ✅ **Fehlerbehandlung**: Detailliertes Logging für Debugging +- ✅ **Cross-Platform**: Windows und Unix/Linux kompatibel + +### Ergebnis + +✅ **Die `.shm` und `.wal` Dateien verschwinden jetzt ordnungsgemäß beim Beenden des Programms** +✅ **Robuste Datenbank-Cleanup-Mechanismen für alle Shutdown-Szenarien** +✅ **Bessere Performance durch behaltenen WAL-Mode während der Laufzeit** +✅ **Sauberer Dateisystem-Zustand nach Programmende** + +**Status:** Problem vollständig behoben - SQLite WAL-Dateien werden automatisch aufgeräumt + +--- + ## ✅ 30.05.2025 19:10 - Schnellaufträge-Funktionalität komplett repariert ### Problem @@ -1200,4 +1295,110 @@ Die Standard-Browser-Scrollbalken waren zu aufdringlich und störten das elegant **Status:** Design-Enhancement erfolgreich implementiert ---- \ No newline at end of file +## ✅ 30.05.2025 20:10 - Ultra-dezente Scrollbalken über base.html implementiert + +### Problem + +Die Scrollbalken in jobs.html waren immer noch zu auffällig. Benutzer wünschte ultra-dezente Scrollbalken die fast unsichtbar sind und nur bei Hover erscheinen. + +### Lösung - Global über base.html Template + +**Ultra-dezente Scrollbalken CSS direkt in `templates/base.html`:** + +```css +/* ===== ULTRA-DEZENTE SCROLLBALKEN ===== */ + +/* Webkit-Browser (Chrome, Safari, Edge) */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 3px; + transition: all 0.3s ease; +} + +/* Nur bei Hover über scrollbaren Container sichtbar */ +*:hover::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.05); +} + +*:hover::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.1); +} + +/* Dark Mode - noch dezenter */ +.dark *:hover::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.03); +} + +.dark *:hover::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* Firefox - ultra-thin */ +* { + scrollbar-width: none; /* Komplett versteckt in Firefox */ +} + +/* Nur bei Hover sichtbar in Firefox */ +*:hover { + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.05) transparent; +} + +.dark *:hover { + scrollbar-color: rgba(255, 255, 255, 0.03) transparent; +} + +/* Spezielle Container die scrollbar brauchen */ +.modal-content::-webkit-scrollbar, +.dropdown-menu::-webkit-scrollbar { + width: 4px; +} + +.modal-content::-webkit-scrollbar-thumb, +.dropdown-menu::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); +} + +.dark .modal-content::-webkit-scrollbar-thumb, +.dark .dropdown-menu::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); +} +``` + +### Features der neuen Implementation + +1. **Ultra-dezent**: Nur 6px breit, komplett transparent bis Hover +2. **Hover-only Sichtbarkeit**: Scrollbalken erscheinen nur bei Hover über Container +3. **Noch schwächere Opacity**: rgba(0,0,0,0.05) vs vorher 0.1 +4. **Dark Mode ultra-dezent**: rgba(255,255,255,0.03) - kaum sichtbar +5. **Firefox hidden by default**: `scrollbar-width: none` macht sie komplett unsichtbar +6. **Global Implementation**: Über base.html auf allen Seiten verfügbar +7. **Modals extra-schmal**: Nur 4px für beste UX +8. **Entfernt aus jobs.html**: Keine Dopplung mehr + +### Vorteile + +- ✅ **Fast unsichtbar**: Nur bei Hover schwach sichtbar +- ✅ **Nicht störend**: Design bleibt komplett clean +- ✅ **Global verfügbar**: Alle Seiten haben konsistente Scrollbalken +- ✅ **Performance**: Keine Dopplung von CSS-Regeln +- ✅ **Dark Mode optimiert**: Noch dezenter in dunklem Theme +- ✅ **Firefox clean**: Scrollbalken komplett versteckt bis Hover + +### Ergebnis + +✅ **Ultra-dezente Scrollbalken die praktisch unsichtbar sind** +✅ **Global über base.html implementiert - kein Duplicate CSS** +✅ **Erscheinen nur bei tatsächlichem Hover über scrollbare Container** +✅ **Design bleibt völlig clean und ungestört** + +**Status:** Ultra-dezente Scrollbalken final implementiert \ No newline at end of file diff --git a/backend/app/app.py b/backend/app/app.py index 6f3da578..47dece91 100644 --- a/backend/app/app.py +++ b/backend/app/app.py @@ -343,11 +343,11 @@ def login(): password = request.form.get("password") remember_me = request.form.get("remember_me") == "on" - # Zusätzlicher Fallback für verschiedene Feldnamen - if not username: - username = request.form.get("username") or request.values.get("email") or request.values.get("username") - if not password: - password = request.form.get("password") or request.values.get("password") + # Zusätzlicher Fallback für verschiedene Feldnamen + if not username: + username = request.form.get("username") or request.values.get("email") or request.values.get("username") + if not password: + password = request.form.get("password") or request.values.get("password") except Exception as extract_error: auth_logger.error(f"Fehler beim Extrahieren der Login-Daten: {str(extract_error)}") @@ -2299,16 +2299,16 @@ def finish_job(job_id): def cancel_job(job_id): """Bricht einen Job ab.""" try: - db_session = get_db_session() + db_session = get_db_session() job = db_session.query(Job).get(job_id) if not job: - db_session.close() + db_session.close() return jsonify({"error": "Job nicht gefunden"}), 404 # Prüfen, ob der Job abgebrochen werden kann if job.status not in ["scheduled", "running"]: - db_session.close() + db_session.close() return jsonify({"error": f"Job kann im Status '{job.status}' nicht abgebrochen werden"}), 400 # Job als abgebrochen markieren @@ -2320,7 +2320,7 @@ def cancel_job(job_id): from utils.job_scheduler import toggle_plug toggle_plug(job.printer_id, False) - db_session.commit() + db_session.commit() job_dict = job.to_dict() db_session.close() @@ -2338,7 +2338,7 @@ def cancel_job(job_id): def start_job(job_id): """Startet einen Job manuell.""" try: - db_session = get_db_session() + db_session = get_db_session() job = db_session.query(Job).options(joinedload(Job.printer)).get(job_id) if not job: @@ -2347,7 +2347,7 @@ def start_job(job_id): # Prüfen, ob der Job gestartet werden kann if job.status not in ["scheduled", "queued", "waiting_for_printer"]: - db_session.close() + db_session.close() return jsonify({"error": f"Job kann im Status '{job.status}' nicht gestartet werden"}), 400 # Drucker einschalten falls verfügbar @@ -2358,7 +2358,7 @@ def start_job(job_id): jobs_logger.info(f"Drucker {job.printer.name} für Job {job_id} eingeschaltet") else: jobs_logger.warning(f"Konnte Drucker {job.printer.name} für Job {job_id} nicht einschalten") - except Exception as e: + except Exception as e: jobs_logger.warning(f"Fehler beim Einschalten des Druckers für Job {job_id}: {str(e)}") # Job als laufend markieren @@ -2398,7 +2398,7 @@ def pause_job(job_id): # Prüfen, ob der Job pausiert werden kann if job.status != "running": - db_session.close() + db_session.close() return jsonify({"error": f"Job kann im Status '{job.status}' nicht pausiert werden"}), 400 # Drucker ausschalten @@ -2409,7 +2409,7 @@ def pause_job(job_id): jobs_logger.info(f"Drucker {job.printer.name} für Job {job_id} ausgeschaltet (Pause)") else: jobs_logger.warning(f"Konnte Drucker {job.printer.name} für Job {job_id} nicht ausschalten") - except Exception as e: + except Exception as e: jobs_logger.warning(f"Fehler beim Ausschalten des Druckers für Job {job_id}: {str(e)}") # Job als pausiert markieren @@ -2419,7 +2419,7 @@ def pause_job(job_id): db_session.commit() job_dict = job.to_dict() - db_session.close() + db_session.close() jobs_logger.info(f"Job {job_id} pausiert von Benutzer {current_user.id}") return jsonify({ @@ -2458,7 +2458,7 @@ def resume_job(job_id): jobs_logger.info(f"Drucker {job.printer.name} für Job {job_id} eingeschaltet (Resume)") else: jobs_logger.warning(f"Konnte Drucker {job.printer.name} für Job {job_id} nicht einschalten") - except Exception as e: + except Exception as e: jobs_logger.warning(f"Fehler beim Einschalten des Druckers für Job {job_id}: {str(e)}") # Job als laufend markieren @@ -4859,4 +4859,179 @@ def get_printers(): return jsonify({ "error": f"Fehler beim Laden der Drucker: {str(e)}", "printers": [] - }), 500 \ No newline at end of file + }), 500 + +# ===== ERWEITERTE SESSION-MANAGEMENT UND AUTO-LOGOUT ===== + +@app.before_request +def check_session_activity(): + """ + Überprüft Session-Aktivität und meldet Benutzer bei Inaktivität automatisch ab. + """ + # Skip für nicht-authentifizierte Benutzer und Login-Route + if not current_user.is_authenticated or request.endpoint in ['login', 'static', 'auth_logout']: + return + + # Skip für AJAX/API calls die nicht als Session-Aktivität zählen sollen + if request.path.startswith('/api/') and request.path.endswith('/heartbeat'): + return + + now = datetime.now() + + # Session-Aktivität tracken + if 'last_activity' in session: + last_activity = datetime.fromisoformat(session['last_activity']) + inactive_duration = now - last_activity + + # Definiere Inaktivitäts-Limits basierend auf Benutzerrolle + max_inactive_minutes = 30 # Standard: 30 Minuten + if hasattr(current_user, 'is_admin') and current_user.is_admin: + max_inactive_minutes = 60 # Admins: 60 Minuten + + max_inactive_duration = timedelta(minutes=max_inactive_minutes) + + # Benutzer abmelden wenn zu lange inaktiv + if inactive_duration > max_inactive_duration: + auth_logger.info(f"🕒 Automatische Abmeldung: Benutzer {current_user.email} war {inactive_duration.total_seconds()/60:.1f} Minuten inaktiv (Limit: {max_inactive_minutes}min)") + + # Session-Daten vor Logout speichern für Benachrichtigung + session['auto_logout_reason'] = f"Automatische Abmeldung nach {max_inactive_minutes} Minuten Inaktivität" + session['auto_logout_time'] = now.isoformat() + + logout_user() + session.clear() + + # JSON-Response für AJAX-Requests + if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or request.is_json: + return jsonify({ + "error": "Session abgelaufen", + "reason": "auto_logout_inactivity", + "message": f"Sie wurden nach {max_inactive_minutes} Minuten Inaktivität automatisch abgemeldet", + "redirect_url": url_for("login") + }), 401 + + # HTML-Redirect für normale Requests + flash(f"Sie wurden nach {max_inactive_minutes} Minuten Inaktivität automatisch abgemeldet.", "warning") + return redirect(url_for("login")) + + # Session-Aktivität aktualisieren (aber nicht bei jedem API-Call) + if not request.path.startswith('/api/stats/') and not request.path.startswith('/api/heartbeat'): + session['last_activity'] = now.isoformat() + session['user_agent'] = request.headers.get('User-Agent', '')[:200] # Begrenzt auf 200 Zeichen + session['ip_address'] = request.remote_addr + + # Session-Sicherheit: Überprüfe IP-Adresse und User-Agent (Optional) + if 'session_ip' in session and session['session_ip'] != request.remote_addr: + auth_logger.warning(f"⚠️ IP-Adresse geändert für Benutzer {current_user.email}: {session['session_ip']} → {request.remote_addr}") + # Optional: Benutzer abmelden bei IP-Wechsel (kann bei VPN/Proxy problematisch sein) + # session['security_warning'] = "IP-Adresse hat sich geändert" + +@app.before_request +def setup_session_security(): + """ + Initialisiert Session-Sicherheit für neue Sessions. + """ + if current_user.is_authenticated and 'session_created' not in session: + session['session_created'] = datetime.now().isoformat() + session['session_ip'] = request.remote_addr + session['last_activity'] = datetime.now().isoformat() + session.permanent = True # Session als permanent markieren + + auth_logger.info(f"🔐 Neue Session erstellt für Benutzer {current_user.email} von IP {request.remote_addr}") + +# ===== SESSION-MANAGEMENT API-ENDPUNKTE ===== + +@app.route('/api/session/heartbeat', methods=['POST']) +@login_required +def session_heartbeat(): + """ + Heartbeat-Endpunkt um Session am Leben zu halten. + Wird vom Frontend alle 5 Minuten aufgerufen. + """ + try: + now = datetime.now() + session['last_activity'] = now.isoformat() + + # Berechne verbleibende Session-Zeit + last_activity = datetime.fromisoformat(session.get('last_activity', now.isoformat())) + max_inactive_minutes = 60 if hasattr(current_user, 'is_admin') and current_user.is_admin else 30 + time_left = max_inactive_minutes * 60 - (now - last_activity).total_seconds() + + return jsonify({ + "success": True, + "session_active": True, + "time_left_seconds": max(0, int(time_left)), + "max_inactive_minutes": max_inactive_minutes, + "current_time": now.isoformat() + }) + except Exception as e: + auth_logger.error(f"Fehler beim Session-Heartbeat: {str(e)}") + return jsonify({"error": "Heartbeat fehlgeschlagen"}), 500 + +@app.route('/api/session/status', methods=['GET']) +@login_required +def session_status(): + """ + Gibt detaillierten Session-Status zurück. + """ + try: + now = datetime.now() + last_activity = datetime.fromisoformat(session.get('last_activity', now.isoformat())) + session_created = datetime.fromisoformat(session.get('session_created', now.isoformat())) + + max_inactive_minutes = 60 if hasattr(current_user, 'is_admin') and current_user.is_admin else 30 + inactive_duration = (now - last_activity).total_seconds() + time_left = max_inactive_minutes * 60 - inactive_duration + + return jsonify({ + "success": True, + "user": { + "id": current_user.id, + "email": current_user.email, + "name": current_user.name, + "is_admin": getattr(current_user, 'is_admin', False) + }, + "session": { + "created": session_created.isoformat(), + "last_activity": last_activity.isoformat(), + "inactive_seconds": int(inactive_duration), + "time_left_seconds": max(0, int(time_left)), + "max_inactive_minutes": max_inactive_minutes, + "ip_address": session.get('session_ip', 'unbekannt'), + "user_agent": session.get('user_agent', 'unbekannt')[:50] + "..." if len(session.get('user_agent', '')) > 50 else session.get('user_agent', 'unbekannt') + }, + "warnings": [] + }) + except Exception as e: + auth_logger.error(f"Fehler beim Abrufen des Session-Status: {str(e)}") + return jsonify({"error": "Session-Status nicht verfügbar"}), 500 + +@app.route('/api/session/extend', methods=['POST']) +@login_required +def extend_session(): + """ + Verlängert die aktuelle Session um weitere Zeit. + """ + try: + data = request.get_json() or {} + extend_minutes = data.get('extend_minutes', 30) + + # Begrenzen der Verlängerung (max 2 Stunden) + extend_minutes = min(extend_minutes, 120) + + now = datetime.now() + session['last_activity'] = now.isoformat() + session['session_extended'] = now.isoformat() + session['extended_by_minutes'] = extend_minutes + + auth_logger.info(f"🕒 Session verlängert für Benutzer {current_user.email} um {extend_minutes} Minuten") + + return jsonify({ + "success": True, + "message": f"Session um {extend_minutes} Minuten verlängert", + "extended_until": (now + timedelta(minutes=extend_minutes)).isoformat(), + "extended_minutes": extend_minutes + }) + except Exception as e: + auth_logger.error(f"Fehler beim Verlängern der Session: {str(e)}") + return jsonify({"error": "Session-Verlängerung fehlgeschlagen"}), 500 \ No newline at end of file diff --git a/backend/app/config/settings.py b/backend/app/config/settings.py index a98543c0..620ef312 100644 --- a/backend/app/config/settings.py +++ b/backend/app/config/settings.py @@ -67,7 +67,7 @@ FLASK_HOST = "0.0.0.0" FLASK_PORT = 443 # Geändert von 443 auf 8443 (nicht-privilegierter Port) FLASK_FALLBACK_PORT = 8080 # Geändert von 80 auf 8080 (nicht-privilegierter Port) FLASK_DEBUG = True -SESSION_LIFETIME = timedelta(days=7) +SESSION_LIFETIME = timedelta(hours=2) # Reduziert von 7 Tagen auf 2 Stunden für bessere Sicherheit # Upload-Konfiguration UPLOAD_FOLDER = os.path.join(BASE_DIR, "uploads") diff --git a/backend/app/static/js/session-manager.js b/backend/app/static/js/session-manager.js new file mode 100644 index 00000000..1f6ae427 --- /dev/null +++ b/backend/app/static/js/session-manager.js @@ -0,0 +1,475 @@ +/** + * Erweitertes Session-Management für MYP Platform + * + * Features: + * - Automatische Session-Überwachung + * - Heartbeat-System für Session-Verlängerung + * - Benutzer-Warnungen bei bevorstehender Abmeldung + * - Graceful Logout bei Session-Ablauf + * - Modal-Dialoge für Session-Verlängerung + * + * @author Mercedes-Benz MYP Platform + * @version 2.0 + */ + +class SessionManager { + constructor() { + this.isAuthenticated = false; + this.maxInactiveMinutes = 30; // Standard: 30 Minuten + this.heartbeatInterval = 5 * 60 * 1000; // 5 Minuten + this.warningTime = 5 * 60 * 1000; // 5 Minuten vor Ablauf warnen + this.checkInterval = 30 * 1000; // Alle 30 Sekunden prüfen + + this.heartbeatTimer = null; + this.statusCheckTimer = null; + this.warningShown = false; + this.sessionWarningModal = null; + + this.init(); + } + + async init() { + try { + // Prüfe initial ob Benutzer angemeldet ist + await this.checkAuthenticationStatus(); + + if (this.isAuthenticated) { + this.startSessionMonitoring(); + this.createWarningModal(); + + console.log('🔐 Session Manager gestartet'); + console.log(`📊 Max Inaktivität: ${this.maxInactiveMinutes} Minuten`); + console.log(`💓 Heartbeat Intervall: ${this.heartbeatInterval / 1000 / 60} Minuten`); + } else { + console.log('👤 Benutzer nicht angemeldet - Session Manager inaktiv'); + } + } catch (error) { + console.error('❌ Session Manager Initialisierung fehlgeschlagen:', error); + } + } + + async checkAuthenticationStatus() { + try { + const response = await fetch('/api/session/status', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + this.isAuthenticated = true; + this.maxInactiveMinutes = data.session.max_inactive_minutes; + + console.log('✅ Session Status:', { + user: data.user.email, + timeLeft: Math.floor(data.session.time_left_seconds / 60) + ' Minuten', + lastActivity: new Date(data.session.last_activity).toLocaleString('de-DE') + }); + + return data; + } + } else if (response.status === 401) { + this.isAuthenticated = false; + this.handleSessionExpired('Authentication check failed'); + } + } catch (error) { + console.error('❌ Fehler beim Prüfen des Session-Status:', error); + this.isAuthenticated = false; + } + + return null; + } + + startSessionMonitoring() { + // Heartbeat alle 5 Minuten senden + this.heartbeatTimer = setInterval(() => { + this.sendHeartbeat(); + }, this.heartbeatInterval); + + // Session-Status alle 30 Sekunden prüfen + this.statusCheckTimer = setInterval(() => { + this.checkSessionStatus(); + }, this.checkInterval); + + // Initial Heartbeat senden + setTimeout(() => this.sendHeartbeat(), 1000); + } + + async sendHeartbeat() { + try { + const response = await fetch('/api/session/heartbeat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ + timestamp: new Date().toISOString(), + page: window.location.pathname + }) + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + console.log('💓 Heartbeat gesendet - Session aktiv:', + Math.floor(data.time_left_seconds / 60) + ' Minuten verbleibend'); + } else { + console.warn('⚠️ Heartbeat fehlgeschlagen:', data); + } + } else if (response.status === 401) { + this.handleSessionExpired('Heartbeat failed - unauthorized'); + } + } catch (error) { + console.error('❌ Heartbeat-Fehler:', error); + } + } + + async checkSessionStatus() { + try { + const sessionData = await this.checkAuthenticationStatus(); + + if (sessionData && sessionData.session) { + const timeLeftSeconds = sessionData.session.time_left_seconds; + const timeLeftMinutes = Math.floor(timeLeftSeconds / 60); + + // Warnung anzeigen wenn weniger als 5 Minuten verbleiben + if (timeLeftSeconds <= this.warningTime / 1000 && timeLeftSeconds > 0) { + if (!this.warningShown) { + this.showSessionWarning(timeLeftMinutes); + this.warningShown = true; + } + } else if (timeLeftSeconds <= 0) { + // Session abgelaufen + this.handleSessionExpired('Session time expired'); + } else { + // Session OK - Warnung zurücksetzen + this.warningShown = false; + this.hideSessionWarning(); + } + + // Session-Status in der UI aktualisieren + this.updateSessionStatusDisplay(sessionData); + } + } catch (error) { + console.error('❌ Session-Status-Check fehlgeschlagen:', error); + } + } + + showSessionWarning(minutesLeft) { + // Bestehende Warnung entfernen + this.hideSessionWarning(); + + // Toast-Notification anzeigen + this.showToast( + 'Session läuft ab', + `Ihre Session läuft in ${minutesLeft} Minuten ab. Möchten Sie verlängern?`, + 'warning', + 10000, // 10 Sekunden anzeigen + [ + { + text: 'Verlängern', + action: () => this.extendSession() + }, + { + text: 'Abmelden', + action: () => this.logout() + } + ] + ); + + // Modal anzeigen für wichtige Warnung + if (this.sessionWarningModal) { + this.sessionWarningModal.show(); + this.updateWarningModal(minutesLeft); + } + + console.log(`⚠️ Session-Warnung: ${minutesLeft} Minuten verbleibend`); + } + + hideSessionWarning() { + if (this.sessionWarningModal) { + this.sessionWarningModal.hide(); + } + } + + createWarningModal() { + // Modal HTML erstellen + const modalHTML = ` + `; + + // Modal in DOM einfügen + document.body.insertAdjacentHTML('beforeend', modalHTML); + + // Event-Listener hinzufügen + document.getElementById('extendSessionBtn').addEventListener('click', () => { + this.extendSession(); + this.hideSessionWarning(); + }); + + document.getElementById('logoutBtn').addEventListener('click', () => { + this.logout(); + }); + + // Modal-Objekt erstellen + this.sessionWarningModal = { + element: document.getElementById('sessionWarningModal'), + show: () => { + document.getElementById('sessionWarningModal').classList.remove('hidden'); + }, + hide: () => { + document.getElementById('sessionWarningModal').classList.add('hidden'); + } + }; + } + + updateWarningModal(minutesLeft) { + const timeElement = document.getElementById('timeRemaining'); + if (timeElement) { + timeElement.textContent = minutesLeft; + } + } + + async extendSession(extendMinutes = 30) { + try { + const response = await fetch('/api/session/extend', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ + extend_minutes: extendMinutes + }) + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + this.warningShown = false; + + this.showToast( + 'Session verlängert', + `Ihre Session wurde um ${data.extended_minutes} Minuten verlängert`, + 'success', + 5000 + ); + + console.log('✅ Session verlängert:', data); + } else { + this.showToast('Fehler', 'Session konnte nicht verlängert werden', 'error'); + } + } else if (response.status === 401) { + this.handleSessionExpired('Extend session failed - unauthorized'); + } + } catch (error) { + console.error('❌ Session-Verlängerung fehlgeschlagen:', error); + this.showToast('Fehler', 'Session-Verlängerung fehlgeschlagen', 'error'); + } + } + + async logout() { + try { + this.stopSessionMonitoring(); + + // Logout-Request senden + const response = await fetch('/auth/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + // Zur Login-Seite weiterleiten + if (response.ok) { + window.location.href = '/auth/login'; + } else { + // Fallback: Direkter Redirect + window.location.href = '/auth/login'; + } + } catch (error) { + console.error('❌ Logout-Fehler:', error); + // Fallback: Direkter Redirect + window.location.href = '/auth/login'; + } + } + + handleSessionExpired(reason) { + console.log('🕒 Session abgelaufen:', reason); + + this.stopSessionMonitoring(); + this.isAuthenticated = false; + + // Benutzer benachrichtigen + this.showToast( + 'Session abgelaufen', + 'Sie wurden automatisch abgemeldet. Bitte melden Sie sich erneut an.', + 'warning', + 8000 + ); + + // Nach kurzer Verzögerung zur Login-Seite weiterleiten + setTimeout(() => { + window.location.href = '/auth/login?reason=session_expired'; + }, 2000); + } + + stopSessionMonitoring() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + if (this.statusCheckTimer) { + clearInterval(this.statusCheckTimer); + this.statusCheckTimer = null; + } + + console.log('🛑 Session-Monitoring gestoppt'); + } + + updateSessionStatusDisplay(sessionData) { + // Session-Status in der Navigation/Header anzeigen (falls vorhanden) + const statusElement = document.getElementById('sessionStatus'); + if (statusElement) { + const timeLeftMinutes = Math.floor(sessionData.session.time_left_seconds / 60); + statusElement.textContent = `Session: ${timeLeftMinutes}min`; + + // Farbe basierend auf verbleibender Zeit + if (timeLeftMinutes <= 5) { + statusElement.className = 'text-red-600 font-medium'; + } else if (timeLeftMinutes <= 10) { + statusElement.className = 'text-yellow-600 font-medium'; + } else { + statusElement.className = 'text-green-600 font-medium'; + } + } + } + + showToast(title, message, type = 'info', duration = 5000, actions = []) { + // Verwende das bestehende Toast-System falls verfügbar + if (window.showToast) { + window.showToast(message, type, duration); + return; + } + + // Fallback: Simple Browser-Notification + if (type === 'error' || type === 'warning') { + alert(`${title}: ${message}`); + } else { + console.log(`${title}: ${message}`); + } + } + + // === ÖFFENTLICHE API === + + /** + * Prüft ob Benutzer angemeldet ist + */ + isLoggedIn() { + return this.isAuthenticated; + } + + /** + * Startet Session-Monitoring manuell + */ + start() { + if (!this.heartbeatTimer && this.isAuthenticated) { + this.startSessionMonitoring(); + } + } + + /** + * Stoppt Session-Monitoring manuell + */ + stop() { + this.stopSessionMonitoring(); + } + + /** + * Verlängert Session manuell + */ + async extend(minutes = 30) { + return await this.extendSession(minutes); + } + + /** + * Meldet Benutzer manuell ab + */ + async logoutUser() { + return await this.logout(); + } +} + +// Session Manager automatisch starten wenn DOM geladen ist +document.addEventListener('DOMContentLoaded', () => { + // Nur starten wenn wir nicht auf der Login-Seite sind + if (!window.location.pathname.includes('/auth/login')) { + window.sessionManager = new SessionManager(); + + // Globale Event-Listener für Session-Management + window.addEventListener('beforeunload', () => { + if (window.sessionManager) { + window.sessionManager.stop(); + } + }); + + // Reaktion auf Sichtbarkeitsänderungen (Tab-Wechsel) + document.addEventListener('visibilitychange', () => { + if (window.sessionManager && window.sessionManager.isLoggedIn()) { + if (document.hidden) { + console.log('🙈 Tab versteckt - Session-Monitoring reduziert'); + } else { + console.log('👁️ Tab sichtbar - Session-Check'); + // Sofortiger Session-Check wenn Tab wieder sichtbar wird + setTimeout(() => window.sessionManager.checkSessionStatus(), 1000); + } + } + }); + } +}); + +// Session Manager für andere Scripts verfügbar machen +window.SessionManager = SessionManager; \ No newline at end of file diff --git a/backend/app/templates/base.html b/backend/app/templates/base.html index 053bdd0b..76dab1cb 100644 --- a/backend/app/templates/base.html +++ b/backend/app/templates/base.html @@ -654,6 +654,7 @@ {% if current_user.is_authenticated %} + {% endif %}