From 4cb9b0181e9f6d9f43ee798d81b7908bc134d4ee Mon Sep 17 00:00:00 2001 From: Gagan7900 <54118395+Gagan7900@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:47:54 +0530 Subject: [PATCH] implement authentication and reusable data table --- .../images/brand-logos/erp-logo-icon.png | Bin 0 -> 1686 bytes src/app/authentication/login/login.html | 118 ++-------- src/app/authentication/login/login.ts | 207 ++++++------------ src/app/core/auth/auth.service.ts | 110 ++++++---- src/app/core/auth/token-storage.service.ts | 107 +++++++-- src/app/core/config/api.config.ts | 3 + src/app/core/guards/auth.guard.ts | 32 ++- src/app/core/guards/guest.guard.ts | 53 +++++ src/app/core/interceptors/auth.interceptor.ts | 30 ++- .../core/interceptors/error.interceptor.ts | 11 +- .../core/interceptors/loading-interceptor.ts | 15 +- src/app/core/models/context.model.ts | 33 +++ src/app/core/services/app-context.service.ts | 104 +++++++++ src/app/core/services/menu.data.ts | 66 ++++++ src/app/core/services/menu.service.ts | 42 ++++ src/app/core/services/permission.service.ts | 34 +++ .../core/services/session-timeout.service.ts | 154 +++++++++++++ .../core/services/tenant-context.service.ts | 23 ++ src/app/core/services/user-context.service.ts | 32 +++ .../pages/tenant-list/tenant-list.html | 22 +- .../tenants/pages/tenant-list/tenant-list.ts | 82 ++++++- .../components/data-table/data-table.html | 130 ++++++++++- .../components/data-table/data-table.ts | 103 ++++++++- .../components/data-table/data-table.types.ts | 35 +++ .../shared/components/sidebar/sidebar.html | 23 +- .../authentication-layout.html | 2 +- .../authentication-layout.ts | 3 +- .../content-layout/content-layout.html | 27 +++ .../layouts/content-layout/content-layout.ts | 13 ++ src/app/shared/routes/auth.routes.ts | 2 + src/app/shared/services/nav.service.ts | 84 +------ src/environments/environment.prod.ts | 4 + src/environments/environment.ts | 4 + src/index.html | 2 +- 34 files changed, 1303 insertions(+), 407 deletions(-) create mode 100644 public/assets/images/brand-logos/erp-logo-icon.png create mode 100644 src/app/core/guards/guest.guard.ts create mode 100644 src/app/core/models/context.model.ts create mode 100644 src/app/core/services/app-context.service.ts create mode 100644 src/app/core/services/menu.data.ts create mode 100644 src/app/core/services/menu.service.ts create mode 100644 src/app/core/services/permission.service.ts create mode 100644 src/app/core/services/session-timeout.service.ts create mode 100644 src/app/core/services/tenant-context.service.ts create mode 100644 src/app/core/services/user-context.service.ts create mode 100644 src/app/shared/components/data-table/data-table.types.ts diff --git a/public/assets/images/brand-logos/erp-logo-icon.png b/public/assets/images/brand-logos/erp-logo-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..eacc976ad7210030d666bf2c996eb4685698438d GIT binary patch literal 1686 zcmV;H25I?;P)6l?7{~Du5?h2?LW>AOT!;v>cvBe>`T4RrZXeob)E^H`oo@(C_0qX90&=xxMsnLm~wYX}Ch zE5&+@KJ<#9Kvpb4#u5}Mr@;=y=tpk|ip-8BNEfP59i<(`_(AcpzSp`!Q0N0C*mP#R zBq;RAju(wP2#S5A!7;9kQi5W~>AY2+4c94 zC&Hq{M-Zczuo$1In_WT95e$O2FC#`Ay&?#5&Cb6d`Ut|5)4*09`d$-+Ib>&$nFN6< z>Ptd*Uug>p`Wnv}0%cJmnNiG@g;>PPAXKcfU!NS3T9bDap% zjVdLqjH=5Gj8OC^hBq;|GAI;fa#NHgVz#eAPJPjMQ}xQI2{f8xn23w=%*jtokEmQ3 zyuHQd>BYfq$`r$-+4hyKHp+P?gUnO5G8kjiv|=nD8a9S``!WuCm3Lojo&FGegX;%) zAb5Z?2E0ja$7p$`D2uYwt*_&^x)q~}cT--T8Ow)RiqXnJ?`o92ZvSK<$AxVw+{%!= zQucU^5gNt%0Uj`(Gs46Wxl%$gR7J_Pdu7yyUm22D%0Z7Yf}>bJz^8>-8DdvTD29|M z+wFNbRm7EX()7^hY?SQSqa2X>+r%xg9N^yHXGZvKO5*mlSNab|w^@!6Xy8L%n@`{_ zY+sgR?DiNVJ&MIw=#f5wOFB~y{A)k0-WkEWDIVZ0?xvJR$&BoU)Z9`^COy<7^SIa(UGGcujW+^;%1O`L!8`S5-6&{a2aDH>!LJcO9wTz)Iw`^ zpt){^{(lrG>X?k?x>cIBpM*8F*iu{0Z+^QfP^x=pFh>4onX(=+Mh&;@%=xN@A|}Il zg(0Ov_>W)Qk~7p7cJEIa!%c*{TF%?m6-x=@LY4}%S94A#-6rW8mP>L8W zd5nf#U{s??`Dj4aM)IFLUc%TJW2j*le-5egFON2!jC1~?Kx3j4CxQ?{2qA
@@ -17,18 +17,6 @@
@@ -40,10 +28,9 @@
-
+
- - {{error}} + {{ loginError }}
@@ -66,7 +53,7 @@
- +
- +
@@ -103,89 +96,6 @@
- - diff --git a/src/app/authentication/login/login.ts b/src/app/authentication/login/login.ts index 78807e06..9f8681c4 100644 --- a/src/app/authentication/login/login.ts +++ b/src/app/authentication/login/login.ts @@ -1,174 +1,113 @@ -import { Component, Renderer2, importProvidersFrom, inject } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Router, RouterModule } from '@angular/router'; -//import { AuthService } from '../../shared/services/auth.service'; +import { Component, inject } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AuthService } from '../../core/auth/auth.service'; -import { AngularFireAuthModule } from '@angular/fire/compat/auth'; -// import { AuthService } from 'src/app/shared/services/auth.service'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { AngularFireModule, FIREBASE_OPTIONS } from '@angular/fire/compat'; -import { AngularFirestoreModule } from '@angular/fire/compat/firestore'; -import { AngularFireDatabaseModule } from '@angular/fire/compat/database'; -import { environment } from '../../../environments/environment'; -import { FirebaseService } from '../../shared/services/firebase.service'; -import { ToastrModule, ToastrService } from 'ngx-toastr'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr'; +import { finalize, switchMap, tap } from 'rxjs'; +import { AppContextService } from '../../core/services/app-context.service'; @Component({ selector: 'app-login', standalone: true, - imports: [RouterModule, AngularFireAuthModule, FormsModule, ReactiveFormsModule, AngularFireModule, - AngularFireDatabaseModule, - AngularFirestoreModule, ToastrModule - ], - - providers: [FirebaseService, { provide: ToastrService, useClass: ToastrService }], - + imports: [RouterModule, ReactiveFormsModule], templateUrl: './login.html', styleUrl: './login.scss' }) export class Login { - // public showPassword = false; - public angularLoginForm!: FormGroup; - public firebaseLoginForm!: FormGroup; + private readonly formBuilder = inject(FormBuilder); + private readonly fallbackRoute = '/dashboards/crm'; - firestoreModule: any; - databaseModule: any; - authModule: any; - - disabled = ''; - active: any; - showLoader: boolean | undefined; + public readonly adminLoginForm = this.formBuilder.nonNullable.group({ + username: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + rememberMe: [false] + }); + public isSubmitting = false; + public loginError = ''; public visibilityMap: Record = { - Angular: false, - Firebase: false + Angular: false }; public iconMap: Record = { - Angular: 'fe fe-eye-off', - Firebase: 'fe fe-eye-off' + Angular: 'fe fe-eye-off' }; + constructor( public authservice: AuthService, + private appContextService: AppContextService, + private route: ActivatedRoute, private router: Router, - private formBuilder: FormBuilder, - private renderer: Renderer2, - private firebaseService: FirebaseService, private toastr: ToastrService - ) { - - // AngularFireModule.initializeApp(environment.firebase); - - const bodyElement = this.renderer.selectRootElement('body', true); - // this.renderer.setAttribute(bodyElement, 'class', 'cover1 justify-center'); - } - ngOnInit(): void { - this.angularLoginForm = this.formBuilder.group({ - username: ['spruko@admin.com', [Validators.required, Validators.email]], - password: ['sprukoadmin', Validators.required] - }); - - this.firebaseLoginForm = this.formBuilder.group({ - username: ['spruko@admin.com', [Validators.required, Validators.email]], - password: ['sprukoadmin', Validators.required] - }); - - this.firestoreModule = this.firebaseService.getFirestore(); - this.databaseModule = this.firebaseService.getDatabase(); - this.authModule = this.firebaseService.getAuth(); - } - - email = 'spruko@admin.com'; - password = 'sprukoadmin'; - errorMessage = ''; // validation _error handle - _error: { name: string; message: string } = { name: '', message: '' }; // for firbase _error handle - - clearErrorMessage() { - this.errorMessage = ''; - this._error = { name: '', message: '' }; - } + ) {} login() { - this.clearErrorMessage(); - if (this.angularLoginForm.invalid) { - this.angularLoginForm.markAllAsTouched(); + this.loginError = ''; + + if (this.adminLoginForm.invalid) { + this.adminLoginForm.markAllAsTouched(); return; } - const { username, password } = this.angularLoginForm.getRawValue(); - - this.authservice.login({email: username,password}).subscribe({ - next: () => { - this.router.navigate(['/dashboards/crm']); - this.toastr.success('Login successful', 'Ynex'); - }, - error: (error) => { - console.log('Login error:', error); - this.toastr.error(error?.error?.detail || 'Invalid details', 'Ynex'); - } - }); - - } - - validateForm(email: string, password: string): boolean { - if (!email) { - this.errorMessage = 'Please enter email ID'; - return false; + // if login is already in progress, prevent multiple submissions + if (this.isSubmitting) { + return; } - if (!password) { - this.errorMessage = 'Please enter password'; - return false; - } - if (password.length < 6) { - this.errorMessage = 'Password should be at least 6 characters'; - return false; - } - return true; - } + this.isSubmitting = true; + let loginSucceeded = false; - //angular - public loginForm!: FormGroup; - public error: any = ''; + const { username, password, rememberMe } = this.adminLoginForm.getRawValue(); - get form() { - return this.loginForm.controls; - } - - Submit() { - const { username, password } = this.angularLoginForm.controls; - if ( - username.value === 'spruko@admin.com' && password.value === 'sprukoadmin' - ) { - this.router.navigate(['/dashboards/crm']); - this.toastr.success('login successful', 'ynex', { - timeOut: 3000, - positionClass: 'toast-top-right', + this.authservice.login({ email: username, password }, !!rememberMe) + .pipe( + tap(() => { + loginSucceeded = true; + }), + switchMap(() => this.appContextService.ensureMenuInitialized(true)), + finalize(() => { + this.isSubmitting = false; + }) + ) + .subscribe({ + next: () => { + void this.router.navigateByUrl(this.getSafeReturnUrl()); + this.toastr.success('Login successful', username); + }, + error: (error) => { + if (loginSucceeded) { + this.authservice.logout(); + this.loginError = 'Unable to load application context.'; + } else { + this.loginError = error?.error?.detail || 'Invalid details'; + } + this.toastr.error(this.loginError, username); + } }); - } else { - this.error = 'Please check email and passowrd'; - this.toastr.error('Invalid details', 'ynex', { - timeOut: 3000, - positionClass: 'toast-top-right', - - }); - } - } - - - ngOnDestroy(): void { - const bodyElement = this.renderer.selectRootElement('body', true); - this.renderer.removeAttribute(bodyElement, 'class'); - } - showPassword = false; - toggleClass = "ri-eye-off-line"; toggleVisibility(tab: string): void { this.visibilityMap[tab] = !this.visibilityMap[tab]; this.iconMap[tab] = this.visibilityMap[tab] ? 'fe fe-eye' : 'fe fe-eye-off'; } + private getSafeReturnUrl(): string { + const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim() ?? ''; + if (!returnUrl) { + return this.fallbackRoute; + } + + const lowerReturnUrl = returnUrl.toLowerCase(); + const isSafeInternalUrl = + returnUrl.startsWith('/') && + !returnUrl.startsWith('//') && + !returnUrl.includes('\\') && + !lowerReturnUrl.includes('http://') && + !lowerReturnUrl.includes('https://'); + + return isSafeInternalUrl ? returnUrl : this.fallbackRoute; + } } diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 292192c7..25a123f3 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,37 +1,43 @@ -import { Injectable, inject, signal } from '@angular/core'; -import { BehaviorSubject, Observable, catchError, map, throwError } from 'rxjs'; +import { computed, Injectable, inject, signal } from '@angular/core'; +import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model'; import { API_CONFIG } from '../config/api.config'; import { TokenStorageService } from './token-storage.service'; +import { AppContextService } from '../services/app-context.service'; @Injectable({ providedIn: 'root' }) export class AuthService { private readonly http = inject(HttpClient); private readonly tokenStorage = inject(TokenStorageService); + private readonly appContextService = inject(AppContextService); - private readonly userSubject = new BehaviorSubject(this.tokenStorage.getUser()); + private readonly userSubject = new BehaviorSubject(null); readonly user$ = this.userSubject.asObservable(); - readonly currentUserSignal = signal(this.tokenStorage.getUser()); + readonly currentUserSignal = signal(null); + private readonly accessTokenSignal = signal(null); + readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal()); - private isRefreshing = false; - private refreshPromise: Observable | null = null; + private refreshRequest$: Observable | null = null; - login(payload: LoginRequest): Observable { + constructor() { + this.restoreAuthState(); + } + + login(payload: LoginRequest, rememberMe: boolean): Observable { return this.http.post(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/login`, payload).pipe( map((response) => { - const user = this.buildUserFromResponse(response); - this.tokenStorage.saveAuth(response); - this.userSubject.next(user); - this.currentUserSignal.set(user); + const user = this.normalizeUserProfile(response); + this.tokenStorage.saveAuth(response, rememberMe); + this.setAuthState(user, this.tokenStorage.getAccessToken()); return response; }) ); } refreshAccessToken(): Observable { - if (this.isRefreshing && this.refreshPromise) { - return this.refreshPromise; + if (this.refreshRequest$) { + return this.refreshRequest$; } const refreshToken = this.tokenStorage.getRefreshToken(); @@ -40,68 +46,88 @@ export class AuthService { return throwError(() => new HttpErrorResponse({ status: 401, statusText: 'Refresh token missing' })); } - this.isRefreshing = true; - this.refreshPromise = this.http.post(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe( + this.refreshRequest$ = this.http.post(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe( map((response) => { - const user = this.buildUserFromResponse(response); - this.tokenStorage.saveAuth(response); - this.userSubject.next(user); - this.currentUserSignal.set(user); + const user = this.normalizeUserProfile(response, this.currentUserSignal()); + const storageType = this.tokenStorage.getStorageType(); + const rememberMe = storageType === 'local'; + this.tokenStorage.saveAuth(response, rememberMe); + this.setAuthState(user, this.tokenStorage.getAccessToken()); return response; }), catchError((error) => { this.logout(); return throwError(() => error); - }) + }), + finalize(() => { + this.refreshRequest$ = null; + }), + shareReplay(1) ); - return this.refreshPromise.pipe( - map((response) => { - this.isRefreshing = false; - this.refreshPromise = null; - return response; - }), - catchError((error) => { - this.isRefreshing = false; - this.refreshPromise = null; - return throwError(() => error); - }) - ); + return this.refreshRequest$; } logout(): void { this.tokenStorage.clearAuth(); - this.userSubject.next(null); - this.currentUserSignal.set(null); - this.isRefreshing = false; - this.refreshPromise = null; + this.appContextService.clearContext(); + this.setAuthState(null, null); + this.refreshRequest$ = null; } get accessToken(): string | null { return this.tokenStorage.getAccessToken(); } + get refreshToken(): string | null { + return this.tokenStorage.getRefreshToken(); + } + get isAuthenticated(): boolean { - return !!this.accessToken; + return this.isAuthenticatedSignal(); + } + + get isLoggedIn(): boolean { + return this.isAuthenticatedSignal(); } get currentUser(): UserProfile | null { return this.currentUserSignal(); } - private buildUserFromResponse(response: LoginResponse): UserProfile | null { + private restoreAuthState(): void { + const storedUser = this.tokenStorage.getUser(); + const storedAccessToken = this.tokenStorage.getAccessToken(); + + if (storedUser && storedAccessToken) { + this.setAuthState(storedUser, storedAccessToken); + return; + } + + this.setAuthState(null, storedAccessToken ?? null); + } + + private setAuthState(user: UserProfile | null, token: string | null): void { + this.userSubject.next(user); + this.currentUserSignal.set(user); + this.accessTokenSignal.set(token); + } + + private normalizeUserProfile(response: LoginResponse, fallbackUser: UserProfile | null = null): UserProfile | null { if (response.user) { return response.user; } - if (response.userId || response.email) { + const userId = response.userId?.trim(); + const email = response.email?.trim(); + if (userId || email) { return { - id: response.userId ?? '', - email: response.email ?? '', + id: userId ?? fallbackUser?.id ?? '', + email: email ?? fallbackUser?.email ?? '', roles: response.roles, }; } - return this.tokenStorage.getUser(); + return fallbackUser; } } diff --git a/src/app/core/auth/token-storage.service.ts b/src/app/core/auth/token-storage.service.ts index e79393b7..1bc3ab7d 100644 --- a/src/app/core/auth/token-storage.service.ts +++ b/src/app/core/auth/token-storage.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { LoginResponse, UserProfile } from '../models/auth.model'; +type StorageType = 'local' | 'session'; + @Injectable({ providedIn: 'root' }) export class TokenStorageService { private readonly accessTokenKey = 'master-admin-access-token'; @@ -9,58 +11,129 @@ export class TokenStorageService { private readonly refreshTokenExpiresOnKey = 'master-admin-refresh-token-expires-on'; private readonly userKey = 'master-admin-user'; - saveAuth(response: LoginResponse): void { + saveAuth(response: LoginResponse, rememberMe: boolean): void { + this.clearAuth(); + const selectedStorage = rememberMe ? localStorage : sessionStorage; + if (response.accessToken) { - localStorage.setItem(this.accessTokenKey, response.accessToken); + selectedStorage.setItem(this.accessTokenKey, response.accessToken); } if (response.refreshToken) { - localStorage.setItem(this.refreshTokenKey, response.refreshToken); + selectedStorage.setItem(this.refreshTokenKey, response.refreshToken); } if (response.accessTokenExpiresOn) { - localStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn); + selectedStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn); } if (response.refreshTokenExpiresOn) { - localStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn); + selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn); } const user = this.buildUserProfile(response); if (user) { - localStorage.setItem(this.userKey, JSON.stringify(user)); + selectedStorage.setItem(this.userKey, JSON.stringify(user)); } } clearAuth(): void { - localStorage.removeItem(this.accessTokenKey); - localStorage.removeItem(this.refreshTokenKey); - localStorage.removeItem(this.accessTokenExpiresOnKey); - localStorage.removeItem(this.refreshTokenExpiresOnKey); - localStorage.removeItem(this.userKey); + this.removeFromStorage(localStorage); + this.removeFromStorage(sessionStorage); } getAccessToken(): string | null { - return localStorage.getItem(this.accessTokenKey); + return this.getByPriority(this.accessTokenKey); } getRefreshToken(): string | null { - return localStorage.getItem(this.refreshTokenKey); + return this.getByPriority(this.refreshTokenKey); } getAccessTokenExpiresOn(): string | null { - return localStorage.getItem(this.accessTokenExpiresOnKey); + return this.getByPriority(this.accessTokenExpiresOnKey); } getRefreshTokenExpiresOn(): string | null { - return localStorage.getItem(this.refreshTokenExpiresOnKey); + return this.getByPriority(this.refreshTokenExpiresOnKey); } getUser(): UserProfile | null { - const raw = localStorage.getItem(this.userKey); - return raw ? (JSON.parse(raw) as UserProfile) : null; + const raw = this.getByPriority(this.userKey); + + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as UserProfile; + } catch { + this.clearAuth(); + return null; + } } + getStorageType(): 'local' | 'session' | null { + if (this.hasAnyAuthKey(sessionStorage)) { + return 'session'; + } + + if (this.hasAnyAuthKey(localStorage)) { + return 'local'; + } + + return null; + } + + isAccessTokenExpired(): boolean { + return this.isExpired(this.getAccessTokenExpiresOn()); + } + + isRefreshTokenExpired(): boolean { + return this.isExpired(this.getRefreshTokenExpiresOn()); + } + + private getByPriority(key: string): string | null { + const fromSession = sessionStorage.getItem(key); + if (fromSession !== null) { + return fromSession; + } + + return localStorage.getItem(key); + } + + private hasAnyAuthKey(storage: Storage): boolean { + return [ + this.accessTokenKey, + this.refreshTokenKey, + this.accessTokenExpiresOnKey, + this.refreshTokenExpiresOnKey, + this.userKey, + ].some((key) => storage.getItem(key) !== null); + } + + private removeFromStorage(storage: Storage): void { + storage.removeItem(this.accessTokenKey); + storage.removeItem(this.refreshTokenKey); + storage.removeItem(this.accessTokenExpiresOnKey); + storage.removeItem(this.refreshTokenExpiresOnKey); + storage.removeItem(this.userKey); + } + + private isExpired(expiresOn: string | null): boolean { + debugger; + if (!expiresOn) { + return true; + } + + const parsedExpiry = Date.parse(expiresOn); + + if (Number.isNaN(parsedExpiry)) { + return true; + } + + return parsedExpiry <= Date.now(); + } private buildUserProfile(response: LoginResponse): UserProfile | null { if (response.user) { return response.user; diff --git a/src/app/core/config/api.config.ts b/src/app/core/config/api.config.ts index c7d66b9a..2d3be74d 100644 --- a/src/app/core/config/api.config.ts +++ b/src/app/core/config/api.config.ts @@ -6,6 +6,9 @@ export const API_CONFIG = { auth: '/v1/auth', users: '/users', tenants: '/tenants', + currentUserProfile: '/users/me', + tenantContext: '/tenants/current-context', + permissionContext: '/users/me/permissions', masters: '/masters', billing: '/billing', localization: '/localization', diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts index 8d194f48..cb0fab9b 100644 --- a/src/app/core/guards/auth.guard.ts +++ b/src/app/core/guards/auth.guard.ts @@ -1,15 +1,37 @@ import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; +import { CanActivateChildFn, Router } from '@angular/router'; +import { catchError, map, of } from 'rxjs'; import { AuthService } from '../auth/auth.service'; +import { TokenStorageService } from '../auth/token-storage.service'; -export const authGuard: CanActivateFn = () => { +export const authGuard: CanActivateChildFn = (_childRoute, state) => { const authService = inject(AuthService); + const tokenStorage = inject(TokenStorageService); const router = inject(Router); - if (authService.isAuthenticated) { + const loginUrlTree = router.createUrlTree(['/auth/login'], { + queryParams: { returnUrl: state.url }, + }); + + if (!authService.accessToken || !authService.currentUser) { + authService.logout(); + return loginUrlTree; + } + + if (!tokenStorage.isAccessTokenExpired()) { return true; } - router.navigate(['/auth/login']); - return false; + if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) { + authService.logout(); + return loginUrlTree; + } + + return authService.refreshAccessToken().pipe( + map(() => true), + catchError(() => { + authService.logout(); + return of(loginUrlTree); + }) + ); }; diff --git a/src/app/core/guards/guest.guard.ts b/src/app/core/guards/guest.guard.ts new file mode 100644 index 00000000..42629512 --- /dev/null +++ b/src/app/core/guards/guest.guard.ts @@ -0,0 +1,53 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { catchError, map, of } from 'rxjs'; +import { AuthService } from '../auth/auth.service'; +import { TokenStorageService } from '../auth/token-storage.service'; + +const DEFAULT_AUTHENTICATED_REDIRECT = '/dashboards/crm'; + +function resolveSafeReturnUrl(returnUrl: string | null): string { + const candidate = returnUrl?.trim(); + if (!candidate) { + return DEFAULT_AUTHENTICATED_REDIRECT; + } + + const lowerCandidate = candidate.toLowerCase(); + const isSafeInternal = + candidate.startsWith('/') && + !candidate.startsWith('//') && + !lowerCandidate.includes('http://') && + !lowerCandidate.includes('https://'); + + return isSafeInternal ? candidate : DEFAULT_AUTHENTICATED_REDIRECT; +} + +export const guestGuard: CanActivateFn = (route) => { + const authService = inject(AuthService); + const tokenStorage = inject(TokenStorageService); + const router = inject(Router); + + const targetUrl = resolveSafeReturnUrl(route.queryParamMap.get('returnUrl')); + const targetUrlTree = router.createUrlTree([targetUrl]); + + if (!authService.accessToken || !authService.currentUser) { + return true; + } + + if (!tokenStorage.isAccessTokenExpired()) { + return targetUrlTree; + } + + if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) { + authService.logout(); + return true; + } + + return authService.refreshAccessToken().pipe( + map(() => targetUrlTree), + catchError(() => { + authService.logout(); + return of(true); + }) + ); +}; \ No newline at end of file diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 8faa5f00..204c3832 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -5,11 +5,16 @@ import { catchError, switchMap, throwError } from 'rxjs'; import { AuthService } from '../auth/auth.service'; import { API_CONFIG } from '../config/api.config'; +const RETRY_HEADER = 'X-Auth-Retry'; + export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const router = inject(Router); - const isAuthRequest = req.url.includes(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`); + const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`; + const isAuthRequest = req.url.includes(authBaseUrl); + const isRefreshRequest = req.url.includes(`${authBaseUrl}/refresh`); + if (isAuthRequest) { return next(req); } @@ -21,21 +26,34 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { return next(authReq).pipe( catchError((error: HttpErrorResponse) => { - if (error.status !== 401 || !token) { + if (error.status !== 401 || !authService.accessToken || isRefreshRequest || req.headers.has(RETRY_HEADER)) { return throwError(() => error); } return authService.refreshAccessToken().pipe( switchMap(() => { const refreshedToken = authService.accessToken; - const retriedRequest = refreshedToken - ? req.clone({ setHeaders: { Authorization: `Bearer ${refreshedToken}` } }) - : req; + + if (!refreshedToken) { + return throwError(() => error); + } + + const retriedRequest = req.clone({ + setHeaders: { + Authorization: `Bearer ${refreshedToken}`, + [RETRY_HEADER]: 'true', + }, + }); + return next(retriedRequest); }), catchError((refreshError) => { authService.logout(); - router.navigate(['/auth/login']); + const currentUrl = router.url; + const safeReturnUrl = currentUrl.startsWith('/') ? currentUrl : '/'; + void router.navigate(['/auth/login'], { + queryParams: { returnUrl: safeReturnUrl }, + }); return throwError(() => refreshError); }) ); diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index 19a2cb1b..1fd7c0c3 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -2,14 +2,21 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; import { catchError, throwError } from 'rxjs'; +import { API_CONFIG } from '../config/api.config'; export const errorInterceptor: HttpInterceptorFn = (req, next) => { const toastr = inject(ToastrService); + const authBaseUrl = `${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}`; + const isLoginRequest = req.url.includes(`${authBaseUrl}/login`); + const isRefreshRequest = req.url.includes(`${authBaseUrl}/refresh`); return next(req).pipe( catchError((error: HttpErrorResponse) => { - const detail = error.error?.detail ?? error.message ?? 'Unexpected error occurred.'; - toastr.error(detail, 'Request failed'); + if (!isLoginRequest && !isRefreshRequest && error.status !== 401) { + const message = error.error?.detail ?? error.error?.message ?? error.message ?? 'Request failed'; + toastr.error(message, 'Request failed'); + } + return throwError(() => error); }) ); diff --git a/src/app/core/interceptors/loading-interceptor.ts b/src/app/core/interceptors/loading-interceptor.ts index 1006e5c7..5d920d6d 100644 --- a/src/app/core/interceptors/loading-interceptor.ts +++ b/src/app/core/interceptors/loading-interceptor.ts @@ -5,12 +5,21 @@ import { LoadingService } from '../services/loading.service'; export const loadingInterceptor: HttpInterceptorFn = (req, next) => { const loadingService = inject(LoadingService); + const skipLoaderHeader = req.headers.get('X-Skip-Loader'); + const shouldSkipLoader = skipLoaderHeader?.toLowerCase() === 'true'; + const request = req.headers.has('X-Skip-Loader') + ? req.clone({ headers: req.headers.delete('X-Skip-Loader') }) + : req; - loadingService.show(); + if (!shouldSkipLoader) { + loadingService.show(); + } - return next(req).pipe( + return next(request).pipe( finalize(() => { - loadingService.hide(); + if (!shouldSkipLoader) { + loadingService.hide(); + } }) ); }; diff --git a/src/app/core/models/context.model.ts b/src/app/core/models/context.model.ts new file mode 100644 index 00000000..aab308a8 --- /dev/null +++ b/src/app/core/models/context.model.ts @@ -0,0 +1,33 @@ +import { UserProfile } from './auth.model'; +import { Menu } from '../../shared/services/nav.service'; + +export interface CurrentUserContext extends UserProfile { + fullName?: string; + defaultLandingPage?: string; +} + +export interface TenantContext { + tenantId?: string; + companyId?: string; + companyName?: string; + tenantName?: string; + defaultLandingPage?: string; +} + +export interface PermissionContext { + roles: string[]; + permissions: string[]; + defaultLandingPage?: string; +} + +export interface MenuContext { + items: Menu[]; + defaultLandingPage?: string; +} + +export interface AppContextState { + user: CurrentUserContext; + tenant: TenantContext; + permissions: PermissionContext; + menu: MenuContext; +} \ No newline at end of file diff --git a/src/app/core/services/app-context.service.ts b/src/app/core/services/app-context.service.ts new file mode 100644 index 00000000..eaa14305 --- /dev/null +++ b/src/app/core/services/app-context.service.ts @@ -0,0 +1,104 @@ +import { Injectable, inject } from '@angular/core'; +import { finalize, forkJoin, Observable, of, shareReplay, tap } from 'rxjs'; +import { AppContextState, MenuContext } from '../models/context.model'; +import { MenuService } from './menu.service'; +import { PermissionService } from './permission.service'; +import { TenantContextService } from './tenant-context.service'; +import { UserContextService } from './user-context.service'; +import { NavService } from '../../shared/services/nav.service'; + +@Injectable({ providedIn: 'root' }) +export class AppContextService { + private readonly userContextService = inject(UserContextService); + private readonly tenantContextService = inject(TenantContextService); + private readonly permissionService = inject(PermissionService); + private readonly menuService = inject(MenuService); + private readonly navService = inject(NavService); + + private loadRequest$: Observable | null = null; + private menuLoadRequest$: Observable | null = null; + private loaded = false; + + ensureMenuInitialized(forceReload = false): Observable { + const currentMenu = this.menuService.menuContext(); + if (currentMenu && !forceReload) { + this.navService.setMenuItems(this.menuService.getNavigationMenu()); + return of(currentMenu); + } + + if (this.menuLoadRequest$ && !forceReload) { + return this.menuLoadRequest$; + } + + this.menuLoadRequest$ = this.menuService.loadMenu().pipe( + tap(() => { + this.navService.setMenuItems(this.menuService.getNavigationMenu()); + }), + finalize(() => { + this.menuLoadRequest$ = null; + }), + shareReplay(1) + ); + + return this.menuLoadRequest$; + } + + loadAppContext(forceReload = false): Observable { + if (this.loaded && !forceReload) { + return of(this.getCurrentState()); + } + + if (this.loadRequest$ && !forceReload) { + return this.loadRequest$; + } + + this.loadRequest$ = forkJoin({ + user: this.userContextService.loadCurrentUserProfile(), + tenant: this.tenantContextService.loadTenantContext(), + permissions: this.permissionService.loadPermissions(), + menu: this.menuService.loadMenu(), + }).pipe( + tap((context) => { + this.navService.setMenuItems(this.menuService.getNavigationMenu()); + this.loaded = true; + }), + finalize(() => { + this.loadRequest$ = null; + }), + shareReplay(1) + ); + + return this.loadRequest$; + } + + ensureContextLoaded(): Observable { + return this.loadAppContext(); + } + + clearContext(): void { + this.userContextService.clear(); + this.tenantContextService.clear(); + this.permissionService.clear(); + this.menuService.clear(); + this.navService.clearMenuItems(); + this.loaded = false; + this.loadRequest$ = null; + } + + getDefaultLandingPage(): string { + return this.userContextService.currentUserContext()?.defaultLandingPage + ?? this.permissionService.permissionContext()?.defaultLandingPage + ?? this.tenantContextService.tenantContext()?.defaultLandingPage + ?? this.menuService.getDefaultLandingPage() + ?? '/dashboards/crm'; + } + + private getCurrentState(): AppContextState { + return { + user: this.userContextService.currentUserContext() ?? { id: '', email: '', roles: [] }, + tenant: this.tenantContextService.tenantContext() ?? {}, + permissions: this.permissionService.permissionContext() ?? { roles: [], permissions: [] }, + menu: this.menuService.menuContext() ?? { items: [] }, + }; + } +} \ No newline at end of file diff --git a/src/app/core/services/menu.data.ts b/src/app/core/services/menu.data.ts new file mode 100644 index 00000000..60b32306 --- /dev/null +++ b/src/app/core/services/menu.data.ts @@ -0,0 +1,66 @@ +import { MenuContext } from '../models/context.model'; + +export const SAAS_MENU_DATA: MenuContext = { + defaultLandingPage: '/dashboards/crm', + items: [ + { headTitle: 'MAIN' }, + { + title: 'Dashboards', + icon: '', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false }, + ], + }, + { headTitle: 'SAAS ADMIN' }, + { + title: 'Management', + icon: '', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/tenants', title: 'Tenants', type: 'link', dirchange: false }, + { path: '/users', title: 'Users', type: 'link', dirchange: false }, + { + title: 'Configuration', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/global-masters', title: 'Global Masters', type: 'link', dirchange: false }, + { path: '/localization', title: 'Localization', type: 'link', dirchange: false }, + { + title: 'Branding', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/theming', title: 'Theming', type: 'link', dirchange: false }, + { path: '/platform', title: 'Platform', type: 'link', dirchange: false }, + ], + }, + ], + }, + ], + }, + { + title: 'Operations', + icon: '', + type: 'sub', + active: false, + selected: false, + dirchange: false, + children: [ + { path: '/billing', title: 'Billing', type: 'link', dirchange: false }, + { path: '/monitoring', title: 'Monitoring', type: 'link', dirchange: false }, + ], + }, + ], +}; \ No newline at end of file diff --git a/src/app/core/services/menu.service.ts b/src/app/core/services/menu.service.ts new file mode 100644 index 00000000..51fc72e7 --- /dev/null +++ b/src/app/core/services/menu.service.ts @@ -0,0 +1,42 @@ +import { Injectable, signal } from '@angular/core'; +import { Observable, of, tap } from 'rxjs'; +import { MenuContext } from '../models/context.model'; +import { SAAS_MENU_DATA } from './menu.data'; +import { Menu } from '../../shared/services/nav.service'; + +@Injectable({ providedIn: 'root' }) +export class MenuService { + readonly menuContext = signal(null); + + loadMenu(): Observable { + const context = this.cloneMenuContext(SAAS_MENU_DATA); + return of(context).pipe( + tap((menuContext) => { + this.menuContext.set(menuContext); + }) + ); + } + + getNavigationMenu(): Menu[] { + return this.cloneMenuItems(this.menuContext()?.items ?? []); + } + + getDefaultLandingPage(): string | null { + return this.menuContext()?.defaultLandingPage ?? null; + } + + clear(): void { + this.menuContext.set(null); + } + + private cloneMenuContext(context: MenuContext): MenuContext { + return { + defaultLandingPage: context.defaultLandingPage, + items: this.cloneMenuItems(context.items), + }; + } + + private cloneMenuItems(items: Menu[]): Menu[] { + return JSON.parse(JSON.stringify(items)) as Menu[]; + } +} \ No newline at end of file diff --git a/src/app/core/services/permission.service.ts b/src/app/core/services/permission.service.ts new file mode 100644 index 00000000..773ee393 --- /dev/null +++ b/src/app/core/services/permission.service.ts @@ -0,0 +1,34 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import { map, Observable, tap } from 'rxjs'; +import { API_CONFIG } from '../config/api.config'; +import { PermissionContext } from '../models/context.model'; + +interface PermissionResponse { + roles?: string[]; + permissions?: string[]; + defaultLandingPage?: string; +} + +@Injectable({ providedIn: 'root' }) +export class PermissionService { + private readonly http = inject(HttpClient); + readonly permissionContext = signal(null); + + loadPermissions(): Observable { + return this.http.get(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.permissionContext}`).pipe( + map((response) => ({ + roles: response.roles ?? [], + permissions: response.permissions ?? [], + defaultLandingPage: response.defaultLandingPage?.trim() || undefined, + })), + tap((context) => { + this.permissionContext.set(context); + }) + ); + } + + clear(): void { + this.permissionContext.set(null); + } +} \ No newline at end of file diff --git a/src/app/core/services/session-timeout.service.ts b/src/app/core/services/session-timeout.service.ts new file mode 100644 index 00000000..7a20fd76 --- /dev/null +++ b/src/app/core/services/session-timeout.service.ts @@ -0,0 +1,154 @@ +import { DOCUMENT } from '@angular/common'; +import { DestroyRef, effect, inject, Injectable, OnDestroy, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router } from '@angular/router'; +import { merge, fromEvent, Subscription } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; +import { environment } from '../../../environments/environment'; +import { AuthService } from '../auth/auth.service'; + +@Injectable() +export class SessionTimeoutService implements OnDestroy { + private readonly document = inject(DOCUMENT); + private readonly router = inject(Router); + private readonly authService = inject(AuthService); + private readonly destroyRef = inject(DestroyRef); + + readonly showWarning = signal(false); + readonly remainingSeconds = signal(0); + + private readonly warningAfterMs = environment.sessionTimeout?.warningAfterMs ?? 25 * 60 * 1000; + private readonly logoutAfterMs = environment.sessionTimeout?.logoutAfterMs ?? 30 * 60 * 1000; + + private activitySubscription: Subscription | null = null; + private warningTimer: ReturnType | null = null; + private logoutTimer: ReturnType | null = null; + private countdownTimer: ReturnType | null = null; + private logoutDeadline = 0; + private trackingEnabled = false; + + constructor() { + effect(() => { + if (this.authService.currentUserSignal()) { + this.start(); + return; + } + + this.stop(); + }); + } + + start(): void { + if (this.trackingEnabled) { + this.resetTimers(); + return; + } + + this.trackingEnabled = true; + this.bindActivityTracking(); + this.resetTimers(); + } + + stop(): void { + this.trackingEnabled = false; + this.showWarning.set(false); + this.remainingSeconds.set(0); + this.clearTimers(); + this.activitySubscription?.unsubscribe(); + this.activitySubscription = null; + } + + staySignedIn(): void { + if (!this.trackingEnabled) { + return; + } + + this.resetTimers(); + } + + logoutNow(): void { + this.handleTimeoutLogout(); + } + + ngOnDestroy(): void { + this.stop(); + } + + private bindActivityTracking(): void { + if (this.activitySubscription) { + return; + } + + this.activitySubscription = merge( + fromEvent(this.document, 'mousemove'), + fromEvent(this.document, 'keydown'), + fromEvent(this.document, 'click'), + fromEvent(window, 'scroll') + ) + .pipe(throttleTime(1000), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + if (!this.trackingEnabled || !this.authService.currentUserSignal()) { + return; + } + + this.resetTimers(); + }); + } + + private resetTimers(): void { + if (!this.trackingEnabled) { + return; + } + + this.clearTimers(); + this.showWarning.set(false); + this.remainingSeconds.set(0); + + const safeWarningDelay = Math.max(Math.min(this.warningAfterMs, this.logoutAfterMs), 0); + const safeLogoutDelay = Math.max(this.logoutAfterMs, 0); + + this.warningTimer = setTimeout(() => { + this.showWarning.set(true); + this.logoutDeadline = Date.now() + Math.max(safeLogoutDelay - safeWarningDelay, 0); + this.updateRemainingSeconds(); + this.countdownTimer = setInterval(() => { + this.updateRemainingSeconds(); + }, 1000); + }, safeWarningDelay); + + this.logoutTimer = setTimeout(() => { + this.handleTimeoutLogout(); + }, safeLogoutDelay); + } + + private updateRemainingSeconds(): void { + const remainingMs = Math.max(this.logoutDeadline - Date.now(), 0); + this.remainingSeconds.set(Math.ceil(remainingMs / 1000)); + } + + private handleTimeoutLogout(): void { + const returnUrl = this.router.url.startsWith('/auth') ? '/' : this.router.url; + this.stop(); + this.authService.logout(); + void this.router.navigate(['/auth/login'], { + queryParams: returnUrl && returnUrl !== '/' ? { returnUrl } : undefined, + }); + } + + private clearTimers(): void { + if (this.warningTimer) { + clearTimeout(this.warningTimer); + this.warningTimer = null; + } + + if (this.logoutTimer) { + clearTimeout(this.logoutTimer); + this.logoutTimer = null; + } + + if (this.countdownTimer) { + clearInterval(this.countdownTimer); + this.countdownTimer = null; + } + } +} diff --git a/src/app/core/services/tenant-context.service.ts b/src/app/core/services/tenant-context.service.ts new file mode 100644 index 00000000..aa9e6f62 --- /dev/null +++ b/src/app/core/services/tenant-context.service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, tap } from 'rxjs'; +import { API_CONFIG } from '../config/api.config'; +import { TenantContext } from '../models/context.model'; + +@Injectable({ providedIn: 'root' }) +export class TenantContextService { + private readonly http = inject(HttpClient); + readonly tenantContext = signal(null); + + loadTenantContext(): Observable { + return this.http.get(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`).pipe( + tap((context) => { + this.tenantContext.set(context); + }) + ); + } + + clear(): void { + this.tenantContext.set(null); + } +} \ No newline at end of file diff --git a/src/app/core/services/user-context.service.ts b/src/app/core/services/user-context.service.ts new file mode 100644 index 00000000..632f1511 --- /dev/null +++ b/src/app/core/services/user-context.service.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import { map, Observable, tap } from 'rxjs'; +import { API_CONFIG } from '../config/api.config'; +import { CurrentUserContext } from '../models/context.model'; + +@Injectable({ providedIn: 'root' }) +export class UserContextService { + private readonly http = inject(HttpClient); + readonly currentUserContext = signal(null); + + loadCurrentUserProfile(): Observable { + return this.http.get>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.currentUserProfile}`).pipe( + map((response) => ({ + id: response.id?.trim() ?? '', + email: response.email?.trim() ?? '', + displayName: response.displayName?.trim() || undefined, + fullName: response.fullName?.trim() || undefined, + tenantId: response.tenantId?.trim() || undefined, + roles: response.roles ?? [], + defaultLandingPage: response.defaultLandingPage?.trim() || undefined, + })), + tap((profile) => { + this.currentUserContext.set(profile); + }) + ); + } + + clear(): void { + this.currentUserContext.set(null); + } +} \ No newline at end of file diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.html b/src/app/features/tenants/pages/tenant-list/tenant-list.html index 9136da4e..3e622b7f 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.html +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.html @@ -1,4 +1,18 @@ -
-

