/** * TASKMATE - Authentication Module * ================================ */ import api from './api.js'; import { $, $$ } from './utils.js'; class AuthManager { constructor() { this.user = null; this.isAuthenticated = false; this.loginAttempts = 0; this.maxLoginAttempts = 5; this.lockoutDuration = 5 * 60 * 1000; // 5 minutes this.lockoutUntil = null; } // Initialize authentication state async init() { const token = api.getToken(); console.log('[Auth] init() - Token exists:', !!token); if (token) { try { // Verify token by making a request console.log('[Auth] Verifying token...'); const users = await api.getUsers(); console.log('[Auth] Token valid, users loaded'); this.isAuthenticated = true; // Get current user from stored data const storedUser = localStorage.getItem('current_user'); if (storedUser) { this.user = JSON.parse(storedUser); } return true; } catch (error) { // Token invalid console.log('[Auth] Token invalid, logging out'); this.logout(); return false; } } console.log('[Auth] No token found'); return false; } // Login async login(username, password) { // Check lockout if (this.isLockedOut()) { const remainingTime = Math.ceil((this.lockoutUntil - Date.now()) / 1000); throw new Error(`Zu viele Fehlversuche. Bitte warten Sie ${remainingTime} Sekunden.`); } try { const response = await api.login(username, password); this.user = response.user; this.isAuthenticated = true; this.loginAttempts = 0; // Store user data localStorage.setItem('current_user', JSON.stringify(response.user)); // Dispatch login event window.dispatchEvent(new CustomEvent('auth:login', { detail: { user: this.user } })); return response; } catch (error) { this.loginAttempts++; if (this.loginAttempts >= this.maxLoginAttempts) { this.lockoutUntil = Date.now() + this.lockoutDuration; } throw error; } } // Logout async logout() { try { if (this.isAuthenticated) { await api.logout(); } } catch (error) { // Ignore logout errors } finally { this.user = null; this.isAuthenticated = false; // Clear stored data localStorage.removeItem('current_user'); localStorage.removeItem('auth_token'); // Dispatch logout event window.dispatchEvent(new CustomEvent('auth:logout')); } } // Change Password async changePassword(currentPassword, newPassword) { return api.changePassword(currentPassword, newPassword); } // Get Current User getUser() { return this.user; } // Check if logged in isLoggedIn() { return this.isAuthenticated && this.user !== null; } // Check lockout status isLockedOut() { if (!this.lockoutUntil) return false; if (Date.now() >= this.lockoutUntil) { this.lockoutUntil = null; this.loginAttempts = 0; return false; } return true; } // Get user initials getUserInitials() { if (!this.user) return '?'; // Verwende das initials Feld direkt if (this.user.initials) { return this.user.initials.toUpperCase(); } // Fallback für alte Daten return '??'; } // Get user color getUserColor() { if (!this.user) return '#888888'; return this.user.color || '#00D4FF'; } // Update user color updateUserColor(color) { if (this.user) { this.user.color = color; // Also update localStorage so color persists after refresh localStorage.setItem('current_user', JSON.stringify(this.user)); } } // Check if user is admin isAdmin() { return this.user?.role === 'admin'; } // Check if user has a specific permission hasPermission(permission) { if (!this.user) return false; const permissions = this.user.permissions || []; return permissions.includes(permission); } // Get user role getRole() { return this.user?.role || 'user'; } // Get user permissions getPermissions() { return this.user?.permissions || []; } } // Login Form Handler class LoginFormHandler { constructor(authManager) { this.auth = authManager; this.form = $('#login-form'); this.usernameInput = $('#login-username'); this.passwordInput = $('#login-password'); this.submitButton = this.form?.querySelector('button[type="submit"]'); this.errorMessage = $('#login-error'); this.bindEvents(); } bindEvents() { if (!this.form) return; this.form.addEventListener('submit', (e) => this.handleSubmit(e)); // Enter key handling this.passwordInput?.addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.form.dispatchEvent(new Event('submit')); } }); // Clear error on input [this.usernameInput, this.passwordInput].forEach(input => { input?.addEventListener('input', () => this.clearError()); }); } async handleSubmit(e) { e.preventDefault(); const username = this.usernameInput?.value.trim(); const password = this.passwordInput?.value; if (!username || !password) { this.showError('Bitte Benutzername und Passwort eingeben.'); return; } this.setLoading(true); this.clearError(); try { await this.auth.login(username, password); // Success - app will handle the redirect } catch (error) { this.showError(error.message || 'Anmeldung fehlgeschlagen.'); this.passwordInput.value = ''; this.passwordInput.focus(); } finally { this.setLoading(false); } } showError(message) { if (this.errorMessage) { this.errorMessage.textContent = message; this.errorMessage.classList.remove('hidden'); } } clearError() { if (this.errorMessage) { this.errorMessage.textContent = ''; this.errorMessage.classList.add('hidden'); } } setLoading(loading) { if (this.submitButton) { this.submitButton.disabled = loading; this.submitButton.classList.toggle('loading', loading); } if (this.usernameInput) this.usernameInput.disabled = loading; if (this.passwordInput) this.passwordInput.disabled = loading; } reset() { if (this.form) this.form.reset(); this.clearError(); this.setLoading(false); } } // User Menu Handler class UserMenuHandler { constructor(authManager) { this.auth = authManager; this.userMenu = $('.user-menu'); this.userAvatar = $('#user-avatar'); this.userDropdown = $('.user-dropdown'); this.userName = $('#user-name'); this.userRole = $('#user-role'); this.logoutButton = $('#btn-logout'); this.changePasswordButton = $('#btn-change-password'); this.isOpen = false; this.bindEvents(); } bindEvents() { // Toggle dropdown this.userAvatar?.addEventListener('click', (e) => { e.stopPropagation(); this.toggle(); }); // Close on outside click document.addEventListener('click', (e) => { if (!this.userMenu?.contains(e.target)) { this.close(); } }); // Logout this.logoutButton?.addEventListener('click', () => this.handleLogout()); // Change password this.changePasswordButton?.addEventListener('click', () => { this.close(); window.dispatchEvent(new CustomEvent('modal:open', { detail: { modalId: 'change-password-modal' } })); }); // Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isOpen) { this.close(); } }); } update() { const user = this.auth.getUser(); if (this.userAvatar) { this.userAvatar.textContent = this.auth.getUserInitials(); this.userAvatar.style.backgroundColor = this.auth.getUserColor(); } if (this.userName) { this.userName.textContent = user?.displayName || user?.username || 'Benutzer'; } if (this.userRole) { let roleText = 'Benutzer'; if (user?.role === 'admin') { roleText = 'Administrator'; } else if (user?.permissions?.length > 0) { roleText = user.permissions.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(', '); } this.userRole.textContent = roleText; } } toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } open() { if (this.userDropdown) { this.userDropdown.classList.remove('hidden'); this.isOpen = true; } } close() { if (this.userDropdown) { this.userDropdown.classList.add('hidden'); this.isOpen = false; } } async handleLogout() { this.close(); try { await this.auth.logout(); } catch (error) { console.error('Logout error:', error); } } } // Session Timer Handler class SessionTimerHandler { constructor(authManager) { this.auth = authManager; this.timerElement = null; this.countdownElement = null; this.intervalId = null; this.expiresAt = null; this.warningThreshold = 60; // Warnung bei 60 Sekunden verbleibend this.refreshDebounceTimer = null; this.refreshDebounceDelay = 1000; // 1 Sekunde Debounce this.isRefreshing = false; this.isActive = false; // Nur aktiv wenn eingeloggt und Timer läuft } init() { this.timerElement = $('#session-timer'); this.countdownElement = $('#session-countdown'); // Bei Login neu initialisieren window.addEventListener('auth:login', () => { // Kurze Verzögerung um sicherzustellen, dass Token gespeichert ist setTimeout(() => { this.updateFromToken(); this.start(); this.isActive = true; }, 100); }); // Bei Logout stoppen window.addEventListener('auth:logout', () => { this.isActive = false; this.stop(); this.hide(); }); // Bei Interaktionen Session refreshen (mit Debouncing) this.bindInteractionEvents(); } // Interaktions-Events binden für Session-Refresh bindInteractionEvents() { const refreshOnInteraction = (e) => { // Nicht refreshen wenn nicht aktiv (nicht eingeloggt oder Timer läuft nicht) if (!this.isActive) return; // Nicht refreshen bei Klicks auf Login-Formular if (e.target.closest('#login-form') || e.target.closest('.login-container')) return; // Nur refreshen wenn Token existiert if (!localStorage.getItem('auth_token')) return; // Debounce: Nur alle X ms refreshen if (this.refreshDebounceTimer) { clearTimeout(this.refreshDebounceTimer); } this.refreshDebounceTimer = setTimeout(() => { this.refreshSession(); }, this.refreshDebounceDelay); }; // Click-Events auf dem gesamten Dokument document.addEventListener('click', refreshOnInteraction); // Keyboard-Events document.addEventListener('keydown', refreshOnInteraction); } // Session beim Server refreshen async refreshSession() { if (this.isRefreshing) return; const token = localStorage.getItem('auth_token'); if (!token) return; this.isRefreshing = true; try { const response = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` } }); if (response.ok) { const data = await response.json(); if (data.token) { // Wichtig: api.setToken() verwenden, um den Cache zu aktualisieren api.setToken(data.token); this.expiresAt = this.parseToken(data.token); this.timerElement?.classList.remove('warning', 'critical'); // CSRF-Token auch aktualisieren if (data.csrfToken) { api.setCsrfToken(data.csrfToken); } } } else if (response.status === 401) { // Token ungültig - aber nur ausloggen wenn kein neuer Login stattfand // (Race-Condition: Alter Refresh-Request kann 401 zurückgeben nachdem // ein neuer Login erfolgreich war) const currentToken = localStorage.getItem('auth_token'); if (currentToken === token) { // Gleicher Token, wirklich ungültig, ausloggen console.log('[Auth] Refresh returned 401, logging out'); this.auth.logout(); } else { // Token hat sich geändert (neuer Login oder bereits ausgeloggt) console.log('[Auth] Refresh 401 ignored - token changed (new login occurred)'); } } } catch (error) { console.error('Session refresh error:', error); } finally { this.isRefreshing = false; } } // JWT-Token parsen und Ablaufzeit extrahieren parseToken(token) { if (!token) return null; try { const payload = token.split('.')[1]; const decoded = JSON.parse(atob(payload)); return decoded.exp ? decoded.exp * 1000 : null; // exp ist in Sekunden, wir brauchen ms } catch (e) { console.error('Token parsing error:', e); return null; } } updateFromToken() { const token = localStorage.getItem('auth_token'); this.expiresAt = this.parseToken(token); } // Beim Seiten-Reload aufrufen async initFromExistingSession() { const token = localStorage.getItem('auth_token'); if (!token) { this.hide(); return false; } // Prüfen ob Token noch gültig ist const expiresAt = this.parseToken(token); if (!expiresAt || expiresAt <= Date.now()) { // Token abgelaufen this.hide(); return false; } // Timer mit aktuellem Token starten this.expiresAt = expiresAt; this.isActive = true; this.start(); // Token-Refresh VERZÖGERN um Race-Condition zu vermeiden: // Andere Module machen beim Start Requests mit dem aktuellen Token. // Ein sofortiger Refresh würde den Token ändern, während Requests noch laufen. const remainingTime = expiresAt - Date.now(); const refreshThreshold = 5 * 60 * 1000; // 5 Minuten if (remainingTime < refreshThreshold) { // Token läuft bald ab - nach kurzer Verzögerung refreshen setTimeout(() => this.refreshSession(), 2000); } // Sonst: Token ist noch frisch genug, Refresh passiert später durch Interaktion return true; } start() { this.stop(); // Bestehenden Timer stoppen if (!this.expiresAt) { this.hide(); return; } this.show(); this.update(); // Sofort updaten // Jede Sekunde aktualisieren this.intervalId = setInterval(() => this.update(), 1000); } stop() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } } update() { if (!this.expiresAt || !this.countdownElement) return; const now = Date.now(); const remaining = Math.max(0, this.expiresAt - now); const seconds = Math.floor(remaining / 1000); // Zeit formatieren const minutes = Math.floor(seconds / 60); const secs = seconds % 60; const timeStr = `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; this.countdownElement.textContent = timeStr; // Warnung bei wenig Zeit if (seconds <= this.warningThreshold && seconds > 0) { this.timerElement?.classList.add('warning'); } else { this.timerElement?.classList.remove('warning'); } // Kritisch bei < 30 Sekunden if (seconds <= 30 && seconds > 0) { this.timerElement?.classList.add('critical'); } else { this.timerElement?.classList.remove('critical'); } // Session abgelaufen if (seconds <= 0) { this.stop(); this.handleExpired(); } } handleExpired() { // Toast anzeigen window.dispatchEvent(new CustomEvent('toast:show', { detail: { message: 'Sitzung abgelaufen. Bitte erneut anmelden.', type: 'warning', duration: 5000 } })); // Automatisch ausloggen this.auth.logout(); } show() { this.timerElement?.classList.remove('hidden'); } hide() { this.timerElement?.classList.add('hidden'); if (this.countdownElement) { this.countdownElement.textContent = '--:--'; } } // Token wurde erneuert (z.B. durch API-Response mit X-New-Token) refreshToken(newToken) { if (newToken) { // api.setToken() wird bereits in api.js aufgerufen, hier nur Timer aktualisieren this.expiresAt = this.parseToken(newToken); this.timerElement?.classList.remove('warning', 'critical'); } } } // Change Password Modal Handler class ChangePasswordHandler { constructor(authManager) { this.auth = authManager; this.modal = $('#change-password-modal'); this.form = $('#change-password-form'); this.currentPassword = $('#current-password'); this.newPassword = $('#new-password'); this.confirmPassword = $('#confirm-password'); this.errorMessage = this.modal?.querySelector('.error-message'); this.submitButton = this.form?.querySelector('button[type="submit"]'); this.bindEvents(); } bindEvents() { this.form?.addEventListener('submit', (e) => this.handleSubmit(e)); // Password strength indicator this.newPassword?.addEventListener('input', () => { this.updatePasswordStrength(); }); // Confirm password validation this.confirmPassword?.addEventListener('input', () => { this.validateConfirmPassword(); }); } async handleSubmit(e) { e.preventDefault(); const currentPassword = this.currentPassword?.value; const newPassword = this.newPassword?.value; const confirmPassword = this.confirmPassword?.value; // Validation if (!currentPassword || !newPassword || !confirmPassword) { this.showError('Bitte alle Felder ausfüllen.'); return; } if (newPassword.length < 8) { this.showError('Das neue Passwort muss mindestens 8 Zeichen lang sein.'); return; } if (newPassword !== confirmPassword) { this.showError('Die Passwörter stimmen nicht überein.'); return; } this.setLoading(true); this.clearError(); try { await this.auth.changePassword(currentPassword, newPassword); // Success this.reset(); this.closeModal(); window.dispatchEvent(new CustomEvent('toast:show', { detail: { message: 'Passwort erfolgreich geändert.', type: 'success' } })); } catch (error) { this.showError(error.message || 'Fehler beim Ändern des Passworts.'); } finally { this.setLoading(false); } } updatePasswordStrength() { const password = this.newPassword?.value || ''; const strengthIndicator = this.modal?.querySelector('.password-strength'); if (!strengthIndicator) return; let strength = 0; if (password.length >= 8) strength++; if (password.length >= 12) strength++; if (/[A-Z]/.test(password)) strength++; if (/[a-z]/.test(password)) strength++; if (/[0-9]/.test(password)) strength++; if (/[^A-Za-z0-9]/.test(password)) strength++; const levels = ['weak', 'fair', 'good', 'strong']; const level = Math.min(Math.floor(strength / 1.5), 3); strengthIndicator.className = `password-strength ${levels[level]}`; strengthIndicator.dataset.strength = levels[level]; } validateConfirmPassword() { const newPassword = this.newPassword?.value; const confirmPassword = this.confirmPassword?.value; if (confirmPassword && newPassword !== confirmPassword) { this.confirmPassword.setCustomValidity('Passwörter stimmen nicht überein'); } else { this.confirmPassword.setCustomValidity(''); } } showError(message) { if (this.errorMessage) { this.errorMessage.textContent = message; this.errorMessage.classList.remove('hidden'); } } clearError() { if (this.errorMessage) { this.errorMessage.textContent = ''; this.errorMessage.classList.add('hidden'); } } setLoading(loading) { if (this.submitButton) { this.submitButton.disabled = loading; this.submitButton.classList.toggle('loading', loading); } } reset() { if (this.form) this.form.reset(); this.clearError(); this.setLoading(false); } closeModal() { window.dispatchEvent(new CustomEvent('modal:close', { detail: { modalId: 'change-password-modal' } })); } } // Create singleton instances const authManager = new AuthManager(); let loginFormHandler = null; let userMenuHandler = null; let changePasswordHandler = null; let sessionTimerHandler = null; // Initialize handlers when DOM is ready async function initAuthHandlers() { loginFormHandler = new LoginFormHandler(authManager); userMenuHandler = new UserMenuHandler(authManager); changePasswordHandler = new ChangePasswordHandler(authManager); sessionTimerHandler = new SessionTimerHandler(authManager); sessionTimerHandler.init(); // Bei bestehendem Token: Session refreshen und Timer starten const token = localStorage.getItem('auth_token'); if (token) { await sessionTimerHandler.initFromExistingSession(); } } // Listen for DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initAuthHandlers); } else { initAuthHandlers(); } // Listen for login event to update UI window.addEventListener('auth:login', () => { userMenuHandler?.update(); }); // Listen for token refresh event to update session timer window.addEventListener('auth:token-refreshed', (e) => { sessionTimerHandler?.refreshToken(e.detail?.token); }); export { authManager, loginFormHandler, userMenuHandler, changePasswordHandler, sessionTimerHandler }; export default authManager;