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="my-[2.5rem] flex justify-center">
<a routerLink="/dashboards/crm">
<img src="./assets/images/brand-logos/desktop-logo.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-logo">
<img src="./assets/images/brand-logos/erp-logo-icon.png" alt="logo" class="desktop-dark">
</a>
</div>
<div class="box">
@@ -17,18 +17,6 @@
<nav aria-label="Tabs" role="tablist" aria-orientation="horizontal"
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>
<div class="mt-3">
@@ -40,10 +28,9 @@
</div>
<div class="mt-3">
<form [formGroup]="angularLoginForm" (ngSubmit)="login()">
<form [formGroup]="adminLoginForm" (ngSubmit)="login()">
<div class="text-danger">
{{error}}
{{ loginError }}
</div>
<div class="grid grid-cols-12 gap-y-4">
<div class="xl:col-span-12 col-span-12">
@@ -66,7 +53,7 @@
</div>
<div class="mt-2">
<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"
for="defaultCheck1">
Remember password ?
@@ -75,9 +62,15 @@
</div>
</div>
<div class="xl:col-span-12 col-span-12 grid mt-2">
<button type="submit" [disabled]="
!angularLoginForm.valid
" class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium">Sign In</button>
<button type="submit" [disabled]="adminLoginForm.invalid || isSubmitting"
class="ti-btn ti-btn-primary !bg-primary !text-white !font-medium inline-flex items-center justify-center gap-2">
@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>
</form>
@@ -103,89 +96,6 @@
</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>
+73 -134
View File
@@ -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<string, boolean> = {
Angular: false,
Firebase: false
Angular: false
};
public iconMap: Record<string, string> = {
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;
}
}
+68 -42
View File
@@ -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<UserProfile | null>(this.tokenStorage.getUser());
private readonly userSubject = new BehaviorSubject<UserProfile | null>(null);
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 refreshPromise: Observable<LoginResponse> | null = null;
private refreshRequest$: 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(
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<LoginResponse> {
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<LoginResponse>(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.auth}/refresh`, { refreshToken }).pipe(
this.refreshRequest$ = this.http.post<LoginResponse>(`${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;
}
}
+90 -17
View File
@@ -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;
+3
View File
@@ -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',
+27 -5
View File
@@ -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);
})
);
};
+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 { 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);
})
);
@@ -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);
})
);
@@ -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();
}
})
);
};
+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>
<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>
</div>
<data-table
title="Tenant List"
[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 { 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);
}
}
@@ -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({
selector: 'data-table',
imports: [],
templateUrl: './data-table.html',
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;
}
+18 -3
View File
@@ -3,12 +3,25 @@
<!-- Start::main-sidebar-header -->
<div class="main-sidebar-header">
<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/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/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>
</div>
<div>
@@ -181,7 +194,9 @@
}
<span class="side-menu__label">{{ menuItem.title }}
@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>
</a>
@@ -1,2 +1,2 @@
<app-loader />
<router-outlet />
@@ -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 {
@@ -17,3 +17,30 @@
</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 { 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>(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;
});
+2
View File
@@ -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),
},
{
+7 -77
View File
@@ -90,83 +90,13 @@ export class NavService implements OnDestroy {
this.screenWidth.next(width);
}
MENUITEMS: 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, },
items = new BehaviorSubject<Menu[]>([]);
],
},
setMenuItems(menuItems: Menu[]): void {
this.items.next(menuItems);
}
{
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);
clearMenuItems(): void {
this.items.next([]);
}
}
+4
View File
@@ -1,6 +1,10 @@
export const environment = {
production: true,
apiBaseUrl: '/api',
sessionTimeout: {
warningAfterMs: 25 * 60 * 1000,
logoutAfterMs: 30 * 60 * 1000,
},
firebase: {
apiKey: '********************************',
authDomain: '********************************',
+4
View File
@@ -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: '********************************',
+1 -1
View File
@@ -8,7 +8,7 @@
<base href="/">
<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">
<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 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">