refactor(login): modernize authentication system and UI components

parent 91fbf237
...@@ -42,6 +42,12 @@ ...@@ -42,6 +42,12 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
],
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
...@@ -57,10 +63,28 @@ ...@@ -57,10 +63,28 @@
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "development": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
],
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"staging": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.staging.ts"
}
],
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
......
This diff is collapsed.
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { CanActivate, Router } from '@angular/router'; import { CanActivate, Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { AuthService } from '../../../shared/data-access/service/auth.service'; import { AuthService } from '../../../shared/data-access/service/auth.service';
@Injectable({ @Injectable({ providedIn: 'root' })
providedIn: 'root',
})
export class AdminGuard implements CanActivate { export class AdminGuard implements CanActivate {
constructor( private readonly authService = inject(AuthService);
private authService: AuthService, private readonly router = inject(Router);
private router: Router
) {}
canActivate(): Observable<boolean> { canActivate(): Observable<boolean> {
return this.authService.authState$.pipe( return this.authService.authState$.pipe(
......
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot } from '@angular/router'; import { CanActivate, Router, ActivatedRouteSnapshot } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, take } from 'rxjs/operators'; import { map, take } from 'rxjs/operators';
import { AuthService } from '../../../shared/data-access/service/auth.service'; import { AuthService } from '../../../shared/data-access/service/auth.service';
@Injectable({ @Injectable({ providedIn: 'root' })
providedIn: 'root',
})
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor( private readonly authService = inject(AuthService);
private authService: AuthService, private readonly router = inject(Router);
private router: Router
) {}
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> { canActivate(route: ActivatedRouteSnapshot): Observable<boolean> {
return this.authService.authState$.pipe( return this.authService.authState$.pipe(
......
This diff is collapsed.
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
signal,
inject,
OnInit
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import {
FormBuilder,
FormGroup,
Validators,
ReactiveFormsModule
} from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../shared/data-access/service/auth.service'; import { AuthService } from '../../shared/data-access/service/auth.service';
import { LoginRequest } from '../../shared/data-access/interface/auth.interface'; import { LoginRequest } from '../../shared/data-access/interface/auth.interface';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
@Component({ @Component({
selector: 'meu-login', selector: 'meu-login',
...@@ -12,41 +30,53 @@ import { LoginRequest } from '../../shared/data-access/interface/auth.interface' ...@@ -12,41 +30,53 @@ import { LoginRequest } from '../../shared/data-access/interface/auth.interface'
styleUrls: ['./login.component.scss'], styleUrls: ['./login.component.scss'],
imports: [ imports: [
CommonModule, CommonModule,
ReactiveFormsModule ReactiveFormsModule,
NzLayoutModule,
NzFormModule,
NzInputModule,
NzButtonModule,
NzIconModule,
NzToolTipModule,
NzCheckboxModule
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
loginForm: FormGroup; private readonly fb = inject(FormBuilder); // tạo form login
isLoading = false; private readonly authService = inject(AuthService); // xác thực
errorMessage = ''; private readonly router = inject(Router); // điều hướng
constructor( isVisible = signal<boolean>(false);
private fb: FormBuilder, errorMessage = signal<string>('');
private authService: AuthService, isLoading = signal<boolean>(false);
private router: Router,
private cdr: ChangeDetectorRef loginForm!: FormGroup;
) {
ngOnInit(): void {
this.initForm();
this.checkAuthenticationStatus();
}
initForm(): void {
this.loginForm = this.fb.group({ this.loginForm = this.fb.group({
username: ['', [Validators.required]], username: ['', [Validators.required]],
password: ['', [Validators.required, Validators.minLength(6)]] password: ['', [Validators.required, Validators.minLength(6)]],
}); });
} }
ngOnInit() { private checkAuthenticationStatus(): void {
if (this.authService.isAuthenticated()) { this.authService.isAuthenticated()
this.redirectBasedOnRole(); ? this.redirectBasedOnRole()
} : null;
} }
onSubmit(): void { onLogin(): void {
if (this.loginForm.invalid) { this.loginForm.invalid ? this.markFormGroupTouched() : this.performLogin();
this.markFormGroupTouched(); }
return;
}
this.isLoading = true; private performLogin(): void {
this.errorMessage = ''; this.isLoading.set(true);
this.errorMessage.set('');
const credentials: LoginRequest = { const credentials: LoginRequest = {
username: this.loginForm.value.username, username: this.loginForm.value.username,
...@@ -55,26 +85,28 @@ export class LoginComponent implements OnInit { ...@@ -55,26 +85,28 @@ export class LoginComponent implements OnInit {
this.authService.login(credentials).subscribe({ this.authService.login(credentials).subscribe({
next: (response) => { next: (response) => {
this.isLoading = false; this.isLoading.set(false);
this.cdr.detectChanges();
this.redirectBasedOnRole(); this.redirectBasedOnRole();
}, },
error: (error) => { error: (error) => {
this.errorMessage = error; this.errorMessage.set(error);
this.isLoading = false; this.isLoading.set(false);
this.cdr.detectChanges();
} }
}); });
} }
private redirectBasedOnRole(): void { private redirectBasedOnRole(): void {
if (this.authService.isAdmin()) { this.authService.isAuthenticated()
this.router.navigate(['/admin']); ? this.navigateByRole()
} else if (this.authService.isCustomer()) { : this.router.navigate(['/login']);
this.router.navigate(['/']); }
} else {
this.router.navigate(['/']); private navigateByRole(): void {
} this.authService.isAdmin()
? this.router.navigate(['/admin'])
: this.authService.isCustomer()
? this.router.navigate(['/'])
: this.router.navigate(['/']);
} }
private markFormGroupTouched(): void { private markFormGroupTouched(): void {
...@@ -83,7 +115,6 @@ export class LoginComponent implements OnInit { ...@@ -83,7 +115,6 @@ export class LoginComponent implements OnInit {
}); });
} }
// Getter methods for template
get username() { return this.loginForm.get('username'); } get username() { return this.loginForm.get('username'); }
get password() { return this.loginForm.get('password'); } get password() { return this.loginForm.get('password'); }
} }
...@@ -16,8 +16,17 @@ import { provideAnimationsAsync } from '@angular/platform-browser/animations/asy ...@@ -16,8 +16,17 @@ import { provideAnimationsAsync } from '@angular/platform-browser/animations/asy
import { interceptors } from '../../shared/utils/interceptor/interceptor'; import { interceptors } from '../../shared/utils/interceptor/interceptor';
import { shellRoutes } from './shell.routes'; import { shellRoutes } from './shell.routes';
// Import ng-zorro icons
import * as AllIcons from '@ant-design/icons-angular/icons';
import { IconDefinition } from '@ant-design/icons-angular';
registerLocaleData(en); registerLocaleData(en);
const antDesignIcons = AllIcons as {
[key: string]: IconDefinition;
};
const icons: IconDefinition[] = Object.keys(antDesignIcons).map(key => antDesignIcons[key]);
export const shellConfig: ApplicationConfig = { export const shellConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
...@@ -25,6 +34,7 @@ export const shellConfig: ApplicationConfig = { ...@@ -25,6 +34,7 @@ export const shellConfig: ApplicationConfig = {
provideAnimations(), provideAnimations(),
provideHttpClient(withInterceptors([interceptors])), provideHttpClient(withInterceptors([interceptors])),
provideNzI18n(en_US), provideNzI18n(en_US),
provideNzIcons(icons),
importProvidersFrom(FormsModule), importProvidersFrom(FormsModule),
provideAnimationsAsync(), provideAnimationsAsync(),
provideHttpClient(), provideHttpClient(),
......
<header class="tw-bg-white tw-shadow-sm tw-border-b tw-border-gray-200"> <nz-header class="tw-bg-white/95 tw-backdrop-blur-sm tw-shadow-lg tw-border-b tw-border-gray-100 tw-px-0">
<div class="tw-max-w-7xl tw-mx-auto tw-px-4 sm:tw-px-6 lg:tw-px-8"> <div class="tw-max-w-7xl tw-mx-auto tw-px-4 sm:tw-px-6 lg:tw-px-8">
<div class="tw-flex tw-justify-between tw-items-center tw-h-16"> <div class="tw-flex tw-justify-between tw-items-center tw-h-18">
<!-- Logo/Brand --> <!-- Logo/Brand -->
<div class="tw-flex tw-items-center"> <div class="tw-flex tw-items-center">
<div class="tw-w-8 tw-h-8 tw-bg-gradient-to-r tw-from-blue-600 tw-to-indigo-600 tw-rounded-lg tw-flex tw-items-center tw-justify-center tw-mr-3"> <div class="tw-w-12 tw-h-12 tw-bg-gradient-to-br tw-from-blue-600 tw-via-indigo-600 tw-to-purple-600 tw-rounded-2xl tw-flex tw-items-center tw-justify-center tw-shadow-lg tw-mr-4">
<svg class="tw-w-5 tw-h-5 tw-text-white" fill="currentColor" viewBox="0 0 24 24"> <span nz-icon nzType="star" nzTheme="fill" class="tw-text-white tw-text-xl"></span>
<path d="M20 6h-2V4c0-1.11-.89-2-2-2H8c-1.11 0-2 .89-2 2v2H4c-1.11 0-2 .89-2 2v11c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zM8 4h8v2H8V4zm11 15H5V10h14v9zm-8-7.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5z"/> </div>
</svg> <div>
<h1 class="tw-text-2xl tw-font-bold tw-text-gray-900">JobMeU</h1>
<p class="tw-text-sm tw-text-gray-600">Management Suite</p>
</div> </div>
<h1 class="tw-text-xl tw-font-bold tw-text-gray-900">Job Management</h1>
</div> </div>
<!-- User Menu --> <!-- User Menu -->
<div *ngIf="authState$ | async as authState" class="tw-flex tw-items-center tw-space-x-4"> @if (authState$ | async; as authState) {
<div *ngIf="authState.isAuthenticated" class="tw-flex tw-items-center tw-space-x-4"> @if (authState.isAuthenticated) {
<!-- User Info --> <div class="tw-flex tw-items-center tw-space-x-6">
<div class="tw-flex tw-items-center tw-space-x-2"> <!-- User Info -->
<div class="tw-w-8 tw-h-8 tw-bg-gradient-to-r tw-from-blue-500 tw-to-purple-500 tw-rounded-full tw-flex tw-items-center tw-justify-center"> <nz-card
<svg class="tw-w-4 tw-h-4 tw-text-white" fill="currentColor" viewBox="0 0 20 20"> class="tw-bg-gray-50 tw-rounded-2xl tw-shadow-none"
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"/> [nzBordered]="false"
</svg> [nzBodyStyle]="{ padding: '12px 16px' }">
</div> <div class="tw-flex tw-items-center tw-space-x-3">
<div class="tw-text-sm"> <nz-avatar
<div class="tw-font-medium tw-text-gray-900">{{ authState.user?.username || 'User' }}</div> [nzSize]="40"
<div class="tw-text-gray-500 tw-capitalize">{{ authState.user?.role }}</div> nzIcon="user"
</div> class="tw-bg-gradient-to-br tw-from-blue-500 tw-to-purple-500">
</div> </nz-avatar>
<div class="tw-text-sm">
<div class="tw-font-semibold tw-text-gray-900">{{ authState.user?.username || 'User' }}</div>
<nz-tag
[nzColor]="authState.user?.role === 'admin' ? 'blue' : 'green'"
class="tw-text-xs tw-font-medium tw-capitalize tw-mt-1">
{{ authState.user?.role }}
</nz-tag>
</div>
</div>
</nz-card>
<!-- Logout Button --> <!-- Logout Button -->
<button <button
(click)="onLogout()" nz-button
[disabled]="isLoggingOut" nzType="primary"
class="tw-inline-flex tw-items-center tw-px-3 tw-py-2 tw-border tw-border-transparent tw-text-sm tw-leading-4 tw-font-medium tw-rounded-md tw-text-white tw-bg-red-600 hover:tw-bg-red-700 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-offset-2 focus:tw-ring-red-500 disabled:tw-opacity-50 disabled:tw-cursor-not-allowed tw-transition-colors tw-duration-200" nzDanger
> (click)="onLogout()"
<div *ngIf="!isLoggingOut" class="tw-flex tw-items-center"> [nzLoading]="isLoggingOut"
<svg class="tw-w-4 tw-h-4 tw-mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> [disabled]="isLoggingOut"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/> class="tw-h-10 tw-px-4 tw-rounded-lg tw-font-medium tw-shadow-sm hover:tw-shadow-md tw-transition-all tw-duration-200 tw-flex tw-items-center tw-justify-center tw-min-w-[100px] tw-text-sm">
</svg> @if (!isLoggingOut) {
<span>Logout</span> <span nz-icon nzType="logout" nzTheme="outline" class="tw-mr-2 tw-text-sm"></span>
</div> <span>Logout</span>
<div *ngIf="isLoggingOut" class="tw-flex tw-items-center"> } @else {
<svg class="tw-animate-spin tw-w-4 tw-h-4 tw-mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <span nz-icon nzType="loading" nzSpin class="tw-mr-2 tw-text-sm"></span>
<circle class="tw-opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <span>Logging out...</span>
<path class="tw-opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> }
</svg> </button>
<span>Logging out...</span> </div>
</div> }
</button> }
</div>
</div>
</div> </div>
</div> </div>
</header> </nz-header>
\ No newline at end of file \ No newline at end of file
...@@ -2,13 +2,27 @@ import { CommonModule } from '@angular/common'; ...@@ -2,13 +2,27 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, OnInit, inject, ChangeDetectorRef } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnInit, inject, ChangeDetectorRef } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../../../../shared/data-access/service/auth.service'; import { AuthService } from '../../../../../shared/data-access/service/auth.service';
import { Observable } from 'rxjs'; import { Observable, finalize } from 'rxjs';
import { AuthState } from '../../../../../shared/data-access/interface/auth.interface'; import { AuthState } from '../../../../../shared/data-access/interface/auth.interface';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzAvatarModule } from 'ng-zorro-antd/avatar';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzButtonModule } from 'ng-zorro-antd/button';
@Component({ @Component({
selector: 'meu-header', selector: 'meu-header',
standalone: true, standalone: true,
imports: [CommonModule], imports: [
CommonModule,
NzLayoutModule,
NzIconModule,
NzCardModule,
NzAvatarModule,
NzTagModule,
NzButtonModule
],
templateUrl: './header.component.html', templateUrl: './header.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
...@@ -28,18 +42,14 @@ export class HeaderComponent implements OnInit { ...@@ -28,18 +42,14 @@ export class HeaderComponent implements OnInit {
this.isLoggingOut = true; this.isLoggingOut = true;
this.cdr.markForCheck(); this.cdr.markForCheck();
this.authService.logout().subscribe({ this.authService.logout()
next: () => { .pipe(
this.router.navigate(['/login']); finalize(() => {
}, this.isLoggingOut = false;
error: (error) => { this.cdr.markForCheck();
// Even if API fails, redirect to login this.router.navigate(['/login']);
this.router.navigate(['/login']); })
}, )
complete: () => { .subscribe();
this.isLoggingOut = false;
this.cdr.markForCheck();
}
});
} }
} }
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { LoginRequest, LoginResponse } from '../interface/auth.interface';
import { environment } from '../../../../environments/environment';
import { StorageService } from './storage.service';
@Injectable({ providedIn: 'root' })
export class AuthApiService {
private readonly http = inject(HttpClient);
private readonly storageService = inject(StorageService);
private readonly API_URL = environment.API_DOMAIN;
// HTTP Headers
private getHeaders = (withAuth = false): HttpHeaders => {
const headers: { [key: string]: string } = { 'Content-Type': 'application/json' };
if (withAuth) {
const token = this.storageService.getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
}
return new HttpHeaders(headers);
};
// API Calls
login = (credentials: LoginRequest): Observable<LoginResponse> =>
this.http.post<LoginResponse>(`${this.API_URL}/login`, credentials, {
headers: this.getHeaders()
}).pipe(catchError(this.handleError));
refreshToken = (): Observable<LoginResponse> => {
const refreshToken = this.storageService.getRefreshToken();
if (!refreshToken) {
return throwError('No refresh token available');
}
return this.http.post<LoginResponse>(`${this.API_URL}/refresh`, { refreshToken }, {
headers: this.getHeaders()
}).pipe(catchError(this.handleError));
};
// Error handling
private handleError = (error: any): Observable<never> => {
const message = error.error?.message || error.message || 'An error occurred';
const statusMessage = this.getStatusMessage(error.status, message);
return throwError(statusMessage);
};
private getStatusMessage = (status: number, defaultMessage: string): string => {
const statusMessages: Record<number, string> = {
401: 'Invalid credentials',
403: 'Access denied',
500: 'Server error. Please try again later.'
};
return statusMessages[status] || defaultMessage;
};
}
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { AuthState, UserInfo } from '../interface/auth.interface';
import { StorageService } from './storage.service';
@Injectable({ providedIn: 'root' })
export class AuthStateService {
private readonly storageService = inject(StorageService);
private authStateSubject = new BehaviorSubject<AuthState>({
isAuthenticated: false,
user: null,
token: null
});
authState$ = this.authStateSubject.asObservable();
// State getters
getCurrentState = (): AuthState => this.authStateSubject.value;
isAuthenticated = (): boolean => this.getCurrentState().isAuthenticated;
getCurrentUser = (): UserInfo | null => this.getCurrentState().user;
getCurrentToken = (): string | null => this.getCurrentState().token;
// Role checks
hasRole = (role: string): boolean => this.getCurrentUser()?.role === role;
isAdmin = (): boolean => this.hasRole('admin');
isCustomer = (): boolean => this.hasRole('customer');
// State updates
setAuthenticatedState = (user: UserInfo, token: string): void => {
this.authStateSubject.next({
isAuthenticated: true,
user,
token
});
};
setUnauthenticatedState = (): void => {
this.authStateSubject.next({
isAuthenticated: false,
user: null,
token: null
});
};
// Initialize state from storage
loadStateFromStorage = (): void => {
if (this.storageService.hasValidAuthData()) {
const user = this.storageService.getUser();
const token = this.storageService.getToken();
if (user && token) {
this.setAuthenticatedState(user, token);
return;
}
}
this.setUnauthenticatedState();
};
// Complete logout
logout = (): void => {
this.storageService.clearAllAuth();
this.setUnauthenticatedState();
};
}
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Observable, of, throwError } from 'rxjs';
import { BehaviorSubject, Observable, throwError, of } from 'rxjs'; import { tap, delay, catchError } from 'rxjs/operators';
import { map, catchError, tap, delay } from 'rxjs/operators';
import { LoginRequest, LoginResponse, AuthState, UserInfo } from '../interface/auth.interface'; import { LoginRequest, LoginResponse, AuthState, UserInfo } from '../interface/auth.interface';
import { StorageService } from './storage.service';
import { AuthStateService } from './auth-state.service';
import { AuthApiService } from './auth-api.service';
@Injectable({ @Injectable({ providedIn: 'root' })
providedIn: 'root'
})
export class AuthService { export class AuthService {
private readonly API_URL = 'http://localhost:3000'; // Replace with your actual API domain private readonly storageService = inject(StorageService);
private readonly TOKEN_KEY = 'auth_token'; private readonly authStateService = inject(AuthStateService);
private readonly USER_KEY = 'user_info'; private readonly authApiService = inject(AuthApiService);
// Reactive state management // Expose reactive state
private authStateSubject = new BehaviorSubject<AuthState>({ authState$ = this.authStateService.authState$;
isAuthenticated: false,
user: null,
token: null
});
public authState$ = this.authStateSubject.asObservable(); constructor() {
this.authStateService.loadStateFromStorage();
constructor(private http: HttpClient) {
// Load saved auth state on service init
this.loadAuthState();
}
/**
* Login with username and password
*/
login(credentials: LoginRequest): Observable<LoginResponse> {
const headers = new HttpHeaders({
'Content-Type': 'application/json'
});
return this.http.post<LoginResponse>(`${this.API_URL}/login`, credentials, { headers })
.pipe(
tap(response => this.handleLoginSuccess(response, credentials.username)),
catchError(error => this.handleError(error))
);
} }
/** // Main API methods
* Logout user login = (credentials: LoginRequest): Observable<LoginResponse> =>
*/ this.authApiService.login(credentials).pipe(
logout(): Observable<any> { tap(response => this.handleLoginSuccess(response, credentials.username)),
// Clear local storage and auth state immediately catchError(error => {
this.handleLogoutSuccess(); if (error.status === 401) this.authStateService.logout();
return throwError(error);
// Return success observable })
return of({ success: true, message: 'Logged out successfully' }).pipe(
delay(500) // Small delay to show loading state
); );
}
/** logout = (): Observable<any> => {
* Refresh access token this.authStateService.logout();
*/ return of({ success: true, message: 'Logged out successfully' }).pipe(delay(500));
refreshToken(): Observable<LoginResponse> { };
const refreshToken = localStorage.getItem('refresh_token');
const currentUser = this.authStateSubject.value.user; refreshToken = (): Observable<LoginResponse> =>
this.authApiService.refreshToken().pipe(
if (!refreshToken) { tap(response => {
this.logout(); const username = this.authStateService.getCurrentUser()?.username || 'Unknown';
return throwError('No refresh token available'); this.handleLoginSuccess(response, username);
} }),
return this.http.post<LoginResponse>(`${this.API_URL}/refresh`, {
refreshToken
}).pipe(
tap(response => this.handleLoginSuccess(response, currentUser?.username || 'Unknown')),
catchError(error => { catchError(error => {
this.logout(); this.authStateService.logout();
return throwError(error); return throwError(error);
}) })
); );
}
/** // Auth state getters (delegate to AuthStateService)
* Get current user info isAuthenticated = (): boolean => this.authStateService.isAuthenticated();
*/ getToken = (): string | null => this.storageService.getToken();
getCurrentUser(): Observable<UserInfo> { getUser = (): UserInfo | null => this.authStateService.getCurrentUser();
const headers = this.getAuthHeaders(); hasRole = (role: string): boolean => this.authStateService.hasRole(role);
isAdmin = (): boolean => this.authStateService.isAdmin();
isCustomer = (): boolean => this.authStateService.isCustomer();
// Private helpers
private handleLoginSuccess = (response: LoginResponse, username: string): void => {
if (!response.success) throw new Error(response.message || 'Login failed');
return this.http.get<UserInfo>(`${this.API_URL}/me`, { headers })
.pipe(
tap(user => this.updateUserInfo(user)),
catchError(error => this.handleError(error))
);
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return this.authStateSubject.value.isAuthenticated;
}
/**
* Check if user has specific role
*/
hasRole(role: string): boolean {
const currentUser = this.authStateSubject.value.user;
return currentUser?.role === role;
}
/**
* Check if user is admin
*/
isAdmin(): boolean {
return this.hasRole('admin');
}
/**
* Check if user is customer
*/
isCustomer(): boolean {
return this.hasRole('customer');
}
/**
* Get stored token
*/
getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY);
}
/**
* Get current user info
*/
getUser(): UserInfo | null {
return this.authStateSubject.value.user;
}
/**
* Get auth headers for API calls
*/
private getAuthHeaders(): HttpHeaders {
const token = this.getToken();
return new HttpHeaders({
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
});
}
/**
* Handle successful login
*/
private handleLoginSuccess(response: LoginResponse, username: string): void {
if (!response.success) {
throw new Error(response.message || 'Login failed');
}
const { token, role, expirationTime } = response.responseData; const { token, role, expirationTime } = response.responseData;
const user: UserInfo = { username, role };
// Store tokens this.storageService.storeAuthData(token, user, expirationTime);
localStorage.setItem(this.TOKEN_KEY, token); this.authStateService.setAuthenticatedState(user, token);
localStorage.setItem('token_expiration', expirationTime.toString()); };
// Create user info from response
const userInfo: UserInfo = {
username: username, // Use the username from login credentials
role: role
};
localStorage.setItem(this.USER_KEY, JSON.stringify(userInfo));
// Update auth state
this.authStateSubject.next({
isAuthenticated: true,
user: userInfo,
token: token
});
}
/**
* Handle logout
*/
private handleLogoutSuccess(): void {
// Clear storage
localStorage.removeItem(this.TOKEN_KEY);
localStorage.removeItem('refresh_token');
localStorage.removeItem(this.USER_KEY);
// Reset auth state
this.authStateSubject.next({
isAuthenticated: false,
user: null,
token: null
});
}
/**
* Load auth state from storage
*/
private loadAuthState(): void {
const token = localStorage.getItem(this.TOKEN_KEY);
const userInfo = localStorage.getItem(this.USER_KEY);
if (token && userInfo) {
try {
const user = JSON.parse(userInfo);
this.authStateSubject.next({
isAuthenticated: true,
user,
token
});
} catch (error) {
this.handleLogoutSuccess();
}
}
}
/**
* Update user info
*/
private updateUserInfo(user: UserInfo): void {
localStorage.setItem(this.USER_KEY, JSON.stringify(user));
const currentState = this.authStateSubject.value;
this.authStateSubject.next({
...currentState,
user
});
}
/**
* Handle API errors
*/
private handleError(error: any): Observable<never> {
let errorMessage = 'An error occurred';
if (error.error?.message) {
errorMessage = error.error.message;
} else if (error.message) {
errorMessage = error.message;
}
switch (error.status) {
case 401:
errorMessage = 'Invalid credentials';
this.handleLogoutSuccess();
break;
case 403:
errorMessage = 'Access denied';
break;
case 500:
errorMessage = 'Server error. Please try again later.';
break;
}
return throwError(errorMessage);
}
} }
import { Injectable } from '@angular/core';
import { UserInfo } from '../interface/auth.interface';
@Injectable({ providedIn: 'root' })
export class StorageService {
private readonly TOKEN_KEY = 'token';
private readonly USER_KEY = 'user_info';
private readonly REFRESH_KEY = 'refresh_token';
private readonly EXPIRY_KEY = 'token_expiration';
// Token operations
setToken = (token: string): void => localStorage.setItem(this.TOKEN_KEY, token);
getToken = (): string | null => localStorage.getItem(this.TOKEN_KEY);
removeToken = (): void => localStorage.removeItem(this.TOKEN_KEY);
// User operations
setUser = (user: UserInfo): void => localStorage.setItem(this.USER_KEY, JSON.stringify(user));
getUser = (): UserInfo | null => {
const userStr = localStorage.getItem(this.USER_KEY);
if (!userStr) return null;
try {
return JSON.parse(userStr);
} catch {
return null;
}
};
removeUser = (): void => localStorage.removeItem(this.USER_KEY);
// Expiry operations
setExpiry = (expiry: number): void => localStorage.setItem(this.EXPIRY_KEY, expiry.toString());
getExpiry = (): number | null => {
const expiry = localStorage.getItem(this.EXPIRY_KEY);
return expiry ? parseInt(expiry, 10) : null;
};
removeExpiry = (): void => localStorage.removeItem(this.EXPIRY_KEY);
// Refresh token operations
setRefreshToken = (token: string): void => localStorage.setItem(this.REFRESH_KEY, token);
getRefreshToken = (): string | null => localStorage.getItem(this.REFRESH_KEY);
removeRefreshToken = (): void => localStorage.removeItem(this.REFRESH_KEY);
// Batch operations
storeAuthData = (token: string, user: UserInfo, expiry: number): void => {
this.setToken(token);
this.setUser(user);
this.setExpiry(expiry);
};
clearAllAuth = (): void => {
this.removeToken();
this.removeUser();
this.removeExpiry();
this.removeRefreshToken();
};
// Validation
hasValidAuthData = (): boolean => {
const token = this.getToken();
const user = this.getUser();
return Boolean(token && user);
};
}
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError, BehaviorSubject } from 'rxjs';
import { catchError, filter, take, switchMap } from 'rxjs/operators';
import { AuthService } from '../../data-access/service/auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private isRefreshing = false;
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
constructor(private authService: AuthService) {}
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Add auth token to requests
const authToken = this.authService.getToken();
if (authToken) {
request = this.addToken(request, authToken);
}
return next.handle(request).pipe(
catchError(error => {
if (error instanceof HttpErrorResponse && error.status === 401) {
return this.handle401Error(request, next);
} else {
return throwError(error);
}
})
);
}
private addToken(request: HttpRequest<any>, token: string): HttpRequest<any> {
return request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!this.isRefreshing) {
this.isRefreshing = true;
this.refreshTokenSubject.next(null);
return this.authService.refreshToken().pipe(
switchMap((response: any) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(response.accessToken);
return next.handle(this.addToken(request, response.accessToken));
}),
catchError((error) => {
this.isRefreshing = false;
this.authService.logout();
return throwError(error);
})
);
} else {
return this.refreshTokenSubject.pipe(
filter(token => token != null),
take(1),
switchMap(jwt => {
return next.handle(this.addToken(request, jwt));
})
);
}
}
}
...@@ -24,15 +24,20 @@ export const interceptors: HttpInterceptorFn = ( ...@@ -24,15 +24,20 @@ export const interceptors: HttpInterceptorFn = (
const showLoading = req.context.get(SHOW_LOADING); const showLoading = req.context.get(SHOW_LOADING);
const showApiMessage = req.context.get(SHOW_API_MESSAGE); const showApiMessage = req.context.get(SHOW_API_MESSAGE);
const showApiErrorMessage = req.context.get(SHOW_API_ERROR_MESSAGE); const showApiErrorMessage = req.context.get(SHOW_API_ERROR_MESSAGE);
const token = localStorage.getItem('token'); //custom const token = localStorage.getItem('token');
// Clone request with headers
const headers: { [key: string]: string } = {
StandardTimeZoneName: `${Intl.DateTimeFormat().resolvedOptions().timeZone}`,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
req = req.clone({ req = req.clone({
url: !req.url.includes('http') ? environment.API_DOMAIN + req.url : req.url, url: !req.url.includes('http') ? environment.API_DOMAIN + req.url : req.url,
setHeaders: { setHeaders: headers,
Authorization: `Bearer ${token ? token : ''}`,
StandardTimeZoneName: `${
Intl.DateTimeFormat().resolvedOptions().timeZone
}`,
},
}); });
runIf(showLoading, () => {}); runIf(showLoading, () => {});
return next(req).pipe( return next(req).pipe(
......
// ng-zorro-antd styles
@import 'ng-zorro-antd/ng-zorro-antd.min.css';
// Custom ant design overrides
@import "input"; @import "input";
export const environment = { export const environment = {
API_DOMAIN: 'http://localhost:5051/api/v1.0/', //custom API_DOMAIN: 'http://localhost:3000', //custom
production: false,
staging: false,
development: true,
}; };
export const environment = { export const environment = {
API_DOMAIN: ' ', //custom API_DOMAIN: ' ', //custom
production: true,
staging: false,
development: false,
}; };
export const environment = { export const environment = {
API_DOMAIN: ' ', //custom API_DOMAIN: ' ', //custom
production: false,
staging: true,
development: false,
}; };
export const environment = {
API_DOMAIN: '', //custom
production: false,
staging: false,
development: false,
};
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment