implement authentication and reusable data table

This commit is contained in:
Gagan7900
2026-07-08 20:47:54 +05:30
parent 66e2c15e51
commit 4cb9b0181e
34 changed files with 1303 additions and 407 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+14 -104
View File
@@ -5,8 +5,8 @@
<div class="xxl:col-span-4 xl:col-span-4 lg:col-span-4 md:col-span-6 sm:col-span-8 col-span-12"> <div class="xxl:col-span-4 xl:col-span-4 lg:col-span-4 md:col-span-6 sm:col-span-8 col-span-12">
<div class="my-[2.5rem] flex justify-center"> <div class="my-[2.5rem] flex justify-center">
<a routerLink="/dashboards/crm"> <a routerLink="/dashboards/crm">
<img src="./assets/images/brand-logos/desktop-logo.png" alt="logo" class="desktop-logo"> <img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-logo">
<img src="./assets/images/brand-logos/desktop-dark.png" alt="logo" class="desktop-dark"> <img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-dark">
</a> </a>
</div> </div>
<div class="box"> <div class="box">
@@ -17,18 +17,6 @@
<nav aria-label="Tabs" role="tablist" aria-orientation="horizontal" <nav aria-label="Tabs" role="tablist" aria-orientation="horizontal"
class="sm:flex rounded-lg transition p-1 "> class="sm:flex rounded-lg transition p-1 ">
<!-- <button
class="w-full sm:w-auto hs-tab-active:bg-primary hs-tab-active:text-gray-700 dark:hs-tab-active:bg-bgdark dark:hs-tab-active:text-white py-2 px-3 inline-flex items-center gap-2 bg-transparent text-sm text-gray-500 hover:text-gray-700 font-medium rounded-md hover:hover:text-primary dark:text-white/70 dark:hover:text-white active"
type="button" id="segment-item-2" aria-selected="true" data-hs-tab="#segment-2" aria-controls="segment-2">
<img class="firebase" src="./assets/images/angular.svg" alt="" />
</button>
<button
class="w-full sm:w-auto hs-tab-active:bg-primary hs-tab-active:text-gray-700 dark:hs-tab-active:bg-bgdark dark:hs-tab-active:text-white py-2 px-3 inline-flex items-center gap-2 bg-transparent text-sm text-gray-500 hover:text-gray-700 font-medium rounded-md hover:hover:text-primary dark:text-white/70 dark:hover:text-white "
type="button" id="segment-item-1" data-hs-tab="#segment-1" aria-controls="segment-1"
aria-selected="false">
<img class="firebase" src="./assets/images/firebase.svg" alt="" />
</button> -->
</nav> </nav>
<div class="mt-3"> <div class="mt-3">
@@ -40,10 +28,9 @@
</div> </div>
<div class="mt-3"> <div class="mt-3">
<form [formGroup]="angularLoginForm" (ngSubmit)="login()"> <form [formGroup]="adminLoginForm" (ngSubmit)="login()">
<div class="text-danger"> <div class="text-danger">
{{ loginError }}
{{error}}
</div> </div>
<div class="grid grid-cols-12 gap-y-4"> <div class="grid grid-cols-12 gap-y-4">
<div class="xl:col-span-12 col-span-12"> <div class="xl:col-span-12 col-span-12">
@@ -66,7 +53,7 @@
</div> </div>
<div class="mt-2"> <div class="mt-2">
<div class="form-check !ps-0"> <div class="form-check !ps-0">
<input class="form-check-input" type="checkbox" value="" id="defaultCheck1"> <input class="form-check-input" type="checkbox" value="" id="defaultCheck1" formControlName="rememberMe">
<label class="form-check-label text-[#8c9097] dark:text-white/50 font-normal" <label class="form-check-label text-[#8c9097] dark:text-white/50 font-normal"
for="defaultCheck1"> for="defaultCheck1">
Remember password ? Remember password ?
@@ -75,9 +62,15 @@
</div> </div>
</div> </div>
<div class="xl:col-span-12 col-span-12 grid mt-2"> <div class="xl:col-span-12 col-span-12 grid mt-2">
<button type="submit" [disabled]=" <button type="submit" [disabled]="adminLoginForm.invalid || isSubmitting"
!angularLoginForm.valid class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium inline-flex items-center justify-center gap-2">
" class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium">Sign In</button> @if (isSubmitting) {
<span class="ti-spinner text-white" role="status" aria-label="loading"></span>
<span>Signing In...</span>
} @else {
<span>Sign In</span>
}
</button>
</div> </div>
</div> </div>
</form> </form>
@@ -103,89 +96,6 @@
</div> </div>
</div> </div>
<div id="segment-1" role="tabpanel" aria-labelledby="segment-item-1" class="hidden">
<form [formGroup]="firebaseLoginForm" (ngSubmit)="login()">
@if(errorMessage.length > 0){
<p class="text-danger">
{{errorMessage}}</p>
}
@if(_error.message.length > 0){
<p class="text-danger">
{{_error.message}} </p>
}
<div class="text-center">
<h1 class="block text-2xl font-bold text-gray-800 dark:text-white">
</h1>
</div>
<div class="mt-3">
<div class="grid grid-cols-12 gap-y-4">
<div class="xl:col-span-12 col-span-12">
<label for="signin-username" class="form-label text-default">User Name</label>
<input type="text" class="form-control form-control-lg w-full !rounded-md" id="signin-username"
placeholder="Username" autocomplete formControlName="username" required>
</div>
<div class="xl:col-span-12 col-span-12 mb-2">
<label for="signin-password" class="form-label text-default block">Password
</label>
<a routerLink="/authentication/reset-password/basic" class="float-end text-danger">Forget
password ?</a>
<div class="input-group">
<input type="password" class="form-control form-control-lg !rounded-s-md" id="signin-password"
placeholder="password" [type]="visibilityMap['Firebase'] ? 'text' : 'password'" autocomplete
formControlName="password" required>
<button aria-label="button" class="ti-btn ti-btn-light !rounded-s-none !mb-0" type="button"
(click)="toggleVisibility('Firebase')" id="button-addon2"><i
class="{{ iconMap['Firebase'] }} align-middle"></i></button>
</div>
<div class="mt-2">
<div class="form-check !ps-0">
<input class="form-check-input" type="checkbox" value="" id="defaultCheck1">
<label class="form-check-label text-[#8c9097] dark:text-white/50 font-normal"
for="defaultCheck1">
Remember password ? v
</label>
</div>
</div>
</div>
<div class="xl:col-span-12 col-span-12 grid mt-2">
<button type="submit" class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium {{
disabled
}}" autofocus
[disabled]="!firebaseLoginForm.valid">Sign In</button>
<!-- <div class="text-danger mt-3"></div> -->
</div>
</div>
<div class="text-center">
<p class="text-[0.75rem] text-[#8c9097] dark:text-white/50 mt-4">Dont have an account? <a
routerLink="/authentication/sign-up/basic" class="text-primary">Sign Up</a></p>
</div>
<div class="text-center my-4 authentication-barrier">
<span>OR</span>
</div>
<div class="btn-list text-center">
<button aria-label="button" type="button" class="ti-btn ti-btn-icon ti-btn-light me-[0.365rem]">
<i class="ri-facebook-line font-bold text-dark opacity-[0.7]"></i>
</button>
<button aria-label="button" type="button" class="ti-btn ti-btn-icon ti-btn-light me-[0.365rem]">
<i class="ri-google-line font-bold text-dark opacity-[0.7]"></i>
</button>
<button aria-label="button" type="button" class="ti-btn ti-btn-icon ti-btn-light">
<i class="ri-twitter-x-line font-bold text-dark opacity-[0.7]"></i>
</button>
</div>
</div>
</form>
</div>
</div> </div>
</div> </div>
+73 -134
View File
@@ -1,174 +1,113 @@
import { Component, Renderer2, importProvidersFrom, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, Validators } from '@angular/forms';
import { Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
//import { AuthService } from '../../shared/services/auth.service';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { AngularFireAuthModule } from '@angular/fire/compat/auth'; import { ReactiveFormsModule } from '@angular/forms';
// import { AuthService } from 'src/app/shared/services/auth.service'; import { ToastrService } from 'ngx-toastr';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { finalize, switchMap, tap } from 'rxjs';
import { AngularFireModule, FIREBASE_OPTIONS } from '@angular/fire/compat'; import { AppContextService } from '../../core/services/app-context.service';
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';
@Component({ @Component({
selector: 'app-login', selector: 'app-login',
standalone: true, standalone: true,
imports: [RouterModule, AngularFireAuthModule, FormsModule, ReactiveFormsModule, AngularFireModule, imports: [RouterModule, ReactiveFormsModule],
AngularFireDatabaseModule,
AngularFirestoreModule, ToastrModule
],
providers: [FirebaseService, { provide: ToastrService, useClass: ToastrService }],
templateUrl: './login.html', templateUrl: './login.html',
styleUrl: './login.scss' styleUrl: './login.scss'
}) })
export class Login { export class Login {
// public showPassword = false; private readonly formBuilder = inject(FormBuilder);
public angularLoginForm!: FormGroup; private readonly fallbackRoute = '/dashboards/crm';
public firebaseLoginForm!: FormGroup;
firestoreModule: any; public readonly adminLoginForm = this.formBuilder.nonNullable.group({
databaseModule: any; username: ['', [Validators.required, Validators.email]],
authModule: any; password: ['', Validators.required],
rememberMe: [false]
disabled = ''; });
active: any; public isSubmitting = false;
showLoader: boolean | undefined; public loginError = '';
public visibilityMap: Record<string, boolean> = { public visibilityMap: Record<string, boolean> = {
Angular: false, Angular: false
Firebase: false
}; };
public iconMap: Record<string, string> = { public iconMap: Record<string, string> = {
Angular: 'fe fe-eye-off', Angular: 'fe fe-eye-off'
Firebase: 'fe fe-eye-off'
}; };
constructor( constructor(
public authservice: AuthService, public authservice: AuthService,
private appContextService: AppContextService,
private route: ActivatedRoute,
private router: Router, private router: Router,
private formBuilder: FormBuilder,
private renderer: Renderer2,
private firebaseService: FirebaseService,
private toastr: ToastrService 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() { login() {
this.clearErrorMessage(); this.loginError = '';
if (this.angularLoginForm.invalid) {
this.angularLoginForm.markAllAsTouched(); if (this.adminLoginForm.invalid) {
this.adminLoginForm.markAllAsTouched();
return; return;
} }
const { username, password } = this.angularLoginForm.getRawValue(); // if login is already in progress, prevent multiple submissions
if (this.isSubmitting) {
this.authservice.login({email: username,password}).subscribe({ return;
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 (!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 const { username, password, rememberMe } = this.adminLoginForm.getRawValue();
public loginForm!: FormGroup;
public error: any = '';
get form() { this.authservice.login({ email: username, password }, !!rememberMe)
return this.loginForm.controls; .pipe(
} tap(() => {
loginSucceeded = true;
Submit() { }),
const { username, password } = this.angularLoginForm.controls; switchMap(() => this.appContextService.ensureMenuInitialized(true)),
if ( finalize(() => {
username.value === 'spruko@admin.com' && password.value === 'sprukoadmin' this.isSubmitting = false;
) { })
this.router.navigate(['/dashboards/crm']); )
this.toastr.success('login successful', 'ynex', { .subscribe({
timeOut: 3000, next: () => {
positionClass: 'toast-top-right', 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 { toggleVisibility(tab: string): void {
this.visibilityMap[tab] = !this.visibilityMap[tab]; this.visibilityMap[tab] = !this.visibilityMap[tab];
this.iconMap[tab] = this.visibilityMap[tab] ? 'fe fe-eye' : 'fe fe-eye-off'; 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;
}
} }
+68 -42
View File
@@ -1,37 +1,43 @@
import { Injectable, inject, signal } from '@angular/core'; import { computed, Injectable, inject, signal } from '@angular/core';
import { BehaviorSubject, Observable, catchError, map, throwError } from 'rxjs'; import { BehaviorSubject, Observable, catchError, finalize, map, shareReplay, throwError } from 'rxjs';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model'; import { LoginRequest, LoginResponse, UserProfile } from '../models/auth.model';
import { API_CONFIG } from '../config/api.config'; import { API_CONFIG } from '../config/api.config';
import { TokenStorageService } from './token-storage.service'; import { TokenStorageService } from './token-storage.service';
import { AppContextService } from '../services/app-context.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly tokenStorage = inject(TokenStorageService); private readonly tokenStorage = inject(TokenStorageService);
private readonly appContextService = inject(AppContextService);
private readonly userSubject = new BehaviorSubject<UserProfile | null>(this.tokenStorage.getUser()); private readonly userSubject = new BehaviorSubject<UserProfile | null>(null);
readonly user$ = this.userSubject.asObservable(); readonly user$ = this.userSubject.asObservable();
readonly currentUserSignal = signal<UserProfile | null>(this.tokenStorage.getUser()); readonly currentUserSignal = signal<UserProfile | null>(null);
private readonly accessTokenSignal = signal<string | null>(null);
readonly isAuthenticatedSignal = computed(() => !!this.accessTokenSignal() && !!this.currentUserSignal());
private isRefreshing = false; private refreshRequest$: Observable<LoginResponse> | null = null;
private refreshPromise: Observable<LoginResponse> | null = null;
login(payload: LoginRequest): Observable<LoginResponse> { constructor() {
this.restoreAuthState();
}
login(payload: LoginRequest, rememberMe: boolean): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/login`, payload).pipe( return this.http.post<LoginResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/login`, payload).pipe(
map((response) => { map((response) => {
const user = this.buildUserFromResponse(response); const user = this.normalizeUserProfile(response);
this.tokenStorage.saveAuth(response); this.tokenStorage.saveAuth(response, rememberMe);
this.userSubject.next(user); this.setAuthState(user, this.tokenStorage.getAccessToken());
this.currentUserSignal.set(user);
return response; return response;
}) })
); );
} }
refreshAccessToken(): Observable<LoginResponse> { refreshAccessToken(): Observable<LoginResponse> {
if (this.isRefreshing && this.refreshPromise) { if (this.refreshRequest$) {
return this.refreshPromise; return this.refreshRequest$;
} }
const refreshToken = this.tokenStorage.getRefreshToken(); const refreshToken = this.tokenStorage.getRefreshToken();
@@ -40,68 +46,88 @@ export class AuthService {
return throwError(() => new HttpErrorResponse({ status: 401, statusText: 'Refresh token missing' })); return throwError(() => new HttpErrorResponse({ status: 401, statusText: 'Refresh token missing' }));
} }
this.isRefreshing = true; this.refreshRequest$ = this.http.post<LoginResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe(
this.refreshPromise = this.http.post<LoginResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe(
map((response) => { map((response) => {
const user = this.buildUserFromResponse(response); const user = this.normalizeUserProfile(response, this.currentUserSignal());
this.tokenStorage.saveAuth(response); const storageType = this.tokenStorage.getStorageType();
this.userSubject.next(user); const rememberMe = storageType === 'local';
this.currentUserSignal.set(user); this.tokenStorage.saveAuth(response, rememberMe);
this.setAuthState(user, this.tokenStorage.getAccessToken());
return response; return response;
}), }),
catchError((error) => { catchError((error) => {
this.logout(); this.logout();
return throwError(() => error); return throwError(() => error);
}) }),
finalize(() => {
this.refreshRequest$ = null;
}),
shareReplay(1)
); );
return this.refreshPromise.pipe( return this.refreshRequest$;
map((response) => {
this.isRefreshing = false;
this.refreshPromise = null;
return response;
}),
catchError((error) => {
this.isRefreshing = false;
this.refreshPromise = null;
return throwError(() => error);
})
);
} }
logout(): void { logout(): void {
this.tokenStorage.clearAuth(); this.tokenStorage.clearAuth();
this.userSubject.next(null); this.appContextService.clearContext();
this.currentUserSignal.set(null); this.setAuthState(null, null);
this.isRefreshing = false; this.refreshRequest$ = null;
this.refreshPromise = null;
} }
get accessToken(): string | null { get accessToken(): string | null {
return this.tokenStorage.getAccessToken(); return this.tokenStorage.getAccessToken();
} }
get refreshToken(): string | null {
return this.tokenStorage.getRefreshToken();
}
get isAuthenticated(): boolean { get isAuthenticated(): boolean {
return !!this.accessToken; return this.isAuthenticatedSignal();
}
get isLoggedIn(): boolean {
return this.isAuthenticatedSignal();
} }
get currentUser(): UserProfile | null { get currentUser(): UserProfile | null {
return this.currentUserSignal(); 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) { if (response.user) {
return response.user; return response.user;
} }
if (response.userId || response.email) { const userId = response.userId?.trim();
const email = response.email?.trim();
if (userId || email) {
return { return {
id: response.userId ?? '', id: userId ?? fallbackUser?.id ?? '',
email: response.email ?? '', email: email ?? fallbackUser?.email ?? '',
roles: response.roles, roles: response.roles,
}; };
} }
return this.tokenStorage.getUser(); return fallbackUser;
} }
} }
+90 -17
View File
@@ -1,6 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { LoginResponse, UserProfile } from '../models/auth.model'; import { LoginResponse, UserProfile } from '../models/auth.model';
type StorageType = 'local' | 'session';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class TokenStorageService { export class TokenStorageService {
private readonly accessTokenKey = 'master-admin-access-token'; 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 refreshTokenExpiresOnKey = 'master-admin-refresh-token-expires-on';
private readonly userKey = 'master-admin-user'; 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) { if (response.accessToken) {
localStorage.setItem(this.accessTokenKey, response.accessToken); selectedStorage.setItem(this.accessTokenKey, response.accessToken);
} }
if (response.refreshToken) { if (response.refreshToken) {
localStorage.setItem(this.refreshTokenKey, response.refreshToken); selectedStorage.setItem(this.refreshTokenKey, response.refreshToken);
} }
if (response.accessTokenExpiresOn) { if (response.accessTokenExpiresOn) {
localStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn); selectedStorage.setItem(this.accessTokenExpiresOnKey, response.accessTokenExpiresOn);
} }
if (response.refreshTokenExpiresOn) { if (response.refreshTokenExpiresOn) {
localStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn); selectedStorage.setItem(this.refreshTokenExpiresOnKey, response.refreshTokenExpiresOn);
} }
const user = this.buildUserProfile(response); const user = this.buildUserProfile(response);
if (user) { if (user) {
localStorage.setItem(this.userKey, JSON.stringify(user)); selectedStorage.setItem(this.userKey, JSON.stringify(user));
} }
} }
clearAuth(): void { clearAuth(): void {
localStorage.removeItem(this.accessTokenKey); this.removeFromStorage(localStorage);
localStorage.removeItem(this.refreshTokenKey); this.removeFromStorage(sessionStorage);
localStorage.removeItem(this.accessTokenExpiresOnKey);
localStorage.removeItem(this.refreshTokenExpiresOnKey);
localStorage.removeItem(this.userKey);
} }
getAccessToken(): string | null { getAccessToken(): string | null {
return localStorage.getItem(this.accessTokenKey); return this.getByPriority(this.accessTokenKey);
} }
getRefreshToken(): string | null { getRefreshToken(): string | null {
return localStorage.getItem(this.refreshTokenKey); return this.getByPriority(this.refreshTokenKey);
} }
getAccessTokenExpiresOn(): string | null { getAccessTokenExpiresOn(): string | null {
return localStorage.getItem(this.accessTokenExpiresOnKey); return this.getByPriority(this.accessTokenExpiresOnKey);
} }
getRefreshTokenExpiresOn(): string | null { getRefreshTokenExpiresOn(): string | null {
return localStorage.getItem(this.refreshTokenExpiresOnKey); return this.getByPriority(this.refreshTokenExpiresOnKey);
} }
getUser(): UserProfile | null { getUser(): UserProfile | null {
const raw = localStorage.getItem(this.userKey); const raw = this.getByPriority(this.userKey);
return raw ? (JSON.parse(raw) as UserProfile) : null;
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 { private buildUserProfile(response: LoginResponse): UserProfile | null {
if (response.user) { if (response.user) {
return response.user; return response.user;
+3
View File
@@ -6,6 +6,9 @@ export const API_CONFIG = {
auth: '/v1/auth', auth: '/v1/auth',
users: '/users', users: '/users',
tenants: '/tenants', tenants: '/tenants',
currentUserProfile: '/users/me',
tenantContext: '/tenants/current-context',
permissionContext: '/users/me/permissions',
masters: '/masters', masters: '/masters',
billing: '/billing', billing: '/billing',
localization: '/localization', localization: '/localization',
+27 -5
View File
@@ -1,15 +1,37 @@
import { inject } from '@angular/core'; 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 { 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 authService = inject(AuthService);
const tokenStorage = inject(TokenStorageService);
const router = inject(Router); 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; return true;
} }
router.navigate(['/auth/login']); if (!authService.refreshToken || tokenStorage.isRefreshTokenExpired()) {
return false; authService.logout();
return loginUrlTree;
}
return authService.refreshAccessToken().pipe(
map(() => true),
catchError(() => {
authService.logout();
return of(loginUrlTree);
})
);
}; };
+53
View File
@@ -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);
})
);
};
+24 -6
View File
@@ -5,11 +5,16 @@ import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { API_CONFIG } from '../config/api.config'; import { API_CONFIG } from '../config/api.config';
const RETRY_HEADER = 'X-Auth-Retry';
export const authInterceptor: HttpInterceptorFn = (req, next) => { export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService); const authService = inject(AuthService);
const router = inject(Router); 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) { if (isAuthRequest) {
return next(req); return next(req);
} }
@@ -21,21 +26,34 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
return next(authReq).pipe( return next(authReq).pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status !== 401 || !token) { if (error.status !== 401 || !authService.accessToken || isRefreshRequest || req.headers.has(RETRY_HEADER)) {
return throwError(() => error); return throwError(() => error);
} }
return authService.refreshAccessToken().pipe( return authService.refreshAccessToken().pipe(
switchMap(() => { switchMap(() => {
const refreshedToken = authService.accessToken; const refreshedToken = authService.accessToken;
const retriedRequest = refreshedToken
? req.clone({ setHeaders: { Authorization: `Bearer ${refreshedToken}` } }) if (!refreshedToken) {
: req; return throwError(() => error);
}
const retriedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${refreshedToken}`,
[RETRY_HEADER]: 'true',
},
});
return next(retriedRequest); return next(retriedRequest);
}), }),
catchError((refreshError) => { catchError((refreshError) => {
authService.logout(); 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); return throwError(() => refreshError);
}) })
); );
@@ -2,14 +2,21 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { catchError, throwError } from 'rxjs'; import { catchError, throwError } from 'rxjs';
import { API_CONFIG } from '../config/api.config';
export const errorInterceptor: HttpInterceptorFn = (req, next) => { export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const toastr = inject(ToastrService); 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( return next(req).pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
const detail = error.error?.detail ?? error.message ?? 'Unexpected error occurred.'; if (!isLoginRequest && !isRefreshRequest && error.status !== 401) {
toastr.error(detail, 'Request failed'); const message = error.error?.detail ?? error.error?.message ?? error.message ?? 'Request failed';
toastr.error(message, 'Request failed');
}
return throwError(() => error); return throwError(() => error);
}) })
); );
@@ -5,12 +5,21 @@ import { LoadingService } from '../services/loading.service';
export const loadingInterceptor: HttpInterceptorFn = (req, next) => { export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loadingService = inject(LoadingService); 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(() => { finalize(() => {
loadingService.hide(); if (!shouldSkipLoader) {
loadingService.hide();
}
}) })
); );
}; };
+33
View File
@@ -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;
}
@@ -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<AppContextState> | null = null;
private menuLoadRequest$: Observable<MenuContext> | null = null;
private loaded = false;
ensureMenuInitialized(forceReload = false): Observable<MenuContext> {
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<AppContextState> {
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<AppContextState> {
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: [] },
};
}
}
+66
View File
@@ -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: '<i class="bx bx-home side-menu__icon"></i>',
type: 'sub',
active: false,
selected: false,
dirchange: false,
children: [
{ path: '/dashboards/crm', title: 'CRM', type: 'link', dirchange: false },
],
},
{ headTitle: 'SAAS ADMIN' },
{
title: 'Management',
icon: '<i class="bx bx-buildings side-menu__icon"></i>',
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: '<i class="bx bx-line-chart side-menu__icon"></i>',
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 },
],
},
],
};
+42
View File
@@ -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<MenuContext | null>(null);
loadMenu(): Observable<MenuContext> {
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[];
}
}
@@ -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<PermissionContext | null>(null);
loadPermissions(): Observable<PermissionContext> {
return this.http.get<PermissionResponse>(`${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);
}
}
@@ -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<typeof setTimeout> | null = null;
private logoutTimer: ReturnType<typeof setTimeout> | null = null;
private countdownTimer: ReturnType<typeof setInterval> | 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;
}
}
}
@@ -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<TenantContext | null>(null);
loadTenantContext(): Observable<TenantContext> {
return this.http.get<TenantContext>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.tenantContext}`).pipe(
tap((context) => {
this.tenantContext.set(context);
})
);
}
clear(): void {
this.tenantContext.set(null);
}
}
@@ -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<CurrentUserContext | null>(null);
loadCurrentUserProfile(): Observable<CurrentUserContext> {
return this.http.get<Partial<CurrentUserContext>>(`${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);
}
}
@@ -1,4 +1,18 @@
<div class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-bodybg">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Tenants</h3> <data-table
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Tenant list, detail tabs, and the create wizard will be added here.</p> title="Tenant List"
</div> [showSearch]="true"
[columns]="columns"
[rows]="tenants"
[actions]="tableActions"
[loading]="loading"
[totalRecords]="totalRecords"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
searchPlaceholder="Search tenants..."
(searchChanged)="onSearch($event)"
(pageChanged)="onPageChange($event)"
(sortChanged)="onSortChange($event)"
(actionClicked)="onTableAction($event)"
(rowClicked)="onRowClick($event)"
/>
@@ -1,11 +1,89 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; 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({ @Component({
selector: 'app-tenant-list', selector: 'app-tenant-list',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, DataTable],
templateUrl: './tenant-list.html', templateUrl: './tenant-list.html',
styleUrl: './tenant-list.scss', 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);
}
}
@@ -1 +1,129 @@
<p>data-table works!</p> <div class="box custom-box">
<div class="box-header flex items-center justify-between">
<div class="box-title">{{ title() }}</div>
@if (showSearch()) {
<div class="w-64">
<input type="text" class="form-control form-control-sm" [placeholder]="searchPlaceholder()"
(input)="onSearch($any($event.target).value)" />
</div>
}
</div>
<div class="box-body !p-0">
<div class="table-responsive">
<table [class]="tableClass()">
<thead [class]="tableHeadClass()">
<tr [class]="trHeadClass()">
@for (column of columns(); track column.key) {
<th
[class]="column.headerClass || defaultThClass()"
[style.width]="column.width || null"
(click)="onSort(column)"
>
<span
class="inline-flex items-center gap-1"
[class.cursor-pointer]="column.sortable"
>
{{ column.label }}
@if (column.sortable) {
<i class="ri-arrow-up-down-line text-xs"></i>
}
</span>
</th>
}
@if (actions().length > 0) {
<th [class]="actionHeaderClass()">Actions</th>
}
</tr>
</thead>
<tbody [class]="tableBodyClass()">
@if (loading()) {
<tr>
<td [attr.colspan]="columns().length + (actions().length ? 1 : 0)" class="text-center py-6">
Loading...
</td>
</tr>
} @else if (rows().length === 0) {
<tr>
<td
[attr.colspan]="columns().length + (actions().length ? 1 : 0)"
class="text-center py-6 text-muted"
>
No records found
</td>
</tr>
} @else {
@for (row of rows(); track $index) {
<tr
[class]="trBodyClass()"
(click)="onRowClick(row)"
>
@for (column of columns(); track column.key) {
<td [class]="column.cellClass || defaultTdClass()">
{{ getCellValue(row, column.key.toString()) }}
</td>
}
@if (actions().length > 0) {
<td [class]="actionCellClass()">
<div class="flex justify-center gap-2">
@for (action of actions(); track action.type) {
@if (isActionVisible(action, row)) {
<button
type="button"
class="btn btn-sm"
[class]="action.className || 'btn-light'"
[title]="action.label"
(click)="onActionClick($event, action, row)"
>
@if (action.icon) {
<i [class]="action.icon"></i>
} @else {
{{ action.label }}
}
</button>
}
}
</div>
</td>
}
</tr>
}
}
</tbody>
</table>
</div>
</div>
<div class="box-footer flex items-center justify-between">
<div class="text-sm text-muted">
Showing {{ startRecord() }} to {{ endRecord() }} of {{ totalRecords() }} entries
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-sm btn-light"
[disabled]="pageIndex() === 1"
(click)="onPageChange(pageIndex() - 1)"
>
Previous
</button>
<span class="text-sm">Page {{ pageIndex() }} of {{ totalPages() }}</span>
<button
type="button"
class="btn btn-sm btn-light"
[disabled]="pageIndex() === totalPages()"
(click)="onPageChange(pageIndex() + 1)"
>
Next
</button>
</div>
</div>
</div>
@@ -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({ @Component({
selector: 'data-table', selector: 'data-table',
imports: [], imports: [],
templateUrl: './data-table.html', templateUrl: './data-table.html',
styleUrl: './data-table.scss', styleUrl: './data-table.scss',
standalone: true
}) })
export class DataTable { export class DataTable<T extends Record<string, any> = any> {
columns = input<DataTableColumn<T>[]>([]);
rows = input<T[]>([]);
actions = input<DataTableAction<T>[]>([]);
loading = input(false);
totalRecords = input(0);
pageIndex = input(1);
pageSize = input(10);
title = input<string>('');
showSearch = input<boolean>(false);
searchPlaceholder = input('Search...');
searchChanged = output<string>();
pageChanged = output<DataTablePageEvent>();
sortChanged = output<DataTableSortEvent>();
actionClicked = output<DataTableActionEvent<T>>();
rowClicked = output<T>();
sortColumn = signal('');
sortDirection = signal<'asc' | 'desc'>('asc');
tableClass = input<string>('table whitespace-nowrap min-w-full');
tableHeadClass = input<string>('');
tableBodyClass = input<string>('');
trHeadClass = input<string>('border-b border-defaultborder');
trBodyClass = input<string>('border-b border-defaultborder hover:bg-light cursor-pointer');
defaultThClass = input<string>('text-start');
defaultTdClass = input<string>('');
actionHeaderClass = input<string>('text-center');
actionCellClass = input<string>('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<T>): 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<T>, row: T): void {
event.stopPropagation();
this.actionClicked.emit({
action,
row
});
}
isActionVisible(action: DataTableAction<T>, row: T): boolean {
return action.visible ? action.visible(row) : true;
}
onRowClick(row: T): void {
this.rowClicked.emit(row);
}
} }
@@ -0,0 +1,35 @@
export type DataTableActionType = 'view' | 'edit' | 'delete' | string;
export interface DataTableColumn<T = any> {
key: keyof T | string;
label: string;
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
headerClass?: string;
cellClass?: string;
}
export interface DataTableAction<T = any> {
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<T = any> {
action: DataTableAction<T>;
row: T;
}
+19 -4
View File
@@ -3,12 +3,25 @@
<!-- Start::main-sidebar-header --> <!-- Start::main-sidebar-header -->
<div class="main-sidebar-header"> <div class="main-sidebar-header">
<a routerLink="/dashboards/crm" class="header-logo"> <a routerLink="/dashboards/crm" class="header-logo">
<img src="./assets/images/brand-logos/desktop-logo.png" alt="logo" class="desktop-logo"> <!-- <img src="./assets/images/brand-logos/desktop-logo.png" alt="logo" class="desktop-logo">
<img src="./assets/images/brand-logos/toggle-logo.png" alt="logo" class="toggle-logo"> <img src="./assets/images/brand-logos/toggle-logo.png" alt="logo" class="toggle-logo">
<img src="./assets/images/brand-logos/desktop-dark.png" alt="logo" class="desktop-dark"> <img src="./assets/images/brand-logos/desktop-dark.png" alt="logo" class="desktop-dark">
<img src="./assets/images/brand-logos/toggle-dark.png" alt="logo" class="toggle-dark"> <img src="./assets/images/brand-logos/toggle-dark.png" alt="logo" class="toggle-dark">
<img src="./assets/images/brand-logos/desktop-white.png" alt="logo" class="desktop-white"> <img src="./assets/images/brand-logos/desktop-white.png" alt="logo" class="desktop-white">
<img src="./assets/images/brand-logos/toggle-white.png" alt="logo" class="toggle-white"> <img src="./assets/images/brand-logos/toggle-white.png" alt="logo" class="toggle-white"> -->
<img src="assets/images/brand-logos/erp-logo-icon.png" alt="ERP Logo" class="desktop-logo" />
<img src="assets/images/brand-logos/erp-logo-icon.png" alt="ERP Logo" class="toggle-logo" />
<img src="assets/images/brand-logos/erp-logo-icon.png" alt="ERP Logo" class="desktop-dark" />
<img src="assets/images/brand-logos/erp-logo-icon.png" alt="ERP Logo" class="toggle-dark" />
<img src="assets/images/brand-logos/erp-logo-icon.png" alt="ERP Logo" class="desktop-white" />
<img src="assets/images/brand-logos/erp-logo-icon.png" alt="ERP Logo" class="toggle-white" />
</a> </a>
</div> </div>
<div> <div>
@@ -181,7 +194,9 @@
} }
<span class="side-menu__label">{{ menuItem.title }} <span class="side-menu__label">{{ menuItem.title }}
@if(menuItem.badgeValue){ @if(menuItem.badgeValue){
<span class="badge bg-{{ menuItem.badgeClass }} text-{{menuItem.badgeText}} float-end">{{menuItem.badgeValue }}</span> <span
class="badge bg-{{ menuItem.badgeClass }} text-{{menuItem.badgeText}} float-end">{{menuItem.badgeValue
}}</span>
} }
</span> </span>
</a> </a>
@@ -198,4 +213,4 @@
</ngx-simplebar> </ngx-simplebar>
</div> </div>
</aside> </aside>
@@ -1,2 +1,2 @@
<app-loader />
<router-outlet /> <router-outlet />
@@ -1,12 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { AppLoader } from '../../components/app-loader/app-loader';
@Component({ @Component({
selector: 'app-authentication-layout', selector: 'app-authentication-layout',
templateUrl: './authentication-layout.html', templateUrl: './authentication-layout.html',
styleUrl: './authentication-layout.scss', styleUrl: './authentication-layout.scss',
imports: [RouterOutlet, AppLoader] imports: [RouterOutlet]
}) })
export class AuthenticationLayout { export class AuthenticationLayout {
@@ -17,3 +17,30 @@
</div> </div>
<div #responsiveoverlay id="responsive-overlay" (click)="clearToggle()"></div> <div #responsiveoverlay id="responsive-overlay" (click)="clearToggle()"></div>
@if (sessionTimeoutService.showWarning()) {
<div
class="hs-overlay ti-modal mt-[1.75rem] hs-overlay-backdrop-open:!bg-[#32325180] dark:hs-overlay-backdrop-open:!bg-[#323251cc] pointer-events-none !block">
<div class="ti-modal-box pointer-events-auto">
<div class="ti-modal-content !border !border-defaultborder dark:!border-defaultborder/10 !rounded-[0.5rem]">
<div class="ti-modal-body !p-[1.5rem]">
<div class="flex items-start gap-4">
<span class="!w-[3rem] !h-[3rem] !leading-[3rem] rounded-[50%] avatar bg-warning/10 !text-warning text-center">
<i class="fe fe-alert-triangle text-[1.125rem]"></i>
</span>
<div>
<p class="text-[1rem] font-semibold text-defaulttextcolor dark:text-defaulttextcolor/70 mb-2">Session Expiring Soon</p>
<p class="text-[#8c9097] dark:text-white/50 text-[0.875rem] mb-0">
You have been inactive. You will be signed out in {{ sessionTimeoutService.remainingSeconds() }} seconds unless you continue your session.
</p>
</div>
</div>
</div>
<div class="ti-modal-footer !border !border-defaultborder dark:!border-defaultborder/10 !py-[1rem] !px-[1.25rem] justify-end gap-2">
<button type="button" class="ti-btn ti-btn-light" (click)="sessionTimeoutService.logoutNow()">Log Out</button>
<button type="button" class="ti-btn ti-btn-primary-full" (click)="sessionTimeoutService.staySignedIn()">Stay Signed In</button>
</div>
</div>
</div>
</div>
}
@@ -10,10 +10,14 @@ import { PageHeader } from '../../components/page-header/page-header';
import { Footer } from '../../components/footer/footer'; import { Footer } from '../../components/footer/footer';
import { TabToTop } from '../../components/tab-to-top/tab-to-top'; import { TabToTop } from '../../components/tab-to-top/tab-to-top';
import { AppLoader } from '../../components/app-loader/app-loader'; 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({ @Component({
selector: 'app-content-layout', selector: 'app-content-layout',
templateUrl: './content-layout.html', templateUrl: './content-layout.html',
styleUrl: './content-layout.scss', styleUrl: './content-layout.scss',
providers: [SessionTimeoutService],
imports: [ imports: [
Switcher, Switcher,
Header, Header,
@@ -28,6 +32,9 @@ import { AppLoader } from '../../components/app-loader/app-loader';
}) })
export class ContentLayout { export class ContentLayout {
navServices = inject(NavService); navServices = inject(NavService);
sessionTimeoutService = inject(SessionTimeoutService);
private readonly appContextService = inject(AppContextService);
private readonly authService = inject(AuthService);
private document = inject<Document>(DOCUMENT); private document = inject<Document>(DOCUMENT);
private appStateService = inject(AppStateService); private appStateService = inject(AppStateService);
private elementRef = inject(ElementRef); private elementRef = inject(ElementRef);
@@ -36,6 +43,12 @@ export class ContentLayout {
public menuItems!: Menu[]; public menuItems!: Menu[];
constructor() { constructor() {
this.sessionTimeoutService.start();
if (this.authService.isLoggedIn) {
this.appContextService.ensureMenuInitialized().subscribe();
}
this.navServices.items.subscribe((menuItems: any) => { this.navServices.items.subscribe((menuItems: any) => {
this.menuItems = menuItems; this.menuItems = menuItems;
}); });
+2
View File
@@ -1,8 +1,10 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { guestGuard } from '../../core/guards/guest.guard';
export const authen: Routes = [ export const authen: Routes = [
{ {
path: 'login', path: 'login',
canActivate: [guestGuard],
loadComponent: () => import('../../authentication/login/login').then((m) => m.Login), loadComponent: () => import('../../authentication/login/login').then((m) => m.Login),
}, },
{ {
+7 -77
View File
@@ -90,83 +90,13 @@ export class NavService implements OnDestroy {
this.screenWidth.next(width); this.screenWidth.next(width);
} }
MENUITEMS: Menu[] = [ items = new BehaviorSubject<Menu[]>([]);
// Dashboard
{ headTitle: 'MAIN' },
{
title: 'Dashboards',
icon: `<i class="bx bx-home side-menu__icon"></i>`,
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, },
], setMenuItems(menuItems: Menu[]): void {
}, this.items.next(menuItems);
}
clearMenuItems(): void {
this.items.next([]);
}
{
title: 'Error',
type: 'sub',
icon: `<i class="bx bx-error side-menu__icon"></i>`,
active: false,
dirchange: false,
children: [
{
path: 'error/error404',
title: 'Error 404',
type: 'link',
dirchange: false,
},
],
},
{
title: 'Nested Menu',
icon: `<i class="bx bx-layer side-menu__icon"></i>`,
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<Menu[]>(this.MENUITEMS);
} }
+4
View File
@@ -1,6 +1,10 @@
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: '/api', apiBaseUrl: '/api',
sessionTimeout: {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
},
firebase: { firebase: {
apiKey: '********************************', apiKey: '********************************',
authDomain: '********************************', authDomain: '********************************',
+4
View File
@@ -4,6 +4,10 @@
export const environment = { export const environment = {
production: false, production: false,
apiBaseUrl: 'https://localhost:5001/api', apiBaseUrl: 'https://localhost:5001/api',
sessionTimeout: {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
},
firebase: { firebase: {
apiKey: '********************************', apiKey: '********************************',
authDomain: '********************************', authDomain: '********************************',
+1 -1
View File
@@ -8,7 +8,7 @@
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="keywords" content="admin dashboard templateadmin template tailwind css,angular admin dashboard,angular admin panel,angular admin template,angular dashboard,angular material dashboard,angular ng template,dashboard admin template,dashboard tailwind template,tailwind admin template,tailwind css admin panel,tailwind css dashboard template,tailwind dashboard template,tailwind template dashboard"> <meta name="keywords" content="admin dashboard templateadmin template tailwind css,angular admin dashboard,angular admin panel,angular admin template,angular dashboard,angular material dashboard,angular ng template,dashboard admin template,dashboard tailwind template,tailwind admin template,tailwind css admin panel,tailwind css dashboard template,tailwind dashboard template,tailwind template dashboard">
<link rel="icon" type="image/x-icon" href="./assets/images/brand-logos/favicon.ico"> <link rel="icon" type="image/x-icon" href="./assets/images/brand-logos/erp-logo-icon.png">
<link rel="preconnect" href="https://fonts.gstatic.com"> <link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">