Tenants

-

Tenant list, detail tabs, and the create wizard will be added here.

-
+ + diff --git a/src/app/features/tenants/pages/tenant-list/tenant-list.ts b/src/app/features/tenants/pages/tenant-list/tenant-list.ts index 8cd6f080..231ef738 100644 --- a/src/app/features/tenants/pages/tenant-list/tenant-list.ts +++ b/src/app/features/tenants/pages/tenant-list/tenant-list.ts @@ -1,11 +1,89 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { DataTable } from '../../../../shared/components/data-table/data-table'; +import { DataTableColumn, DataTableAction } from '../../../../shared/components/data-table/data-table.types'; @Component({ selector: 'app-tenant-list', standalone: true, - imports: [CommonModule], + imports: [CommonModule, DataTable], templateUrl: './tenant-list.html', styleUrl: './tenant-list.scss', }) -export class TenantList {} +export class TenantList { + + loading = false; + pageIndex = 1; + pageSize = 10; + totalRecords = 3; + + columns: DataTableColumn[] = [ + { key: 'id', label: 'Id', sortable: true, headerClass: 'text-center', cellClass: 'text-center' }, + { key: 'tenantName', label: 'Tenant Name', sortable: true }, + { key: 'companyCode', label: 'Company Code', sortable: true }, + { key: 'email', label: 'Email' }, + { key: 'country', label: 'Country', sortable: true }, + { key: 'status', label: 'Status', sortable: true, cellClass: 'text-center' } + ]; + + tenants = [ + { + id: 1, + tenantName: 'Syscom Corporation', + companyCode: 'SYSCOM', + email: 'admin@syscom.com', + country: 'UAE', + status: 'Active' + }, + { + id: 2, + tenantName: 'Biz360 Demo', + companyCode: 'BIZ360', + email: 'demo@biz360.com', + country: 'India', + status: 'Active' + }, + { + id: 3, + tenantName: 'Test Tenant', + companyCode: 'TEST', + email: 'test@test.com', + country: 'India', + status: 'Inactive' + } + ]; + + tableActions: DataTableAction[] = [ + { type: 'view', label: 'View', icon: 'ri-eye-line', className: 'btn-light' }, + { type: 'edit', label: 'Edit', icon: 'ri-edit-line', className: 'btn-primary' }, + { + type: 'delete', + label: 'Delete', + icon: 'ri-delete-bin-line', + className: 'btn-danger', + visible: row => row.status !== 'Active' + } + ]; + + onSearch(searchText: string): void { + console.log('Search:', searchText); + } + + onPageChange(event: any): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + console.log('Page:', event); + } + + onSortChange(event: any): void { + console.log('Sort:', event); + } + + onTableAction(event: any): void { + console.log('Action:', event.action.type, event.row); + } + + onRowClick(row: any): void { + console.log('Row clicked:', row); + } +} diff --git a/src/app/shared/components/data-table/data-table.html b/src/app/shared/components/data-table/data-table.html index 06221b34..857c37d6 100644 --- a/src/app/shared/components/data-table/data-table.html +++ b/src/app/shared/components/data-table/data-table.html @@ -1 +1,129 @@ -

