diff --git a/angular.json b/angular.json index 84275e3..a056098 100644 --- a/angular.json +++ b/angular.json @@ -82,7 +82,8 @@ "buildTarget": "service-provider-app:build:production" }, "development": { - "buildTarget": "service-provider-app:build:development" + "buildTarget": "service-provider-app:build:development", + "port": 5173 } }, "defaultConfiguration": "development" diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 82f21cc..c0cdc3d 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,13 +4,16 @@ import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; -import { provideHttpClient } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { AuthInterceptor } from './core/interceptors/auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideClientHydration(withEventReplay()), - provideHttpClient(), - provideCharts(withDefaultRegisterables())] + provideHttpClient(withInterceptorsFromDi()), + provideCharts(withDefaultRegisterables()), + { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, + ] }; diff --git a/src/app/common/secured-header/secured-header.component.ts b/src/app/common/secured-header/secured-header.component.ts index 6f9d129..c88e88e 100644 --- a/src/app/common/secured-header/secured-header.component.ts +++ b/src/app/common/secured-header/secured-header.component.ts @@ -1,12 +1,10 @@ import { Component, effect, OnInit } from '@angular/core'; import { UserService } from '../../core/services/common/user.service'; -import { Router } from '@angular/router'; import { AngularMaterialModule } from '../../shared/module/angular-material.module'; import { CommonModule } from '@angular/common'; import { NavigationService } from '../../core/services/common/navigation.service'; import { User } from '../../core/models/user'; -import { ThemeService } from '../../core/services/theme.service'; -import { StorageService } from '../../core/services/common/storage.service'; +import { AuthService } from '../../core/services/common/auth.service'; @Component({ selector: 'app-secured-header', @@ -22,10 +20,8 @@ export class SecuredHeaderComponent implements OnInit { constructor( private userService: UserService, - private router: Router, private navigationService: NavigationService, - private themeService: ThemeService, - private storageService: StorageService + private authService: AuthService ) { effect(() => { this.userDetails = this.userService.userDetailsSignal(); @@ -44,9 +40,6 @@ export class SecuredHeaderComponent implements OnInit { setApplicationDetails() { if (this.userDetails?.userDetails) { this.logoUrl = `images/logos/${this.userDetails?.userDetails?.logoName}`; - this.themeService.setTheme(this.userDetails?.userDetails?.themeName); - } else { - this.themeService.setTheme('default'); } } @@ -55,15 +48,11 @@ export class SecuredHeaderComponent implements OnInit { } logout(): void { - this.userService.clearUser(); - this.storageService.clear(); - this.router.navigate(['/login']); + this.authService.logout(); this.showProfileMenu = false; - this.themeService.setTheme('default'); } navigateTo(route: string): void { - this.navigationService.navigate([route]); this.showProfileMenu = false; } diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts new file mode 100644 index 0000000..e2f6fac --- /dev/null +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -0,0 +1,111 @@ +// auth.interceptor.ts +import { Injectable } from '@angular/core'; +import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, HttpErrorResponse } from '@angular/common/http'; +import { Observable, throwError, BehaviorSubject } from 'rxjs'; +import { catchError, filter, switchMap, take } from 'rxjs/operators'; +import { AuthService } from '../services/common/auth.service'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + + private isRefreshing = false; + private refreshTokenSubject: BehaviorSubject = new BehaviorSubject(null); + + private requestQueue: { request: HttpRequest, next: HttpHandler }[] = []; + + constructor(private authService: AuthService) { } + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + + // Add withCredentials to all requests + request = request.clone({ + withCredentials: true + }); + + return next.handle(request).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + // Handle 401 Unauthorized responses + if (error.url?.includes('refresh-tokens')) { + return throwError(() => error); + } + + return this.handle401Error(request, next); + } else { + return throwError(() => error); + } + }) + ); + } + + private handle401Error(request: HttpRequest, next: HttpHandler): Observable> { + if (!this.isRefreshing) { + this.isRefreshing = true; + this.refreshTokenSubject.next(null); + + return this.authService.refreshToken().pipe( + switchMap((token: any) => { + this.isRefreshing = false; + this.refreshTokenSubject.next(token); + + // Retry all queued requests with new token + this.retryQueuedRequests(); + + // Retry the original request + request = request.clone({ + withCredentials: true + }); + + return next.handle(request); + }), + catchError((err) => { + this.isRefreshing = false; + this.authService.logout(); + return throwError(() => err); + + }) + ); + } else { + // If token refresh is already in progress, add to queue + return this.addRequestToQueue(request, next); + } + } + + private addRequestToQueue(request: HttpRequest, next: HttpHandler): Observable> { + return new Observable>(observer => { + const subscription = this.refreshTokenSubject.pipe( + filter(token => token !== null), + take(1) + ).subscribe(token => { + // Remove from queue + const index = this.requestQueue.findIndex(item => item.request === request); + if (index > -1) { + this.requestQueue.splice(index, 1); + } + + // Retry request with new token + next.handle(request).subscribe({ + next: event => observer.next(event), + error: err => observer.error(err), + complete: () => observer.complete() + }); + }); + + // Add to queue + this.requestQueue.push({ request, next }); + }); + } + + private retryQueuedRequests(): void { + // Process all queued requests + while (this.requestQueue.length > 0) { + let { request, next } = this.requestQueue.shift()!; + + request = request.clone({ + withCredentials: true + }); + + next.handle(request).subscribe(); + } + } +} \ No newline at end of file diff --git a/src/app/core/services/common/auth.service.ts b/src/app/core/services/common/auth.service.ts index 1b13ce6..b5bf550 100644 --- a/src/app/core/services/common/auth.service.ts +++ b/src/app/core/services/common/auth.service.ts @@ -1,7 +1,13 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, tap } from 'rxjs'; import { environment } from '../../../../environments/environment'; +import { UserService } from './user.service'; +import { StorageService } from './storage.service'; +import { Router } from '@angular/router'; +import { ThemeService } from '../theme.service'; +import { NotificationService } from './notification.service'; +import { ApiErrorHandlerService } from './api-error-handler.service'; @Injectable({ providedIn: 'root' @@ -9,9 +15,38 @@ import { environment } from '../../../../environments/environment'; export class AuthService { private apiUrl = environment.apiUrl; - constructor(private http: HttpClient) { } + constructor(private http: HttpClient, + private userService: UserService, + private storageService: StorageService, + private router: Router, + private themeService: ThemeService, + private notificationService: NotificationService, + private errorHandler: ApiErrorHandlerService + ) { } login(username: string, password: string): Observable { return this.http.post(`${this.apiUrl}/login`, { p_emailaddr: username, p_password: password }); } + + refreshToken(): Observable { + return this.http.get(`${this.apiUrl}/refresh-tokens`, {}); + } + + logout(): void { + if (this.userService.isLoggedIn()) { + this.http.post(`${this.apiUrl}/logout`, {}).subscribe({ + next: (response) => { + this.userService.clearUser(); + this.storageService.clear(); + this.router.navigate(['/login']); + this.themeService.setTheme('default');; + } + , error: (error) => { + let errorMessage = this.errorHandler.handleApiError(error, `Logout failed`); + this.notificationService.showError(errorMessage); + console.error('Logout failed:', error); + } + }); + } + } } diff --git a/src/app/core/services/common/user.service.ts b/src/app/core/services/common/user.service.ts index f321a22..6670560 100644 --- a/src/app/core/services/common/user.service.ts +++ b/src/app/core/services/common/user.service.ts @@ -19,7 +19,7 @@ export class UserService { private readonly USER_EMAIL_KEY = 'CurrentUserEmail'; private readonly USER_DETAILS_KEY = 'CurrentUserData'; - private userLoggedInSubject = new BehaviorSubject(true); + private userLoggedInSubject = new BehaviorSubject(false); constructor(private http: HttpClient, private storageService: StorageService) { } @@ -90,6 +90,7 @@ export class UserService { } return this.spid; } + private mapToUser(data: any): User { return { roles: data.roleDetails || null, diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html index 3cf2ffd..d234c5c 100644 --- a/src/app/login/login.component.html +++ b/src/app/login/login.component.html @@ -1,10 +1,5 @@