// auth.service.ts // Path: /flare-ui/src/app/services/auth.service.ts import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; import { BehaviorSubject, Observable, throwError, of } from 'rxjs'; import { tap, catchError, map, timeout, retry } from 'rxjs/operators'; interface LoginResponse { token: string; username: string; expires_at?: string; refresh_token?: string; } interface AuthError { message: string; code?: string; details?: any; } @Injectable({ providedIn: 'root' }) export class AuthService { private http = inject(HttpClient); private router = inject(Router); private tokenKey = 'flare_token'; private usernameKey = 'flare_username'; private refreshTokenKey = 'flare_refresh_token'; private tokenExpiryKey = 'flare_token_expiry'; private loggedInSubject = new BehaviorSubject(this.hasValidToken()); public loggedIn$ = this.loggedInSubject.asObservable(); private readonly REQUEST_TIMEOUT = 30000; // 30 seconds private tokenRefreshInProgress = false; private tokenRefreshSubject = new BehaviorSubject(null); login(username: string, password: string): Observable { // Validate input if (!username || !password) { return throwError(() => ({ message: 'Username and password are required', code: 'VALIDATION_ERROR' } as AuthError)); } return this.http.post('/api/admin/login', { username, password }) .pipe( timeout(this.REQUEST_TIMEOUT), retry({ count: 2, delay: 1000 }), tap(response => { this.handleLoginSuccess(response); }), catchError(error => this.handleAuthError(error, 'login')) ); } logout(): void { try { // Clear all auth data this.clearAuthData(); // Update logged in state this.loggedInSubject.next(false); // Optional: Call logout endpoint this.http.post('/api/logout', {}).pipe( catchError(() => of(null)) // Ignore logout errors ).subscribe(); // Navigate to login this.router.navigate(['/login']); console.log('✅ User logged out successfully'); } catch (error) { console.error('Error during logout:', error); // Still navigate to login even if error occurs this.router.navigate(['/login']); } } refreshToken(): Observable { const refreshToken = this.getRefreshToken(); if (!refreshToken) { return throwError(() => ({ message: 'No refresh token available', code: 'NO_REFRESH_TOKEN' } as AuthError)); } // Prevent multiple simultaneous refresh requests if (this.tokenRefreshInProgress) { return this.tokenRefreshSubject.asObservable().pipe( map(token => { if (token) { return { token, username: this.getUsername() || '' } as LoginResponse; } throw new Error('Token refresh failed'); }) ); } this.tokenRefreshInProgress = true; return this.http.post('/api/refresh', { refresh_token: refreshToken }) .pipe( timeout(this.REQUEST_TIMEOUT), tap(response => { this.handleLoginSuccess(response); this.tokenRefreshSubject.next(response.token); this.tokenRefreshInProgress = false; }), catchError(error => { this.tokenRefreshInProgress = false; this.tokenRefreshSubject.next(null); // If refresh fails, logout user if (error.status === 401 || error.status === 403) { this.logout(); } return this.handleAuthError(error, 'refresh'); }) ); } getToken(): string | null { try { // Check if token is expired if (this.isTokenExpired()) { this.clearAuthData(); return null; } return localStorage.getItem(this.tokenKey); } catch (error) { console.error('Error getting token:', error); return null; } } getUsername(): string | null { try { return localStorage.getItem(this.usernameKey); } catch (error) { console.error('Error getting username:', error); return null; } } getRefreshToken(): string | null { try { return localStorage.getItem(this.refreshTokenKey); } catch (error) { console.error('Error getting refresh token:', error); return null; } } setToken(token: string): void { try { localStorage.setItem(this.tokenKey, token); } catch (error) { console.error('Error setting token:', error); throw new Error('Failed to save authentication token'); } } setUsername(username: string): void { try { localStorage.setItem(this.usernameKey, username); } catch (error) { console.error('Error setting username:', error); } } hasToken(): boolean { return !!this.getToken(); } hasValidToken(): boolean { return this.hasToken() && !this.isTokenExpired(); } isLoggedIn(): boolean { return this.hasValidToken(); } isTokenExpired(): boolean { try { const expiryStr = localStorage.getItem(this.tokenExpiryKey); if (!expiryStr) { return false; // No expiry means token doesn't expire } const expiry = new Date(expiryStr); return expiry <= new Date(); } catch (error) { console.error('Error checking token expiry:', error); return true; // Assume expired on error } } getTokenExpiry(): Date | null { try { const expiryStr = localStorage.getItem(this.tokenExpiryKey); return expiryStr ? new Date(expiryStr) : null; } catch (error) { console.error('Error getting token expiry:', error); return null; } } private handleLoginSuccess(response: LoginResponse): void { try { // Save auth data this.setToken(response.token); this.setUsername(response.username); if (response.refresh_token) { localStorage.setItem(this.refreshTokenKey, response.refresh_token); } if (response.expires_at) { localStorage.setItem(this.tokenExpiryKey, response.expires_at); } // Update logged in state this.loggedInSubject.next(true); console.log('✅ Login successful for user:', response.username); } catch (error) { console.error('Error handling login success:', error); throw new Error('Failed to save authentication data'); } } private clearAuthData(): void { try { localStorage.removeItem(this.tokenKey); localStorage.removeItem(this.usernameKey); localStorage.removeItem(this.refreshTokenKey); localStorage.removeItem(this.tokenExpiryKey); } catch (error) { console.error('Error clearing auth data:', error); } } private handleAuthError(error: HttpErrorResponse, operation: string): Observable { console.error(`Auth error during ${operation}:`, error); let authError: AuthError; // Handle different error types if (error.status === 0) { // Network error authError = { message: 'Network error. Please check your connection.', code: 'NETWORK_ERROR' }; } else if (error.status === 401) { authError = { message: error.error?.message || 'Invalid credentials', code: 'UNAUTHORIZED' }; } else if (error.status === 403) { authError = { message: error.error?.message || 'Access forbidden', code: 'FORBIDDEN' }; } else if (error.status === 409) { // Race condition authError = { message: error.error?.message || 'Request conflict. Please try again.', code: 'CONFLICT', details: error.error?.details }; } else if (error.status === 422) { // Validation error authError = { message: error.error?.message || 'Validation error', code: 'VALIDATION_ERROR', details: error.error?.details }; } else if (error.status >= 500) { authError = { message: 'Server error. Please try again later.', code: 'SERVER_ERROR' }; } else { authError = { message: error.error?.message || error.message || 'Authentication failed', code: 'UNKNOWN_ERROR' }; } return throwError(() => authError); } // Validate current session validateSession(): Observable { if (!this.hasToken()) { return of(false); } return this.http.get<{ valid: boolean }>('/api/validate') .pipe( timeout(this.REQUEST_TIMEOUT), map(response => response.valid), catchError(error => { if (error.status === 401) { this.clearAuthData(); this.loggedInSubject.next(false); } return of(false); }) ); } // Get user profile getUserProfile(): Observable { return this.http.get('/api/user/profile') .pipe( timeout(this.REQUEST_TIMEOUT), catchError(error => this.handleAuthError(error, 'getUserProfile')) ); } // Update password updatePassword(currentPassword: string, newPassword: string): Observable { return this.http.post('/api/user/password', { current_password: currentPassword, new_password: newPassword }).pipe( timeout(this.REQUEST_TIMEOUT), catchError(error => this.handleAuthError(error, 'updatePassword')) ); } }