data-table works!

+
+
+
{{ title() }}
+ + @if (showSearch()) { +
+ +
+ } +
+ +
+
+ + + + @for (column of columns(); track column.key) { + + } + + @if (actions().length > 0) { + + } + + + + + @if (loading()) { + + + + } @else if (rows().length === 0) { + + + + } @else { + @for (row of rows(); track $index) { + + @for (column of columns(); track column.key) { + + } + + @if (actions().length > 0) { + + } + + } + } + +
+ + {{ column.label }} + + @if (column.sortable) { + + } + + Actions
+ Loading... +
+ No records found +
+ {{ getCellValue(row, column.key.toString()) }} + +
+ @for (action of actions(); track action.type) { + @if (isActionVisible(action, row)) { + + } + } +
+
+
+
+ + +
\ No newline at end of file diff --git a/src/app/shared/components/data-table/data-table.ts b/src/app/shared/components/data-table/data-table.ts index cd9710f3..ca3d3ba5 100644 --- a/src/app/shared/components/data-table/data-table.ts +++ b/src/app/shared/components/data-table/data-table.ts @@ -1,11 +1,110 @@ -import { Component } from '@angular/core'; +import { Component, computed, input, output, signal } from '@angular/core'; +import { DataTableAction, DataTableActionEvent, DataTableColumn, DataTablePageEvent, DataTableSortEvent } from './data-table.types'; @Component({ selector: 'data-table', imports: [], templateUrl: './data-table.html', styleUrl: './data-table.scss', + standalone: true }) -export class DataTable { +export class DataTable = any> { + columns = input[]>([]); + rows = input([]); + actions = input[]>([]); + + + loading = input(false); + totalRecords = input(0); + pageIndex = input(1); + pageSize = input(10); + title = input(''); + showSearch = input(false); + searchPlaceholder = input('Search...'); + + searchChanged = output(); + pageChanged = output(); + sortChanged = output(); + actionClicked = output>(); + rowClicked = output(); + + sortColumn = signal(''); + sortDirection = signal<'asc' | 'desc'>('asc'); + + tableClass = input('table whitespace-nowrap min-w-full'); + tableHeadClass = input(''); + tableBodyClass = input(''); + trHeadClass = input('border-b border-defaultborder'); + trBodyClass = input('border-b border-defaultborder hover:bg-light cursor-pointer'); + + defaultThClass = input('text-start'); + defaultTdClass = input(''); + actionHeaderClass = input('text-center'); + actionCellClass = input('text-center'); + + totalPages = computed(() => { + return Math.ceil(this.totalRecords() / this.pageSize()) || 1; + }); + + startRecord = computed(() => { + if (!this.totalRecords()) return 0; + return (this.pageIndex() - 1) * this.pageSize() + 1; + }); + + endRecord = computed(() => { + return Math.min(this.pageIndex() * this.pageSize(), this.totalRecords()); + }); + + getCellValue(row: T, key: string): unknown { + return row[key] ?? '-'; + } + + onSearch(value: string): void { + this.searchChanged.emit(value); + } + + onSort(column: DataTableColumn): void { + if (!column.sortable) return; + + const key = String(column.key); + + if (this.sortColumn() === key) { + this.sortDirection.update(value => value === 'asc' ? 'desc' : 'asc'); + } else { + this.sortColumn.set(key); + this.sortDirection.set('asc'); + } + + this.sortChanged.emit({ + column: this.sortColumn(), + direction: this.sortDirection() + }); + } + + onPageChange(pageIndex: number): void { + if (pageIndex < 1 || pageIndex > this.totalPages()) return; + + this.pageChanged.emit({ + pageIndex, + pageSize: this.pageSize() + }); + } + + onActionClick(event: MouseEvent, action: DataTableAction, row: T): void { + event.stopPropagation(); + + this.actionClicked.emit({ + action, + row + }); + } + + isActionVisible(action: DataTableAction, row: T): boolean { + return action.visible ? action.visible(row) : true; + } + + onRowClick(row: T): void { + this.rowClicked.emit(row); + } } diff --git a/src/app/shared/components/data-table/data-table.types.ts b/src/app/shared/components/data-table/data-table.types.ts new file mode 100644 index 00000000..06b9e284 --- /dev/null +++ b/src/app/shared/components/data-table/data-table.types.ts @@ -0,0 +1,35 @@ +export type DataTableActionType = 'view' | 'edit' | 'delete' | string; + +export interface DataTableColumn { + key: keyof T | string; + label: string; + sortable?: boolean; + width?: string; + align?: 'left' | 'center' | 'right'; + + headerClass?: string; + cellClass?: string; +} + +export interface DataTableAction { + type: DataTableActionType; + label: string; + icon?: string; + className?: string; + visible?: (row: T) => boolean; +} + +export interface DataTablePageEvent { + pageIndex: number; + pageSize: number; +} + +export interface DataTableSortEvent { + column: string; + direction: 'asc' | 'desc'; +} + +export interface DataTableActionEvent { + action: DataTableAction; + row: T; +} \ No newline at end of file diff --git a/src/app/shared/components/sidebar/sidebar.html b/src/app/shared/components/sidebar/sidebar.html index 2e7e80c5..ec13545c 100644 --- a/src/app/shared/components/sidebar/sidebar.html +++ b/src/app/shared/components/sidebar/sidebar.html @@ -3,12 +3,25 @@
@@ -181,7 +194,9 @@ } {{ menuItem.title }} @if(menuItem.badgeValue){ - {{menuItem.badgeValue }} + {{menuItem.badgeValue + }} } @@ -198,4 +213,4 @@
- + \ No newline at end of file diff --git a/src/app/shared/layouts/authentication-layout/authentication-layout.html b/src/app/shared/layouts/authentication-layout/authentication-layout.html index ef16bfb2..f2dc9c19 100644 --- a/src/app/shared/layouts/authentication-layout/authentication-layout.html +++ b/src/app/shared/layouts/authentication-layout/authentication-layout.html @@ -1,2 +1,2 @@ - + diff --git a/src/app/shared/layouts/authentication-layout/authentication-layout.ts b/src/app/shared/layouts/authentication-layout/authentication-layout.ts index 90c01a12..85f07795 100644 --- a/src/app/shared/layouts/authentication-layout/authentication-layout.ts +++ b/src/app/shared/layouts/authentication-layout/authentication-layout.ts @@ -1,12 +1,11 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { AppLoader } from '../../components/app-loader/app-loader'; @Component({ selector: 'app-authentication-layout', templateUrl: './authentication-layout.html', styleUrl: './authentication-layout.scss', - imports: [RouterOutlet, AppLoader] + imports: [RouterOutlet] }) export class AuthenticationLayout { diff --git a/src/app/shared/layouts/content-layout/content-layout.html b/src/app/shared/layouts/content-layout/content-layout.html index fd0464b6..4609eac5 100644 --- a/src/app/shared/layouts/content-layout/content-layout.html +++ b/src/app/shared/layouts/content-layout/content-layout.html @@ -17,3 +17,30 @@
+@if (sessionTimeoutService.showWarning()) { +
+
+
+
+
+ + + +
+

Session Expiring Soon

+

+ You have been inactive. You will be signed out in {{ sessionTimeoutService.remainingSeconds() }} seconds unless you continue your session. +

+
+
+
+ +
+
+
+} + diff --git a/src/app/shared/layouts/content-layout/content-layout.ts b/src/app/shared/layouts/content-layout/content-layout.ts index ebb57e31..dee0a12f 100644 --- a/src/app/shared/layouts/content-layout/content-layout.ts +++ b/src/app/shared/layouts/content-layout/content-layout.ts @@ -10,10 +10,14 @@ import { PageHeader } from '../../components/page-header/page-header'; import { Footer } from '../../components/footer/footer'; import { TabToTop } from '../../components/tab-to-top/tab-to-top'; import { AppLoader } from '../../components/app-loader/app-loader'; +import { SessionTimeoutService } from '../../../core/services/session-timeout.service'; +import { AppContextService } from '../../../core/services/app-context.service'; +import { AuthService } from '../../../core/auth/auth.service'; @Component({ selector: 'app-content-layout', templateUrl: './content-layout.html', styleUrl: './content-layout.scss', + providers: [SessionTimeoutService], imports: [ Switcher, Header, @@ -28,6 +32,9 @@ import { AppLoader } from '../../components/app-loader/app-loader'; }) export class ContentLayout { navServices = inject(NavService); + sessionTimeoutService = inject(SessionTimeoutService); + private readonly appContextService = inject(AppContextService); + private readonly authService = inject(AuthService); private document = inject(DOCUMENT); private appStateService = inject(AppStateService); private elementRef = inject(ElementRef); @@ -36,6 +43,12 @@ export class ContentLayout { public menuItems!: Menu[]; constructor() { + this.sessionTimeoutService.start(); + + if (this.authService.isLoggedIn) { + this.appContextService.ensureMenuInitialized().subscribe(); + } + this.navServices.items.subscribe((menuItems: any) => { this.menuItems = menuItems; }); diff --git a/src/app/shared/routes/auth.routes.ts b/src/app/shared/routes/auth.routes.ts index bc4d7aa4..3e72981a 100644 --- a/src/app/shared/routes/auth.routes.ts +++ b/src/app/shared/routes/auth.routes.ts @@ -1,8 +1,10 @@ import { Routes } from '@angular/router'; +import { guestGuard } from '../../core/guards/guest.guard'; export const authen: Routes = [ { path: 'login', + canActivate: [guestGuard], loadComponent: () => import('../../authentication/login/login').then((m) => m.Login), }, { diff --git a/src/app/shared/services/nav.service.ts b/src/app/shared/services/nav.service.ts index c9703019..450e2863 100644 --- a/src/app/shared/services/nav.service.ts +++ b/src/app/shared/services/nav.service.ts @@ -90,83 +90,13 @@ export class NavService implements OnDestroy { this.screenWidth.next(width); } - MENUITEMS: Menu[] = [ - // Dashboard - { headTitle: 'MAIN' }, - { - title: 'Dashboards', - icon: ``, - type: 'sub', - badgeClass: 'warning/10', - badgeText: 'warning', - badgeValue: '12', - selected: false, - active: false, - dirchange: false, - children: [ - { path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false, }, + items = new BehaviorSubject([]); - ], - }, + setMenuItems(menuItems: Menu[]): void { + this.items.next(menuItems); + } - - - - { - title: 'Error', - type: 'sub', - icon: ``, - active: false, - dirchange: false, - children: [ - - { - path: 'error/error404', - title: 'Error 404', - type: 'link', - dirchange: false, - }, - - ], - }, - - - { - title: 'Nested Menu', - icon: ``, - type: 'sub', - active: false, - children: [ - { - title: 'Nested-1', - dirchange: false, - type: 'empty', - active: false, - selected: false, - path: '/nested-menu/nested-1', - }, - { - title: 'Nested-2', - type: 'sub', - active: false, - children: [ - { - title: 'Nested-2.1', - type: 'empty', - active: false, - }, - { - title: 'Nested-2.2', - type: 'empty', - active: false, - } - ], - }, - ], - }, - - - ]; - - items = new BehaviorSubject(this.MENUITEMS); + clearMenuItems(): void { + this.items.next([]); + } } diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index d911cd6a..6e2bb8b7 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,6 +1,10 @@ export const environment = { production: true, apiBaseUrl: '/api', + sessionTimeout: { + warningAfterMs: 25 * 60 * 1000, + logoutAfterMs: 30 * 60 * 1000, + }, firebase: { apiKey: '********************************', authDomain: '********************************', diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 00168fa9..689c3d01 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -4,6 +4,10 @@ export const environment = { production: false, apiBaseUrl: 'https://localhost:5001/api', + sessionTimeout: { + warningAfterMs: 25 * 60 * 1000, + logoutAfterMs: 30 * 60 * 1000, + }, firebase: { apiKey: '********************************', authDomain: '********************************', diff --git a/src/index.html b/src/index.html index d3174d5d..932c3f5e 100644 --- a/src/index.html +++ b/src/index.html @@ -8,7 +8,7 @@ - +