feat: add logo image and RTL support styles
- Added logo image to assets. - Implemented RTL support styles in rtlSupport.scss for various components including breadcrumb, datatable, and tree node toggle button. chore: configure environment files for production, staging, and development - Created environment.prod.ts for production settings. - Created environment.staging.ts for staging settings. - Created environment.ts for local development settings. feat: define custom theme preset - Added presets.ts to define a custom theme preset using PrimeUIX with specific component styles and semantic colors. chore: set up proxy configuration for local development
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import { IPaginatedQuery } from '../models/service.model';
|
||||
|
||||
export const PAGINATED_QUERY_DEFAULT_VALUES: IPaginatedQuery = {
|
||||
lastSeen: '',
|
||||
pageSize: 10,
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './defaultData.const';
|
||||
export * from './roles.const';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TRoles } from '../models';
|
||||
|
||||
export default {
|
||||
ADMIN: {
|
||||
title: 'مدیر سیستم',
|
||||
key: 'ADMIN',
|
||||
},
|
||||
SCHOOL: {
|
||||
title: 'مرکز آموزشی',
|
||||
key: 'SCHOOL',
|
||||
},
|
||||
TEACHER: {
|
||||
title: 'دبیر',
|
||||
key: 'TEACHER',
|
||||
},
|
||||
STUDENTS: {
|
||||
title: 'دانشآموز',
|
||||
key: 'STUDENTS',
|
||||
},
|
||||
} as Record<TRoles, { title: string; key: TRoles }>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Directive, ElementRef, Renderer2 } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Prevent aggressive content-scripts and autofill engines from inspecting
|
||||
* or modifying inputs by setting common attributes that signal disabled
|
||||
* editing/grammar/autocomplete features.
|
||||
*/
|
||||
@Directive({ selector: '[appBlockContentScripts]', standalone: true })
|
||||
export class BlockContentScriptsDirective {
|
||||
constructor(
|
||||
private el: ElementRef<HTMLElement>,
|
||||
private r: Renderer2,
|
||||
) {
|
||||
const attrs: Record<string, string> = {
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
'data-gramm': 'false',
|
||||
'data-gramm_editor': 'false',
|
||||
autocorrect: 'off',
|
||||
autocapitalize: 'off',
|
||||
'aria-autocomplete': 'none',
|
||||
};
|
||||
|
||||
Object.entries(attrs).forEach(([k, v]) => this.r.setAttribute(this.el.nativeElement, k, v));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { ToastService } from '../services/toast.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
canActivate(
|
||||
_route: ActivatedRouteSnapshot,
|
||||
_state: RouterStateSnapshot,
|
||||
): Observable<boolean> | Promise<boolean> | boolean {
|
||||
if (this.authService.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.toastService.warn({
|
||||
title: 'دسترسی غیرمجاز',
|
||||
text: 'برای دسترسی به این بخش باید وارد شوید.',
|
||||
});
|
||||
|
||||
this.router.navigate(['/auth']);
|
||||
return false;
|
||||
// return this.router.navigate(['/auth/login'], {
|
||||
// queryParams: { returnUrl: state.url },
|
||||
// });
|
||||
// return true;
|
||||
// return this.authService.currentUser$.pipe(
|
||||
// map((user) => {
|
||||
// if (user) {
|
||||
// return true;
|
||||
// } else {
|
||||
// this.router.navigate(['/auth/login'], {
|
||||
// queryParams: { returnUrl: state.url },
|
||||
// });
|
||||
// return false;
|
||||
// }
|
||||
// })
|
||||
// );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './auth.guard';
|
||||
export * from './role.guard';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class RoleGuard implements CanActivate {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean> | Promise<boolean> | boolean {
|
||||
return true;
|
||||
// const requiredRoles = route.data['roles'] as UserRole[];
|
||||
// const requiredPermissions = route.data['permissions'] as string[];
|
||||
|
||||
// return this.authService.currentUser$.pipe(
|
||||
// map((user) => {
|
||||
// if (!user) {
|
||||
// this.router.navigate(['/auth/login']);
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// const canAccess = this.authService.canAccess(requiredRoles, requiredPermissions);
|
||||
|
||||
// if (!canAccess) {
|
||||
// this.router.navigate(['/unauthorized']);
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// return true;
|
||||
// })
|
||||
// );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './guards';
|
||||
export * from './models';
|
||||
export * from './services/auth.service';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
||||
@Injectable()
|
||||
export class ApiBaseUrlInterceptor implements HttpInterceptor {
|
||||
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
// Prepare default headers but don't overwrite existing ones
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
};
|
||||
|
||||
const headersToSet: Record<string, string> = {};
|
||||
const isFormData = req.body instanceof FormData;
|
||||
Object.keys(defaultHeaders).forEach((k) => {
|
||||
if (k === 'Content-Type' && isFormData) return;
|
||||
if (!req.headers.has(k)) {
|
||||
headersToSet[k] = defaultHeaders[k];
|
||||
}
|
||||
});
|
||||
|
||||
// Only prepend base URL if the request URL is relative (does not start with http or https)
|
||||
if (!/^https?:\/\//i.test(req.url)) {
|
||||
const apiReq = req.clone({ url: environment.apiBaseUrl + req.url, setHeaders: headersToSet });
|
||||
return next.handle(apiReq);
|
||||
}
|
||||
|
||||
// If absolute URL, still ensure default headers are present
|
||||
if (Object.keys(headersToSet).length > 0) {
|
||||
const reqWithHeaders = req.clone({ setHeaders: headersToSet });
|
||||
return next.handle(reqWithHeaders);
|
||||
}
|
||||
|
||||
return next.handle(req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, throwError } from 'rxjs';
|
||||
import { catchError, filter, switchMap, take } from 'rxjs/operators';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* HTTP Interceptor for token management
|
||||
* - Adds Authorization header with Bearer token to requests
|
||||
* - Handles token refresh on 401 responses
|
||||
* - Prevents duplicate refresh token requests
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
|
||||
// Skip auth for certain endpoints
|
||||
if (shouldSkipAuth(req.url)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const token = authService.getToken();
|
||||
|
||||
// Add token to request if available
|
||||
const authReq = token
|
||||
? req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${token}`),
|
||||
})
|
||||
: req;
|
||||
|
||||
return next(authReq).pipe(
|
||||
catchError((error) => {
|
||||
// Handle 401 Unauthorized - token might be expired
|
||||
if (error.status === 401 && !isRefreshTokenRequest(req.url)) {
|
||||
return handleTokenRefresh(authService, req, next);
|
||||
}
|
||||
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Subject to track if refresh is in progress
|
||||
const isRefreshing = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* Handle token refresh logic
|
||||
*/
|
||||
function handleTokenRefresh(authService: AuthService, req: any, next: any): Observable<any> {
|
||||
if (!isRefreshing.value) {
|
||||
isRefreshing.next(true);
|
||||
|
||||
const refreshToken = authService.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
isRefreshing.next(false);
|
||||
authService.logout();
|
||||
return throwError(() => new Error('توکنی یافت نشد'));
|
||||
}
|
||||
|
||||
return authService.refreshToken().pipe(
|
||||
switchMap((response) => {
|
||||
isRefreshing.next(false);
|
||||
|
||||
// Retry original request with new token
|
||||
const newAuthReq = req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${response.token}`),
|
||||
});
|
||||
|
||||
return next(newAuthReq);
|
||||
}),
|
||||
catchError((refreshError) => {
|
||||
isRefreshing.next(false);
|
||||
authService.logout();
|
||||
return throwError(() => refreshError);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Wait for refresh to complete, then retry request
|
||||
return isRefreshing.pipe(
|
||||
filter((refreshing) => !refreshing),
|
||||
take(1),
|
||||
switchMap(() => {
|
||||
const token = authService.getToken();
|
||||
const newAuthReq = token
|
||||
? req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${token}`),
|
||||
})
|
||||
: req;
|
||||
|
||||
return next(newAuthReq);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request should skip authentication
|
||||
*/
|
||||
function shouldSkipAuth(url: string): boolean {
|
||||
const skipAuthUrls = [
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/verify-email',
|
||||
'/api/public/',
|
||||
];
|
||||
|
||||
return skipAuthUrls.some((skipUrl) => url.includes(skipUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a refresh token request
|
||||
*/
|
||||
function isRefreshTokenRequest(url: string): boolean {
|
||||
return url.includes('/api/auth/refresh');
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, throwError } from 'rxjs';
|
||||
import { catchError, filter, switchMap, take } from 'rxjs/operators';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
|
||||
/**
|
||||
* HTTP Interceptor for token management
|
||||
* - Adds Authorization header with Bearer token to requests
|
||||
* - Handles token refresh on 401 responses
|
||||
* - Prevents duplicate refresh token requests
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
|
||||
// Skip auth for certain endpoints
|
||||
if (shouldSkipAuth(req.url)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const token = authService.getToken();
|
||||
|
||||
// Add token to request if available
|
||||
const authReq = token
|
||||
? req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${token}`),
|
||||
})
|
||||
: req;
|
||||
|
||||
return next(authReq).pipe(
|
||||
catchError((error) => {
|
||||
// Handle 401 Unauthorized - token might be expired
|
||||
if (error.status === 401 && !isRefreshTokenRequest(req.url)) {
|
||||
return handleTokenRefresh(authService, req, next);
|
||||
}
|
||||
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Subject to track if refresh is in progress
|
||||
const isRefreshing = new BehaviorSubject<boolean>(false);
|
||||
|
||||
/**
|
||||
* Handle token refresh logic
|
||||
*/
|
||||
function handleTokenRefresh(authService: AuthService, req: any, next: any): Observable<any> {
|
||||
if (!isRefreshing.value) {
|
||||
isRefreshing.next(true);
|
||||
|
||||
const refreshToken = authService.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
isRefreshing.next(false);
|
||||
authService.logout();
|
||||
return throwError(() => new Error('توکنی یافت نشد'));
|
||||
}
|
||||
|
||||
return authService.refreshToken().pipe(
|
||||
switchMap((response) => {
|
||||
isRefreshing.next(false);
|
||||
|
||||
const newAuthReq = req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${response.token}`),
|
||||
});
|
||||
|
||||
return next(newAuthReq);
|
||||
}),
|
||||
catchError((refreshError) => {
|
||||
isRefreshing.next(false);
|
||||
authService.logout();
|
||||
return throwError(() => refreshError);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Wait for refresh to complete, then retry request
|
||||
return isRefreshing.pipe(
|
||||
filter((refreshing) => !refreshing),
|
||||
take(1),
|
||||
switchMap(() => {
|
||||
const token = authService.getToken();
|
||||
const newAuthReq = token
|
||||
? req.clone({
|
||||
headers: req.headers.set('Authorization', `Bearer ${token}`),
|
||||
})
|
||||
: req;
|
||||
|
||||
return next(newAuthReq);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request should skip authentication
|
||||
*/
|
||||
function shouldSkipAuth(url: string): boolean {
|
||||
const skipAuthUrls = ['/captcha', '/api/v1/mdm', '/login'];
|
||||
|
||||
return skipAuthUrls.some((skipUrl) => url.includes(skipUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a refresh token request
|
||||
*/
|
||||
function isRefreshTokenRequest(url: string): boolean {
|
||||
return url.includes('/api/auth/refresh');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { finalize, shareReplay } from 'rxjs/operators';
|
||||
|
||||
const inFlightRequests = new Map<string, Observable<any>>();
|
||||
|
||||
function getRequestKey(req: any) {
|
||||
// Use method + full URL (including query params) as key
|
||||
return `${req.method}::${req.urlWithParams || req.url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate concurrent identical GET requests.
|
||||
* If a GET with the same url+params is already in-flight, the same observable is returned.
|
||||
*/
|
||||
export const dedupInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Only deduplicate safe idempotent GET requests
|
||||
// if (req.method !== 'GET') {
|
||||
// return next(req);
|
||||
// }
|
||||
|
||||
const key = getRequestKey(req);
|
||||
const existing = inFlightRequests.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const shared$ = next(req).pipe(
|
||||
// replay 1 value for any concurrent subscribers
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
// when finished (success or error) remove from map
|
||||
finalize(() => inFlightRequests.delete(key)),
|
||||
);
|
||||
|
||||
inFlightRequests.set(key, shared$);
|
||||
return shared$;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ErrorHandlerService } from '../services/error-handler.service';
|
||||
|
||||
/**
|
||||
* Error handling interceptor
|
||||
* - Handles HTTP errors globally
|
||||
* - Shows user-friendly error messages
|
||||
* - Redirects on authentication errors
|
||||
* - Logs errors for debugging
|
||||
*/
|
||||
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const router = inject(Router);
|
||||
const errorHandler = inject(ErrorHandlerService);
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
let errorMessage = 'خطای نامشخص رخ داده است';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
// Client-side error
|
||||
errorMessage = `خطای کلاینت: ${error.error.message}`;
|
||||
console.error('Client-side error:', error.error.message);
|
||||
} else {
|
||||
// Server-side error
|
||||
console.error(`Server error ${error.status}:`, error.error);
|
||||
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
errorMessage = error.error?.message || 'درخواست نامعتبر است';
|
||||
break;
|
||||
case 401:
|
||||
errorMessage = 'لطفاً مجدداً وارد شوید';
|
||||
// Don't redirect here as auth interceptor handles it
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = 'شما دسترسی به این بخش ندارید';
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = 'منبع مورد نظر یافت نشد';
|
||||
break;
|
||||
case 412:
|
||||
errorMessage = error.error?.detail || 'اطلاعات ارسالی نامعتبر است';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = error.error?.message || 'اطلاعات ارسالی نامعتبر است';
|
||||
break;
|
||||
case 429:
|
||||
errorMessage = 'تعداد درخواستها بیش از حد مجاز است';
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = 'خطای داخلی سرور رخ داده است';
|
||||
break;
|
||||
case 502:
|
||||
errorMessage = 'سرور در حال حاضر در دسترس نیست';
|
||||
break;
|
||||
case 503:
|
||||
errorMessage = 'سرویس موقتاً خارج از دسترس است';
|
||||
break;
|
||||
default:
|
||||
if (error.status === 0) {
|
||||
errorMessage = 'اتصال به سرور برقرار نشد';
|
||||
} else {
|
||||
errorMessage = `خطای سرور (${error.status}): ${error.error?.message || error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create enhanced error object
|
||||
const enhancedError = {
|
||||
...error,
|
||||
userMessage: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
};
|
||||
|
||||
// Log error details for debugging
|
||||
console.group('🚨 HTTP Error Details');
|
||||
console.error('URL:', req.url);
|
||||
console.error('Method:', req.method);
|
||||
console.error('Status:', error.status);
|
||||
console.error('Message:', errorMessage);
|
||||
console.error('Full Error:', error);
|
||||
console.groupEnd();
|
||||
|
||||
// Handle error with notification service
|
||||
errorHandler.handleError(enhancedError);
|
||||
|
||||
return throwError(() => enhancedError);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show error notification to user
|
||||
* Enhanced with Material Design snackbar notifications
|
||||
*/
|
||||
function showErrorNotification(message: string, status: number): void {
|
||||
// For now, we'll use console.error
|
||||
console.error(`🔥 Error ${status}: ${message}`);
|
||||
|
||||
// You can also show browser notification for critical errors
|
||||
if (status >= 500) {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('خطای سرور', {
|
||||
body: message,
|
||||
icon: '/favicon.ico',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error types for better error handling
|
||||
*/
|
||||
export enum ErrorType {
|
||||
NETWORK = 'NETWORK',
|
||||
SERVER = 'SERVER',
|
||||
CLIENT = 'CLIENT',
|
||||
VALIDATION = 'VALIDATION',
|
||||
AUTHENTICATION = 'AUTHENTICATION',
|
||||
AUTHORIZATION = 'AUTHORIZATION',
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine error type
|
||||
*/
|
||||
export function getErrorType(error: HttpErrorResponse): ErrorType {
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
return ErrorType.NETWORK;
|
||||
}
|
||||
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
case 412:
|
||||
case 422:
|
||||
return ErrorType.VALIDATION;
|
||||
case 401:
|
||||
return ErrorType.AUTHENTICATION;
|
||||
case 403:
|
||||
return ErrorType.AUTHORIZATION;
|
||||
case 0:
|
||||
return ErrorType.NETWORK;
|
||||
default:
|
||||
return error.status >= 500 ? ErrorType.SERVER : ErrorType.CLIENT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// HTTP Interceptors
|
||||
export { ApiBaseUrlInterceptor } from './api-base-url.interceptor';
|
||||
export { authInterceptor } from './auth.interceptor';
|
||||
export { dedupInterceptor } from './dedup.interceptor';
|
||||
export { errorInterceptor, ErrorType, getErrorType } from './error.interceptor';
|
||||
export { loggingInterceptor } from './logging.interceptor';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Logging interceptor for development
|
||||
* - Logs all HTTP requests and responses
|
||||
* - Measures request duration
|
||||
* - Provides detailed debugging information
|
||||
*/
|
||||
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Only log in development mode
|
||||
if (!isProductionMode()) {
|
||||
// console.group(`🌐 HTTP ${req.method} ${req.url}`);
|
||||
// console.log('Request:', {
|
||||
// url: req.url,
|
||||
// method: req.method,
|
||||
// headers: req.headers.keys().reduce((acc, key) => {
|
||||
// // Don't log sensitive headers
|
||||
// if (!isSensitiveHeader(key)) {
|
||||
// acc[key] = req.headers.get(key);
|
||||
// }
|
||||
// return acc;
|
||||
// }, {} as any),
|
||||
// body: req.body,
|
||||
// });
|
||||
}
|
||||
|
||||
return next(req).pipe(
|
||||
tap({
|
||||
next: (response) => {
|
||||
if (!isProductionMode()) {
|
||||
const duration = Date.now() - startTime;
|
||||
// console.log(`✅ Response (${duration}ms):`, response);
|
||||
console.groupEnd();
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (!isProductionMode()) {
|
||||
const duration = Date.now() - startTime;
|
||||
console.error(`❌ Error (${duration}ms):`, error);
|
||||
console.groupEnd();
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if we're in production mode
|
||||
*/
|
||||
function isProductionMode(): boolean {
|
||||
// You can also check environment variables
|
||||
return false; // Set to true in production builds
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if header contains sensitive information
|
||||
*/
|
||||
function isSensitiveHeader(headerName: string): boolean {
|
||||
const sensitiveHeaders = ['authorization', 'cookie', 'x-api-key', 'x-auth-token'];
|
||||
|
||||
return sensitiveHeaders.includes(headerName.toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './maybe.model';
|
||||
export * from './namedRoutes.model';
|
||||
export * from './navigation.model';
|
||||
export * from './route-utils.model';
|
||||
export * from './user.model';
|
||||
@@ -0,0 +1 @@
|
||||
export type Maybe<T> = T | null;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Route } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Generic type for creating strongly typed named routes
|
||||
* @template T - Union of string literals representing route names
|
||||
* @returns Record<T, Route> - Object with route names as keys and Route objects as values
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* type AuthRouteNames = 'auth' | 'login';
|
||||
* const authRoutes: NamedRoutes<AuthRouteNames> = {
|
||||
* auth: { path: 'auth', component: AuthComponent },
|
||||
* login: { path: 'login', component: LoginComponent }
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
|
||||
export interface RouteInfo {
|
||||
meta: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
pagePath?: (params: any) => string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NamedRouteWithInfo extends Route, RouteInfo {}
|
||||
|
||||
export type NamedRoutes<T extends string> = Record<T, NamedRouteWithInfo>;
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface MenuItem {
|
||||
key: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
routerLink?: string;
|
||||
children?: MenuItem[];
|
||||
permissions?: string[];
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
export interface NavigationConfig {
|
||||
role: string;
|
||||
menuItems: MenuItem[];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { NamedRoutes } from './namedRoutes.model';
|
||||
|
||||
/**
|
||||
* Utility function to convert NamedRoutes to Routes array
|
||||
* @param namedRoutes - Object containing named routes
|
||||
* @returns Array of Route objects suitable for Angular router
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const routes = routesToArray(studentNamedRoutes);
|
||||
* // Returns: [Route, Route, Route, ...]
|
||||
* ```
|
||||
*/
|
||||
export function routesToArray<T extends string>(namedRoutes: NamedRoutes<T>): Routes {
|
||||
return Object.values(namedRoutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get route path by name with type safety
|
||||
* @param namedRoutes - Object containing named routes
|
||||
* @param routeName - Name of the route to get path for
|
||||
* @returns The path string for the specified route
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const dashboardPath = getRoutePath(studentNamedRoutes, 'dashboard');
|
||||
* // Returns: 'dashboard'
|
||||
* ```
|
||||
*/
|
||||
export function getRoutePath<T extends string>(namedRoutes: NamedRoutes<T>, routeName: T): string {
|
||||
return namedRoutes[routeName].path || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to build full route path with prefix
|
||||
* @param prefix - Route prefix (e.g., '/student')
|
||||
* @param namedRoutes - Object containing named routes
|
||||
* @param routeName - Name of the route to build path for
|
||||
* @returns Full route path
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fullPath = buildRoutePath('/student', studentNamedRoutes, 'dashboard');
|
||||
* // Returns: '/student/dashboard'
|
||||
* ```
|
||||
*/
|
||||
export function buildRoutePath<T extends string>(
|
||||
prefix: string,
|
||||
namedRoutes: NamedRoutes<T>,
|
||||
routeName: T
|
||||
): string {
|
||||
const basePath = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
|
||||
const routePath = getRoutePath(namedRoutes, routeName);
|
||||
return `${basePath}/${routePath}`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface IPaginatedQuery<LastSeen = string> {
|
||||
lastSeen: LastSeen;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface IPaginatedResponse<T, LastSeen = string> {
|
||||
lastSeen: LastSeen;
|
||||
totalCount: number;
|
||||
items: T[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type TSeverity =
|
||||
| 'success'
|
||||
| 'secondary'
|
||||
| 'info'
|
||||
| 'warn'
|
||||
| 'danger'
|
||||
| 'contrast'
|
||||
| undefined
|
||||
| null;
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface defaultStoreState<T> {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
items: T;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export enum UserRole {
|
||||
ADMIN = 'admin',
|
||||
SCHOOL = 'school',
|
||||
TEACHER = 'teacher',
|
||||
STUDENTS = 'students',
|
||||
GRADER = 'grader',
|
||||
SUPERADMIN = 'superadmin',
|
||||
}
|
||||
|
||||
export type TRoles = keyof typeof UserRole;
|
||||
|
||||
export interface User {
|
||||
fullName: string;
|
||||
role: TRoles;
|
||||
// id: string;
|
||||
// email: string;
|
||||
// firstName: string;
|
||||
// lastName: string;
|
||||
// permissions: Permission[];
|
||||
// isActive: boolean;
|
||||
// lastLogin?: Date;
|
||||
// createdAt: Date;
|
||||
// updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface IAuthResponse {
|
||||
// user: User;
|
||||
token: string;
|
||||
fullName: string;
|
||||
mustChangePassword: boolean;
|
||||
role: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
captcha: string;
|
||||
}
|
||||
|
||||
export interface IUserLoginInfo {
|
||||
username: string;
|
||||
password: string;
|
||||
mobile: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ISignupRequestPayload {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
gender: boolean;
|
||||
mobile: string;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
// import { ADMIN_API_ROUTES } from '@/modules/admin/constants';
|
||||
// import { SCHOOLS_API_ROUTES } from '@/modules/schools/constants';
|
||||
// import { ISchoolMeResponse } from '@/modules/schools/models';
|
||||
// import { TEACHERS_API_ROUTES } from '@/modules/teachers/constants';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject, Observable, throwError } from 'rxjs';
|
||||
import { catchError, finalize, tap } from 'rxjs/operators';
|
||||
import { LOCAL_STORAGE_KEYS } from '../../../assets/constants';
|
||||
import {
|
||||
IAuthResponse,
|
||||
ISignupRequestPayload,
|
||||
IUserLoginInfo,
|
||||
LoginCredentials,
|
||||
Maybe,
|
||||
TRoles,
|
||||
User,
|
||||
} from '../models';
|
||||
import { CaptchaService } from './captcha.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthService {
|
||||
constructor() {
|
||||
this.initializeAuth();
|
||||
}
|
||||
|
||||
readonly modulesLoginRoutes = {
|
||||
// ADMIN: ADMIN_API_ROUTES.login(),
|
||||
// SCHOOL: SCHOOLS_API_ROUTES.login(),
|
||||
// TEACHER: TEACHERS_API_ROUTES.login(),
|
||||
} as Record<TRoles, string>;
|
||||
|
||||
readonly modulesSignupRoutes = {
|
||||
// TEACHER: TEACHERS_API_ROUTES.signup(),
|
||||
} as Record<TRoles, string>;
|
||||
|
||||
readonly modulesGetInfoRoutes = {
|
||||
// SCHOOL: SCHOOLS_API_ROUTES.me(),
|
||||
} as Record<TRoles, string>;
|
||||
|
||||
readonly modulesChangeInfoRoutes = {
|
||||
// SCHOOL: SCHOOLS_API_ROUTES.editLoginInfo(),
|
||||
} as Record<TRoles, string>;
|
||||
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
private captchaService = inject(CaptchaService);
|
||||
|
||||
private readonly currentUserSubject = new BehaviorSubject<Maybe<User>>(
|
||||
null,
|
||||
);
|
||||
private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
readonly currentUser$ = this.currentUserSubject.asObservable();
|
||||
readonly isLoading$ = this.isLoadingSubject.asObservable();
|
||||
|
||||
// Signals for reactive state management
|
||||
// readonly currentUser = signal<Maybe<User>>(null);
|
||||
readonly currentUser = signal<Maybe<User>>(null);
|
||||
readonly isLoading = signal<boolean>(false);
|
||||
readonly token = signal<Maybe<string>>(null);
|
||||
readonly isAuthenticated = computed(() => {
|
||||
return Boolean(this.token());
|
||||
});
|
||||
readonly userRole = computed(() => this.currentUser()?.role);
|
||||
|
||||
private initializeAuth(): void {
|
||||
const token = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
|
||||
|
||||
const userData = localStorage.getItem(LOCAL_STORAGE_KEYS.USER_DATA);
|
||||
|
||||
if (token && userData) {
|
||||
try {
|
||||
this.token.set(token);
|
||||
const user = JSON.parse(userData) as User;
|
||||
this.setCurrentUser(user);
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored user data:', error);
|
||||
this.logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
login(
|
||||
credentials: LoginCredentials,
|
||||
role: TRoles,
|
||||
): Observable<IAuthResponse> {
|
||||
const { username, password, captcha } = credentials;
|
||||
this.isLoading.set(true);
|
||||
this.isLoadingSubject.next(true);
|
||||
const baseHeaders = this.captchaService.buildCaptchaHeaders(
|
||||
{},
|
||||
captcha,
|
||||
);
|
||||
|
||||
return this.http
|
||||
.post<IAuthResponse>(
|
||||
this.modulesLoginRoutes[role],
|
||||
{ username, password },
|
||||
{
|
||||
headers: baseHeaders,
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.handleAuthSuccess(response);
|
||||
return response;
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Login error:', error);
|
||||
this.isLoadingSubject.next(false);
|
||||
throw error;
|
||||
}),
|
||||
finalize(() => {
|
||||
this.isLoading.set(false);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
signup(
|
||||
credentials: ISignupRequestPayload,
|
||||
role: TRoles,
|
||||
): Observable<IAuthResponse> {
|
||||
this.isLoading.set(true);
|
||||
this.isLoadingSubject.next(true);
|
||||
// const baseHeaders = this.captchaService.buildCaptchaHeaders({}, captcha);
|
||||
|
||||
return this.http
|
||||
.post<IAuthResponse>(
|
||||
this.modulesSignupRoutes[role],
|
||||
credentials,
|
||||
// {
|
||||
// headers: baseHeaders,
|
||||
// },
|
||||
)
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.handleAuthSuccess(response);
|
||||
return response;
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Signup error:', error);
|
||||
this.isLoadingSubject.next(false);
|
||||
throw error;
|
||||
}),
|
||||
finalize(() => {
|
||||
this.isLoading.set(false);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.USER_DATA);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
|
||||
|
||||
this.setCurrentUser(null);
|
||||
this.router.navigate(['/auth']);
|
||||
}
|
||||
|
||||
refreshToken(): Observable<IAuthResponse> {
|
||||
const refreshToken = this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
this.logout();
|
||||
return throwError(() => new Error('No refresh token available'));
|
||||
}
|
||||
|
||||
return this.http
|
||||
.post<IAuthResponse>('/api/auth/refresh', { refreshToken })
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.handleAuthSuccess(response);
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Token refresh error:', error);
|
||||
this.logout();
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getInfo(role: TRoles): Observable<Partial<IUserLoginInfo>> {
|
||||
// if (this.modulesGetInfoRoutes[role]) {
|
||||
// return this.http.get<any>(this.modulesGetInfoRoutes[role]).pipe(
|
||||
// map((data) => {
|
||||
// switch (role) {
|
||||
// case 'SCHOOL':
|
||||
// return this.prepareUserInfoBasedOnRole(role, data as ISchoolMeResponse);
|
||||
// default:
|
||||
// return data as Partial<IUserLoginInfo>;
|
||||
// }
|
||||
// }),
|
||||
// catchError((err) => {
|
||||
// console.error('GetInfo error:', err);
|
||||
// return throwError(() => err);
|
||||
// }),
|
||||
// );
|
||||
// }
|
||||
|
||||
return throwError(() => new Error('متاسفانه مشکلی پیش آمده'));
|
||||
}
|
||||
changeInfo(
|
||||
credentials: IUserLoginInfo,
|
||||
role: TRoles,
|
||||
): Observable<IAuthResponse> {
|
||||
return this.http.post<IAuthResponse>(
|
||||
this.modulesChangeInfoRoutes[role],
|
||||
credentials,
|
||||
);
|
||||
}
|
||||
|
||||
// prepareUserInfoBasedOnRole(role: TRoles, info: ISchoolMeResponse): Partial<IUserLoginInfo> {
|
||||
// if (role === 'SCHOOL') {
|
||||
// return {
|
||||
// username: info.username,
|
||||
// email: info.managerInfo.email,
|
||||
// mobile: info.managerInfo.mobile,
|
||||
// };
|
||||
// }
|
||||
// return {};
|
||||
// }
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*/
|
||||
getToken(): Maybe<string> {
|
||||
return localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current refresh token
|
||||
*/
|
||||
getRefreshToken(): Maybe<string> {
|
||||
return localStorage.getItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired (optional - requires JWT parsing)
|
||||
*/
|
||||
isTokenExpired(): boolean {
|
||||
const token = this.getToken();
|
||||
if (!token) return true;
|
||||
|
||||
try {
|
||||
// Parse JWT token to check expiration
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < currentTime;
|
||||
} catch (error) {
|
||||
console.error('Error parsing token:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if refresh token is expired
|
||||
*/
|
||||
isRefreshTokenExpired(): boolean {
|
||||
const refreshToken = this.getRefreshToken();
|
||||
if (!refreshToken) return true;
|
||||
|
||||
try {
|
||||
// Parse JWT refresh token to check expiration
|
||||
const payload = JSON.parse(atob(refreshToken.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp < currentTime;
|
||||
} catch (error) {
|
||||
console.error('Error parsing refresh token:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
hasRole(role: TRoles): boolean {
|
||||
return this.currentUser()?.role === role;
|
||||
}
|
||||
|
||||
hasAnyRole(roles: TRoles[]): boolean {
|
||||
const currentRole = this.currentUser()?.role;
|
||||
return currentRole ? roles.includes(currentRole) : false;
|
||||
}
|
||||
|
||||
hasPermission(permission: string): boolean {
|
||||
const user = this.currentUser();
|
||||
if (!user) return false;
|
||||
|
||||
return true;
|
||||
// return user.permissions.some(
|
||||
// (p) => p.name === permission || `${p.resource}:${p.action}` === permission,
|
||||
// );
|
||||
}
|
||||
|
||||
canAccess(
|
||||
requiredRoles?: TRoles[],
|
||||
requiredPermissions?: string[],
|
||||
): boolean {
|
||||
if (!this.isAuthenticated()) return false;
|
||||
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
if (!this.hasAnyRole(requiredRoles)) return false;
|
||||
}
|
||||
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
return requiredPermissions.every((permission) =>
|
||||
this.hasPermission(permission),
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleAuthSuccess(response: IAuthResponse): void {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN, response.token);
|
||||
this.token.set(response.token);
|
||||
// localStorage.setItem('refresh_token', response.refreshToken);
|
||||
const user = {
|
||||
fullName: response.fullName,
|
||||
role: response.role as TRoles,
|
||||
};
|
||||
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_KEYS.USER_DATA,
|
||||
JSON.stringify(user),
|
||||
);
|
||||
|
||||
this.setCurrentUser(user);
|
||||
// this.isLoading.set(false);
|
||||
this.currentUser.set({
|
||||
fullName: response.fullName,
|
||||
role: response.role as TRoles,
|
||||
});
|
||||
this.isLoadingSubject.next(false);
|
||||
|
||||
// Navigate based on user role
|
||||
// this.navigateByRole(response.user.role);
|
||||
}
|
||||
|
||||
private setCurrentUser(user: Maybe<User>): void {
|
||||
this.currentUser.set(user);
|
||||
this.currentUserSubject.next(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { NavigationStart, Router } from '@angular/router';
|
||||
import { MenuItem } from 'primeng/api';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BreadcrumbService {
|
||||
private readonly _items = signal<MenuItem[]>([]);
|
||||
|
||||
private router = inject(Router);
|
||||
|
||||
constructor() {
|
||||
// Clear breadcrumb items on navigation start so previous page items don't persist.
|
||||
// Components for the new route can set their own items in their lifecycle (e.g. ngOnInit).
|
||||
this.router.events.pipe(filter((e) => e instanceof NavigationStart)).subscribe(() => {
|
||||
this.clear();
|
||||
});
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this._items();
|
||||
}
|
||||
|
||||
setItems(items: MenuItem[]) {
|
||||
this._items.set(items);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._items.set([]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { timeToSeconds } from '@/utils';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { computed, Injectable, signal, Signal } from '@angular/core';
|
||||
import { Subscription, timer } from 'rxjs';
|
||||
import { finalize, tap } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Maybe } from '../models';
|
||||
|
||||
interface ICaptchaResponse {
|
||||
id: string;
|
||||
expiry: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CaptchaService {
|
||||
// Signals for reactive consumption in components
|
||||
private _captchaId = signal<string | null>(null);
|
||||
readonly captchaId: Signal<string | null> = this._captchaId;
|
||||
|
||||
private _loading = signal<boolean>(false);
|
||||
readonly loading: Signal<boolean> = this._loading;
|
||||
|
||||
// default TTL 120 seconds
|
||||
private ttlSeconds = signal<number>(120);
|
||||
private ttlTimerSub: Subscription | null = null;
|
||||
|
||||
readonly captchaImageSrc = signal<Maybe<string>>(null);
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
readonly captchaIsExpired = computed(() => !this._captchaId());
|
||||
|
||||
/** request a new captcha from backend and start TTL countdown */
|
||||
requestNewCaptcha(): void {
|
||||
const url = `/captcha/new`;
|
||||
this.getCaptcha(url);
|
||||
}
|
||||
|
||||
private getCaptcha(url: string, headers?: HttpHeaders): void {
|
||||
this._loading.set(true);
|
||||
|
||||
// Cancel any previous TTL timer
|
||||
this.clearTtlTimer();
|
||||
|
||||
this.http
|
||||
.get<ICaptchaResponse>(url, {
|
||||
headers,
|
||||
})
|
||||
.pipe(
|
||||
tap((res) => {
|
||||
if (!res) {
|
||||
throw new Error('Invalid captcha response');
|
||||
}
|
||||
this._captchaId.set(res.id);
|
||||
this.ttlSeconds.set(timeToSeconds(res.expiry));
|
||||
this.startTtlTimer();
|
||||
this.setCaptchaImageSrc();
|
||||
}),
|
||||
finalize(() => this._loading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {},
|
||||
error: (err) => {
|
||||
console.error('[CaptchaService] requestNewCaptcha error', err);
|
||||
this._captchaId.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Manually clear captcha and cancel TTL */
|
||||
clearCaptcha(): void {
|
||||
this._captchaId.set(null);
|
||||
this.clearTtlTimer();
|
||||
}
|
||||
|
||||
setCaptchaImageSrc = () => {
|
||||
const id = this._captchaId();
|
||||
const base = environment.apiBaseUrl ? environment.apiBaseUrl.replace(/\/$/, '') : '';
|
||||
this.captchaImageSrc.set(
|
||||
id ? `${base}/captcha?Id=${encodeURIComponent(id)}&width=222&height=111` : '',
|
||||
);
|
||||
};
|
||||
|
||||
renewCaptcha = () => {
|
||||
const url = `/captcha/new`;
|
||||
|
||||
const headers = new HttpHeaders({
|
||||
'x-OldCaptchaId': this.captchaId()!,
|
||||
});
|
||||
|
||||
this.getCaptcha(url, headers);
|
||||
};
|
||||
|
||||
buildCaptchaHeaders(
|
||||
existing: Record<string, string> = {},
|
||||
captchaValue: string,
|
||||
): Record<string, string> {
|
||||
const id = this._captchaId();
|
||||
if (!id) return { ...existing };
|
||||
return {
|
||||
...existing,
|
||||
'x-CaptchaId': id,
|
||||
'x-CaptchaValue': captchaValue,
|
||||
};
|
||||
}
|
||||
|
||||
/** internal TTL timer management */
|
||||
private startTtlTimer() {
|
||||
this.clearTtlTimer();
|
||||
this.ttlTimerSub = timer(this.ttlSeconds() * 1000).subscribe(() => {
|
||||
this.requestNewCaptcha();
|
||||
this.ttlTimerSub = null;
|
||||
});
|
||||
}
|
||||
|
||||
private clearTtlTimer() {
|
||||
if (this.ttlTimerSub) {
|
||||
this.ttlTimerSub.unsubscribe();
|
||||
this.ttlTimerSub = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ConfigService } from './config.service';
|
||||
|
||||
describe('ConfigService', () => {
|
||||
let service: ConfigService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ConfigService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return API base URL', () => {
|
||||
expect(service.apiBaseUrl).toBeDefined();
|
||||
expect(typeof service.apiBaseUrl).toBe('string');
|
||||
});
|
||||
|
||||
it('should return host', () => {
|
||||
expect(service.host).toBeDefined();
|
||||
expect(typeof service.host).toBe('string');
|
||||
});
|
||||
|
||||
it('should return port', () => {
|
||||
expect(service.port).toBeDefined();
|
||||
expect(typeof service.port).toBe('number');
|
||||
});
|
||||
|
||||
it('should construct full API URL', () => {
|
||||
const path = '/test/endpoint';
|
||||
const fullUrl = service.getApiUrl(path);
|
||||
expect(fullUrl).toContain(service.apiBaseUrl);
|
||||
expect(fullUrl).toContain(path);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Configuration service to provide centralized access to environment variables
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConfigService {
|
||||
/**
|
||||
* Get the base URL for API calls
|
||||
*/
|
||||
get apiBaseUrl(): string {
|
||||
return environment.apiBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application host
|
||||
*/
|
||||
get host(): string {
|
||||
return environment.host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application port
|
||||
*/
|
||||
get port(): number {
|
||||
return environment.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
get isProduction(): boolean {
|
||||
return environment.production;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging is enabled
|
||||
*/
|
||||
get loggingEnabled(): boolean {
|
||||
return environment.enableLogging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
get debugEnabled(): boolean {
|
||||
return environment.enableDebug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full API URL for a given path
|
||||
* @param path The API path (should start with /)
|
||||
*/
|
||||
getApiUrl(path: string): string {
|
||||
return `${this.apiBaseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message if logging is enabled
|
||||
* @param message The message to log
|
||||
* @param data Optional data to log
|
||||
*/
|
||||
log(message: string, ...data: any[]): void {
|
||||
if (this.loggingEnabled) {
|
||||
console.log(`[ConfigService] ${message}`, ...data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ErrorType, getErrorType } from '../interceptors/error.interceptor';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorHandlerService {
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
/**
|
||||
* Handle HTTP errors with user-friendly notifications
|
||||
*/
|
||||
handleError(error: any): void {
|
||||
this.handleHttpError(error);
|
||||
// if (error instanceof HttpErrorResponse) {
|
||||
|
||||
// } else {
|
||||
// this.handleGenericError(error);
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP errors specifically
|
||||
*/
|
||||
private handleHttpError(error: HttpErrorResponse): void {
|
||||
const errorType = getErrorType(error);
|
||||
const userMessage = this.getUserMessage(error);
|
||||
|
||||
if (userMessage) {
|
||||
this.toastService.error({
|
||||
title: error.error?.title || 'خطا',
|
||||
text: userMessage,
|
||||
});
|
||||
} else {
|
||||
switch (errorType) {
|
||||
case ErrorType.NETWORK:
|
||||
this.showNetworkError(userMessage);
|
||||
break;
|
||||
case ErrorType.AUTHENTICATION:
|
||||
this.showAuthError(userMessage);
|
||||
break;
|
||||
case ErrorType.AUTHORIZATION:
|
||||
this.showAuthorizationError(userMessage);
|
||||
break;
|
||||
case ErrorType.VALIDATION:
|
||||
this.showValidationError(userMessage, error);
|
||||
break;
|
||||
case ErrorType.SERVER:
|
||||
this.showServerError(userMessage);
|
||||
break;
|
||||
default:
|
||||
this.showGenericError(userMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generic JavaScript errors
|
||||
*/
|
||||
private handleGenericError(error: any): void {
|
||||
console.error('Generic error:', error);
|
||||
this.toastService.error({
|
||||
text: 'یک خطای غیرمنتظره رخ داده است. لطفاً صفحه را تازهسازی کنید.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show network error notification
|
||||
*/
|
||||
private showNetworkError(message: string): void {
|
||||
this.toastService.error({
|
||||
title: 'خطای اتصال',
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show authentication error notification
|
||||
*/
|
||||
private showAuthError(message: string): void {
|
||||
this.toastService.warn({
|
||||
title: 'خطای احراز هویت',
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show authorization error notification
|
||||
*/
|
||||
private showAuthorizationError(message: string): void {
|
||||
this.toastService.warn({
|
||||
title: 'عدم دسترسی',
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show validation error notification
|
||||
*/
|
||||
private showValidationError(message: string, error: HttpErrorResponse): void {
|
||||
// Handle field-specific validation errors
|
||||
const validationErrors = this.extractValidationErrors(error);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
const errorList = validationErrors.join(', ');
|
||||
this.toastService.error({
|
||||
title: 'خطاهای اعتبارسنجی',
|
||||
text: errorList,
|
||||
});
|
||||
} else {
|
||||
this.toastService.error({
|
||||
title: 'خطای اعتبارسنجی',
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show server error notification
|
||||
*/
|
||||
private showServerError(message: string): void {
|
||||
this.toastService.error({
|
||||
title: 'خطای سرور',
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show generic error notification
|
||||
*/
|
||||
private showGenericError(message: string): void {
|
||||
this.toastService.error({
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user-friendly message from error
|
||||
*/
|
||||
private getUserMessage(error: HttpErrorResponse): string {
|
||||
if (error.error && typeof error.error === 'object') {
|
||||
// Try different common message fields
|
||||
return (
|
||||
error.error.message ||
|
||||
error.error.error ||
|
||||
error.error.detail ||
|
||||
error.statusText ||
|
||||
'خطای نامشخص رخ داده است'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof error.error === 'string') {
|
||||
return error.error;
|
||||
}
|
||||
|
||||
return error.statusText || 'خطای نامشخص رخ داده است';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract validation errors from server response
|
||||
*/
|
||||
private extractValidationErrors(error: HttpErrorResponse): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (error.error && typeof error.error === 'object') {
|
||||
if (error.error.errors) {
|
||||
Object.keys(error.error.errors).forEach((field) => {
|
||||
const fieldErrors = error.error.errors[field];
|
||||
if (Array.isArray(fieldErrors)) {
|
||||
errors.push(...fieldErrors);
|
||||
} else {
|
||||
errors.push(fieldErrors);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle other validation error formats
|
||||
if (error.error.validationErrors) {
|
||||
errors.push(...error.error.validationErrors);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Success notification helper
|
||||
*/
|
||||
showSuccess(title: string, message: string): void {
|
||||
this.toastService.success({
|
||||
title,
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Info notification helper
|
||||
*/
|
||||
showInfo(title: string, message: string): void {
|
||||
this.toastService.info({
|
||||
title,
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning notification helper
|
||||
*/
|
||||
showWarning(title: string, message: string): void {
|
||||
this.toastService.warn({
|
||||
title,
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FakeApiService {
|
||||
/**
|
||||
* Simulate a GET request
|
||||
*/
|
||||
get<T>(data: T, ms: number = 500): Observable<T> {
|
||||
return of(data).pipe(delay(ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate a POST request
|
||||
*/
|
||||
post<T>(data: T, ms: number = 500): Observable<T> {
|
||||
return of(data).pipe(delay(ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate an error response
|
||||
*/
|
||||
error<T>(message: string, ms: number = 500): Observable<T> {
|
||||
return throwError(() => new Error(message)).pipe(delay(ms));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { AbstractControl } from '@angular/forms';
|
||||
|
||||
export interface ValidationMessage {
|
||||
key: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FormErrorsService {
|
||||
// return list of human friendly messages for a control
|
||||
getErrors(control: AbstractControl | null | undefined, fieldLabel?: string): ValidationMessage[] {
|
||||
if (!control || !control.errors) return [];
|
||||
const errors = control.errors as Record<string, any>;
|
||||
const label = fieldLabel ?? 'این فیلد';
|
||||
const out: ValidationMessage[] = [];
|
||||
|
||||
if (errors['required']) {
|
||||
out.push({ key: 'required', message: `${label} الزامی است.` });
|
||||
}
|
||||
if (errors['requiredTrue']) {
|
||||
out.push({ key: 'requiredTrue', message: `${label} باید تایید شود.` });
|
||||
}
|
||||
if (errors['minlength']) {
|
||||
const info = errors['minlength'];
|
||||
out.push({
|
||||
key: 'minlength',
|
||||
message: `${label} باید حداقل ${info.requiredLength} کاراکتر داشته باشد (فعلی ${info.actualLength}).`,
|
||||
});
|
||||
}
|
||||
if (errors['maxlength']) {
|
||||
const info = errors['maxlength'];
|
||||
out.push({
|
||||
key: 'maxlength',
|
||||
message: `${label} نباید بیشتر از ${info.requiredLength} کاراکتر باشد (فعلی ${info.actualLength}).`,
|
||||
});
|
||||
}
|
||||
if (errors['min']) {
|
||||
const info = errors['min'];
|
||||
out.push({
|
||||
key: 'min',
|
||||
message: `${label} باید حداقل ${info.min} باشد (فعلی ${info.actual}).`,
|
||||
});
|
||||
}
|
||||
if (errors['max']) {
|
||||
const info = errors['max'];
|
||||
out.push({
|
||||
key: 'max',
|
||||
message: `${label} باید حداکثر ${info.max} باشد (فعلی ${info.actual}).`,
|
||||
});
|
||||
}
|
||||
if (errors['email']) {
|
||||
out.push({ key: 'email', message: `${label} ایمیل معتبری نیست.` });
|
||||
}
|
||||
if (errors['pattern']) {
|
||||
out.push({ key: 'pattern', message: `${label} فرمت معتبری ندارد.` });
|
||||
}
|
||||
if (errors['mismatch']) {
|
||||
out.push({ key: 'mismatch', message: `${label} تکرار رمز با رمز وارد شده مطابقت ندارد.` });
|
||||
}
|
||||
if (errors['postalCode']) {
|
||||
out.push({ key: 'postalCode', message: `${label} معتبر نیست.` });
|
||||
}
|
||||
if (errors['invalidMobile']) {
|
||||
out.push({ key: 'invalidMobile', message: `${label} معتبر نیست.` });
|
||||
}
|
||||
// fallback: include any other error keys
|
||||
Object.keys(errors).forEach((k) => {
|
||||
if (
|
||||
[
|
||||
'required',
|
||||
'requiredTrue',
|
||||
'minlength',
|
||||
'maxlength',
|
||||
'min',
|
||||
'max',
|
||||
'email',
|
||||
'pattern',
|
||||
].indexOf(k) === -1
|
||||
) {
|
||||
try {
|
||||
const val = errors[k];
|
||||
out.push({
|
||||
key: k,
|
||||
message: `${label}: ${typeof val === 'string' ? val : JSON.stringify(val)}`,
|
||||
});
|
||||
} catch {
|
||||
out.push({ key: k, message: `${label}: ${k}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Core Services
|
||||
export { AuthService } from './auth.service';
|
||||
export { ErrorHandlerService } from './error-handler.service';
|
||||
export { ConfigService } from './config.service';
|
||||
export * from './breadcrumb.service';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { MessageService, ToastMessageOptions } from 'primeng/api';
|
||||
|
||||
interface IToast extends Pick<ToastMessageOptions, 'sticky' | 'life'> {
|
||||
title?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ToastService {
|
||||
constructor(private messageService: MessageService) {}
|
||||
|
||||
add(message: ToastMessageOptions) {
|
||||
if (!message.detail) return;
|
||||
this.messageService.add(message);
|
||||
}
|
||||
|
||||
info(message: IToast) {
|
||||
const { title = 'اطلاع', text } = message;
|
||||
this.add({ ...message, summary: title, detail: text, severity: 'info' });
|
||||
}
|
||||
|
||||
success(message: IToast) {
|
||||
const { title = 'موفقیت', text } = message;
|
||||
this.add({ ...message, summary: title, detail: text, severity: 'success' });
|
||||
}
|
||||
|
||||
warn(message: IToast) {
|
||||
const { title = 'هشدار', text } = message;
|
||||
this.add({ ...message, summary: title, detail: text, severity: 'warn' });
|
||||
}
|
||||
|
||||
error(message: IToast) {
|
||||
const { title = 'خطا', text } = message;
|
||||
this.add({ ...message, summary: title, detail: text, severity: 'error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { Signal, WritableSignal, computed, signal } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { Maybe } from '../models';
|
||||
|
||||
/**
|
||||
* Base interface for all state objects
|
||||
*/
|
||||
export interface BaseState<T = any> {
|
||||
loading: boolean;
|
||||
error: Maybe<string>;
|
||||
lastId?: string;
|
||||
items?: T;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for paginated lists
|
||||
*/
|
||||
export interface PaginatedState<T = any> extends BaseState {
|
||||
items: T[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for entity states (CRUD operations)
|
||||
*/
|
||||
export interface EntityState<T = any> extends BaseState {
|
||||
entities: Record<string | number, T>;
|
||||
selectedId: Maybe<string | number>;
|
||||
ids: (string | number)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading states for different operations
|
||||
*/
|
||||
export interface LoadingState {
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
updating: boolean;
|
||||
deleting: boolean;
|
||||
fetching: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic action interface
|
||||
*/
|
||||
export interface Action<T = any> {
|
||||
type: string;
|
||||
payload?: T;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* State change event
|
||||
*/
|
||||
export interface StateChange<T = any> {
|
||||
previousState: T;
|
||||
currentState: T;
|
||||
action: Action;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base store class with common functionality
|
||||
*/
|
||||
export abstract class BaseStore<T extends BaseState> {
|
||||
protected readonly _state: WritableSignal<T>;
|
||||
protected readonly _stateSubject: BehaviorSubject<T>;
|
||||
|
||||
// Public readonly signals
|
||||
readonly state: Signal<T>;
|
||||
readonly loading: Signal<boolean>;
|
||||
readonly error: Signal<Maybe<string>>;
|
||||
|
||||
// Observable for RxJS compatibility
|
||||
readonly state$: Observable<T>;
|
||||
|
||||
constructor(initialState: T) {
|
||||
this._state = signal(initialState);
|
||||
this._stateSubject = new BehaviorSubject(initialState);
|
||||
|
||||
// Create computed signals
|
||||
this.state = this._state.asReadonly();
|
||||
this.loading = computed(() => this._state().loading);
|
||||
this.error = computed(() => this._state().error);
|
||||
|
||||
// Observable for RxJS compatibility
|
||||
this.state$ = this._stateSubject.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the entire state
|
||||
*/
|
||||
protected setState(newState: T): void {
|
||||
this._state.set(newState);
|
||||
this._stateSubject.next(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially update the state
|
||||
*/
|
||||
protected patchState(partialState: Partial<T>): void {
|
||||
const currentState = this._state();
|
||||
const newState = {
|
||||
...currentState,
|
||||
...partialState,
|
||||
};
|
||||
this.setState(newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state
|
||||
*/
|
||||
protected setLoading(loading: boolean, error: Maybe<string> = null): void {
|
||||
this.patchState({
|
||||
loading,
|
||||
error,
|
||||
} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set error state
|
||||
*/
|
||||
protected setError(error: string): void {
|
||||
this.patchState({
|
||||
loading: false,
|
||||
error,
|
||||
} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
protected clearError(): void {
|
||||
this.patchState({
|
||||
error: null,
|
||||
} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state to initial values
|
||||
*/
|
||||
abstract reset(): void;
|
||||
|
||||
/**
|
||||
* Get current state snapshot
|
||||
*/
|
||||
getCurrentState(): T {
|
||||
return this._state();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity store with CRUD operations
|
||||
*/
|
||||
export abstract class EntityStore<
|
||||
T,
|
||||
TState extends EntityState<T> = EntityState<T>,
|
||||
> extends BaseStore<TState> {
|
||||
// Computed selectors
|
||||
readonly entities = computed(() => this._state().entities);
|
||||
readonly selectedId = computed(() => this._state().selectedId);
|
||||
readonly ids = computed(() => this._state().ids);
|
||||
readonly selectedEntity = computed(() => {
|
||||
const state = this._state();
|
||||
return state.selectedId ? state.entities[state.selectedId] : null;
|
||||
});
|
||||
readonly entitiesArray = computed(() => {
|
||||
const state = this._state();
|
||||
return state.ids.map((id) => state.entities[id]).filter(Boolean);
|
||||
});
|
||||
|
||||
/**
|
||||
* Add or update entities
|
||||
*/
|
||||
protected upsertEntities(entities: T[], getId: (entity: T) => string | number): void {
|
||||
const currentState = this._state();
|
||||
const newEntities = { ...currentState.entities };
|
||||
const newIds = [...currentState.ids];
|
||||
|
||||
entities.forEach((entity) => {
|
||||
const id = getId(entity);
|
||||
newEntities[id] = entity;
|
||||
if (!newIds.includes(id)) {
|
||||
newIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
this.patchState({
|
||||
entities: newEntities,
|
||||
ids: newIds,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entity
|
||||
*/
|
||||
protected removeEntity(id: string | number): void {
|
||||
const currentState = this._state();
|
||||
const newEntities = { ...currentState.entities };
|
||||
const newIds = currentState.ids.filter((entityId) => entityId !== id);
|
||||
|
||||
delete newEntities[id];
|
||||
|
||||
this.patchState({
|
||||
entities: newEntities,
|
||||
ids: newIds,
|
||||
selectedId: currentState.selectedId === id ? null : currentState.selectedId,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select entity
|
||||
*/
|
||||
selectEntity(id: Maybe<string | number>): void {
|
||||
this.patchState({
|
||||
selectedId: id,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all entities
|
||||
*/
|
||||
protected clearEntities(): void {
|
||||
this.patchState({
|
||||
entities: {},
|
||||
ids: [],
|
||||
selectedId: null,
|
||||
} as unknown as Partial<TState>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated store for lists with pagination
|
||||
*/
|
||||
export abstract class PaginatedStore<
|
||||
T,
|
||||
TState extends PaginatedState<T> = PaginatedState<T>,
|
||||
> extends BaseStore<TState> {
|
||||
// Computed selectors
|
||||
readonly items = computed(() => this._state().items);
|
||||
readonly totalCount = computed(() => this._state().totalCount);
|
||||
readonly currentPage = computed(() => this._state().currentPage);
|
||||
readonly pageSize = computed(() => this._state().pageSize);
|
||||
readonly hasMore = computed(() => this._state().hasMore);
|
||||
readonly totalPages = computed(() => {
|
||||
const state = this._state();
|
||||
return Math.ceil(state.totalCount / state.pageSize);
|
||||
});
|
||||
|
||||
/**
|
||||
* Set items for current page
|
||||
*/
|
||||
protected setItems(items: T[], totalCount: number, currentPage: number): void {
|
||||
const state = this._state();
|
||||
this.patchState({
|
||||
items,
|
||||
totalCount,
|
||||
currentPage,
|
||||
hasMore: currentPage * state.pageSize < totalCount,
|
||||
loading: false,
|
||||
error: null,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append items (for infinite scroll)
|
||||
*/
|
||||
protected appendItems(items: T[], totalCount: number): void {
|
||||
const state = this._state();
|
||||
const allItems = [...state.items, ...items];
|
||||
const newPage = state.currentPage + 1;
|
||||
|
||||
this.patchState({
|
||||
items: allItems,
|
||||
totalCount,
|
||||
currentPage: newPage,
|
||||
hasMore: allItems.length < totalCount,
|
||||
loading: false,
|
||||
error: null,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set page size
|
||||
*/
|
||||
setPageSize(pageSize: number): void {
|
||||
this.patchState({
|
||||
pageSize,
|
||||
currentPage: 1,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to page
|
||||
*/
|
||||
setCurrentPage(page: number): void {
|
||||
this.patchState({
|
||||
currentPage: page,
|
||||
} as Partial<TState>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { BaseState, BaseStore } from '../base-store';
|
||||
|
||||
/**
|
||||
* Global application state
|
||||
*/
|
||||
export interface GlobalState extends BaseState {
|
||||
// UI State
|
||||
sidebarCollapsed: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
language: 'fa' | 'en';
|
||||
|
||||
// App State
|
||||
isOnline: boolean;
|
||||
notifications: AppNotification[];
|
||||
breadcrumbs: Breadcrumb[];
|
||||
|
||||
// User preferences
|
||||
preferences: UserPreferences;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application notification
|
||||
*/
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
read: boolean;
|
||||
actions?: NotificationAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification action
|
||||
*/
|
||||
export interface NotificationAction {
|
||||
label: string;
|
||||
action: () => void;
|
||||
type?: 'primary' | 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Breadcrumb item
|
||||
*/
|
||||
export interface Breadcrumb {
|
||||
label: string;
|
||||
url?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User preferences
|
||||
*/
|
||||
export interface UserPreferences {
|
||||
dashboardLayout: 'grid' | 'list';
|
||||
itemsPerPage: number;
|
||||
autoSave: boolean;
|
||||
followSystemTheme: boolean;
|
||||
enableThemeTransitions: boolean;
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
sound: boolean;
|
||||
};
|
||||
accessibility: {
|
||||
highContrast: boolean;
|
||||
fontSize: 'small' | 'medium' | 'large';
|
||||
reducedMotion: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Global state store
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class GlobalStore extends BaseStore<GlobalState> {
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
loading: false,
|
||||
error: null,
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
language: 'fa',
|
||||
isOnline: navigator.onLine,
|
||||
notifications: [],
|
||||
breadcrumbs: [],
|
||||
preferences: {
|
||||
dashboardLayout: 'grid',
|
||||
itemsPerPage: 10,
|
||||
autoSave: true,
|
||||
followSystemTheme: false,
|
||||
enableThemeTransitions: true,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
sound: false,
|
||||
},
|
||||
accessibility: {
|
||||
highContrast: false,
|
||||
fontSize: 'medium',
|
||||
reducedMotion: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.initializeState();
|
||||
}
|
||||
|
||||
// Computed selectors
|
||||
readonly sidebarCollapsed = this.computed((state) => state.sidebarCollapsed);
|
||||
readonly theme = this.computed((state) => state.theme);
|
||||
readonly language = this.computed((state) => state.language);
|
||||
readonly isOnline = this.computed((state) => state.isOnline);
|
||||
readonly notifications = this.computed((state) => state.notifications);
|
||||
readonly unreadNotifications = this.computed((state) =>
|
||||
state.notifications.filter((n) => !n.read),
|
||||
);
|
||||
readonly breadcrumbs = this.computed((state) => state.breadcrumbs);
|
||||
readonly preferences = this.computed((state) => state.preferences);
|
||||
|
||||
/**
|
||||
* Initialize state from localStorage and browser events
|
||||
*/
|
||||
private initializeState(): void {
|
||||
// Load preferences from localStorage
|
||||
const savedPreferences = localStorage.getItem('user_preferences');
|
||||
if (savedPreferences) {
|
||||
try {
|
||||
const preferences = JSON.parse(savedPreferences);
|
||||
this.patchState({ preferences });
|
||||
} catch (error) {
|
||||
console.error('Error loading user preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load theme from localStorage
|
||||
const savedTheme = localStorage.getItem('app_theme') as 'light' | 'dark';
|
||||
if (savedTheme) {
|
||||
this.patchState({ theme: savedTheme });
|
||||
}
|
||||
|
||||
// Load sidebar state from localStorage
|
||||
const sidebarCollapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
this.patchState({ sidebarCollapsed });
|
||||
|
||||
// Listen to online/offline events
|
||||
window.addEventListener('online', () => this.setOnlineStatus(true));
|
||||
window.addEventListener('offline', () => this.setOnlineStatus(false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create computed signals
|
||||
*/
|
||||
private computed<K>(selector: (state: GlobalState) => K) {
|
||||
return () => selector(this._state());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle sidebar collapse state
|
||||
*/
|
||||
toggleSidebar(): void {
|
||||
const collapsed = !this._state().sidebarCollapsed;
|
||||
this.patchState({ sidebarCollapsed: collapsed });
|
||||
localStorage.setItem('sidebar_collapsed', collapsed.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sidebar collapse state
|
||||
*/
|
||||
setSidebarCollapsed(collapsed: boolean): void {
|
||||
this.patchState({ sidebarCollapsed: collapsed });
|
||||
localStorage.setItem('sidebar_collapsed', collapsed.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle theme
|
||||
*/
|
||||
toggleTheme(): void {
|
||||
const theme = this._state().theme === 'light' ? 'dark' : 'light';
|
||||
this.setTheme(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set theme
|
||||
*/
|
||||
setTheme(theme: 'light' | 'dark'): void {
|
||||
this.patchState({ theme });
|
||||
localStorage.setItem('app_theme', theme);
|
||||
|
||||
// Apply theme to document
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set language
|
||||
*/
|
||||
setLanguage(language: 'fa' | 'en'): void {
|
||||
this.patchState({ language });
|
||||
localStorage.setItem('app_language', language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set online status
|
||||
*/
|
||||
setOnlineStatus(isOnline: boolean): void {
|
||||
this.patchState({ isOnline });
|
||||
|
||||
if (isOnline) {
|
||||
this.addNotification({
|
||||
type: 'success',
|
||||
title: 'اتصال برقرار شد',
|
||||
message: 'اتصال اینترنت برقرار شد',
|
||||
});
|
||||
} else {
|
||||
this.addNotification({
|
||||
type: 'warning',
|
||||
title: 'قطع اتصال',
|
||||
message: 'اتصال اینترنت قطع شده است',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add notification
|
||||
*/
|
||||
addNotification(notification: Omit<AppNotification, 'id' | 'timestamp' | 'read'>): void {
|
||||
const newNotification: AppNotification = {
|
||||
...notification,
|
||||
id: this.generateId(),
|
||||
timestamp: Date.now(),
|
||||
read: false,
|
||||
};
|
||||
|
||||
const notifications = [newNotification, ...this._state().notifications];
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
markNotificationRead(id: string): void {
|
||||
const notifications = this._state().notifications.map((n) =>
|
||||
n.id === id ? { ...n, read: true } : n,
|
||||
);
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
markAllNotificationsRead(): void {
|
||||
const notifications = this._state().notifications.map((n) => ({ ...n, read: true }));
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove notification
|
||||
*/
|
||||
removeNotification(id: string): void {
|
||||
const notifications = this._state().notifications.filter((n) => n.id !== id);
|
||||
this.patchState({ notifications });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all notifications
|
||||
*/
|
||||
clearNotifications(): void {
|
||||
this.patchState({ notifications: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set breadcrumbs
|
||||
*/
|
||||
setBreadcrumbs(breadcrumbs: Breadcrumb[]): void {
|
||||
this.patchState({ breadcrumbs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user preferences
|
||||
*/
|
||||
updatePreferences(preferences: Partial<UserPreferences>): void {
|
||||
const currentPreferences = this._state().preferences;
|
||||
const newPreferences = { ...currentPreferences, ...preferences };
|
||||
|
||||
this.patchState({ preferences: newPreferences });
|
||||
localStorage.setItem('user_preferences', JSON.stringify(newPreferences));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state to initial values
|
||||
*/
|
||||
reset(): void {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
sidebarCollapsed: false,
|
||||
theme: 'light',
|
||||
language: 'fa',
|
||||
isOnline: navigator.onLine,
|
||||
notifications: [],
|
||||
breadcrumbs: [],
|
||||
preferences: {
|
||||
dashboardLayout: 'grid',
|
||||
itemsPerPage: 10,
|
||||
autoSave: true,
|
||||
followSystemTheme: false,
|
||||
enableThemeTransitions: true,
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
sound: false,
|
||||
},
|
||||
accessibility: {
|
||||
highContrast: false,
|
||||
fontSize: 'medium',
|
||||
reducedMotion: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
private generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Base store classes and utilities
|
||||
export * from './base-store';
|
||||
|
||||
// Global state management
|
||||
export * from './global/global.store';
|
||||
|
||||
// State effects and utilities
|
||||
export * from './state-effects.service';
|
||||
|
||||
// State management interfaces
|
||||
export type {
|
||||
Action,
|
||||
BaseState,
|
||||
EntityState,
|
||||
LoadingState,
|
||||
PaginatedState,
|
||||
StateChange,
|
||||
} from './base-store';
|
||||
|
||||
export type {
|
||||
AppNotification,
|
||||
Breadcrumb,
|
||||
GlobalState,
|
||||
NotificationAction,
|
||||
UserPreferences,
|
||||
} from './global/global.store';
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { combineLatest, map, shareReplay, startWith } from 'rxjs';
|
||||
import { LOCAL_STORAGE_KEYS } from 'src/assets/constants';
|
||||
import { AuthStore } from '../../modules/auth/state/auth.store';
|
||||
import { GlobalStore } from './global/global.store';
|
||||
|
||||
/**
|
||||
* State effects service for handling cross-store interactions
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateEffects {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly globalStore = inject(GlobalStore);
|
||||
|
||||
constructor() {
|
||||
this.initializeEffects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all effects
|
||||
*/
|
||||
private initializeEffects(): void {
|
||||
this.setupAuthEffects();
|
||||
this.setupGlobalEffects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup authentication-related effects
|
||||
*/
|
||||
private setupAuthEffects(): void {
|
||||
// Update global notifications when auth state changes
|
||||
this.authStore.state$.subscribe((authState) => {
|
||||
// if (authState.isAuthenticated && authState.user) {
|
||||
// this.globalStore.addNotification({
|
||||
// type: 'success',
|
||||
// title: 'خوش آمدید',
|
||||
// message: `${authState.user.firstName} ${authState.user.lastName} عزیز، خوش آمدید`,
|
||||
// });
|
||||
// }
|
||||
});
|
||||
|
||||
// Handle session expiry warnings
|
||||
this.authStore.state$.subscribe((authState) => {
|
||||
if (authState.sessionExpiry) {
|
||||
const timeUntilExpiry = authState.sessionExpiry - Date.now();
|
||||
const minutesUntilExpiry = Math.floor(timeUntilExpiry / 60000);
|
||||
|
||||
// Show warning when 5 minutes remain
|
||||
if (minutesUntilExpiry === 5) {
|
||||
this.globalStore.addNotification({
|
||||
type: 'warning',
|
||||
title: 'هشدار انقضای جلسه',
|
||||
message: 'جلسه شما در 5 دقیقه منقضی میشود',
|
||||
actions: [
|
||||
{
|
||||
label: 'تمدید جلسه',
|
||||
action: () => this.authStore.refreshAuthToken().subscribe(),
|
||||
type: 'primary',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global effects
|
||||
*/
|
||||
private setupGlobalEffects(): void {
|
||||
// Auto-save preferences when they change
|
||||
this.globalStore.state$.subscribe((globalState) => {
|
||||
localStorage.setItem('user_preferences', JSON.stringify(globalState.preferences));
|
||||
});
|
||||
|
||||
// Handle theme changes
|
||||
this.globalStore.state$.subscribe((globalState) => {
|
||||
document.documentElement.setAttribute('data-theme', globalState.theme);
|
||||
});
|
||||
|
||||
// Handle language changes
|
||||
this.globalStore.state$.subscribe((globalState) => {
|
||||
document.documentElement.setAttribute('lang', globalState.language);
|
||||
document.documentElement.setAttribute('dir', globalState.language === 'fa' ? 'rtl' : 'ltr');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State selectors for complex cross-store queries
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateSelectors {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly globalStore = inject(GlobalStore);
|
||||
|
||||
/**
|
||||
* Combined application state for UI components
|
||||
*/
|
||||
readonly appState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
|
||||
map(([authState, globalState]) => ({
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
user: authState.user,
|
||||
userRole: authState.user?.role,
|
||||
loading: authState.loading || globalState.loading,
|
||||
sidebarCollapsed: globalState.sidebarCollapsed,
|
||||
theme: globalState.theme,
|
||||
language: globalState.language,
|
||||
notifications: globalState.notifications,
|
||||
unreadCount: globalState.notifications.filter((n) => !n.read).length,
|
||||
breadcrumbs: globalState.breadcrumbs,
|
||||
isOnline: globalState.isOnline,
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* User permissions combined with role-based access
|
||||
*/
|
||||
readonly userAccess$ = this.authStore.state$.pipe(
|
||||
map((authState) => ({
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
role: authState.user?.role,
|
||||
permissions: authState.permissions,
|
||||
canAccessAdmin: authState.user?.role === 'ADMIN' || authState.user?.role === 'SUPERADMIN',
|
||||
canAccessStudent: authState.user?.role === 'STUDENTS',
|
||||
canAccessGrader: authState.user?.role === 'GRADER',
|
||||
canAccessSchools: authState.user?.role === 'SCHOOL',
|
||||
canAccessSuperAdmin: authState.user?.role === 'SUPERADMIN',
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigation state for header/sidebar components
|
||||
*/
|
||||
readonly navigationState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
|
||||
map(([authState, globalState]) => ({
|
||||
user: authState.user,
|
||||
sidebarCollapsed: globalState.sidebarCollapsed,
|
||||
breadcrumbs: globalState.breadcrumbs,
|
||||
// userInitials: authState.user
|
||||
// ? `${authState.user.firstName[0]}${authState.user.lastName[0]}`
|
||||
// : '',
|
||||
// userFullName: authState.user ? `${authState.user.firstName} ${authState.user.lastName}` : '',
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* Notification state for notification components
|
||||
*/
|
||||
readonly notificationState$ = this.globalStore.state$.pipe(
|
||||
map((globalState) => ({
|
||||
notifications: globalState.notifications,
|
||||
unreadCount: globalState.notifications.filter((n) => !n.read).length,
|
||||
hasUnread: globalState.notifications.some((n) => !n.read),
|
||||
recentNotifications: globalState.notifications
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, 5),
|
||||
})),
|
||||
shareReplay(1),
|
||||
);
|
||||
|
||||
/**
|
||||
* Loading state across all stores
|
||||
*/
|
||||
readonly loadingState$ = combineLatest([this.authStore.state$, this.globalStore.state$]).pipe(
|
||||
map(([authState, globalState]) => ({
|
||||
isLoading: authState.loading || globalState.loading,
|
||||
authLoading: authState.loading,
|
||||
globalLoading: globalState.loading,
|
||||
})),
|
||||
startWith({ isLoading: false, authLoading: false, globalLoading: false }),
|
||||
shareReplay(1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* State utilities for common operations
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateUtils {
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly globalStore = inject(GlobalStore);
|
||||
|
||||
/**
|
||||
* Check if user can access route
|
||||
*/
|
||||
canAccessRoute(requiredRoles?: string[], requiredPermissions?: string[]): boolean {
|
||||
const authState = this.authStore.getCurrentState();
|
||||
|
||||
if (!authState.isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requiredRoles && requiredRoles.length > 0) {
|
||||
const userRole = authState.user?.role;
|
||||
if (!userRole || !requiredRoles.includes(userRole)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||
const hasAllPermissions = requiredPermissions.every((permission) =>
|
||||
authState.permissions.includes(permission),
|
||||
);
|
||||
if (!hasAllPermissions) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message
|
||||
*/
|
||||
showSuccess(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'success',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
*/
|
||||
showError(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'error',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning message
|
||||
*/
|
||||
showWarning(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'warning',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info message
|
||||
*/
|
||||
showInfo(title: string, message: string): void {
|
||||
this.globalStore.addNotification({
|
||||
type: 'info',
|
||||
title,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update page breadcrumbs
|
||||
*/
|
||||
setBreadcrumbs(breadcrumbs: Array<{ label: string; url?: string; icon?: string }>): void {
|
||||
this.globalStore.setBreadcrumbs(breadcrumbs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user display name
|
||||
*/
|
||||
getUserDisplayName(): string {
|
||||
const user = this.authStore.getCurrentState().user;
|
||||
return '';
|
||||
// return user ? `${user.firstName} ${user.lastName}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user initials
|
||||
*/
|
||||
getUserInitials(): string {
|
||||
const user = this.authStore.getCurrentState().user;
|
||||
return '';
|
||||
// return user ? `${user.firstName[0]}${user.lastName[0]}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is online
|
||||
*/
|
||||
isOnline(): boolean {
|
||||
return this.globalStore.getCurrentState().isOnline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current theme
|
||||
*/
|
||||
getCurrentTheme(): 'light' | 'dark' {
|
||||
return this.globalStore.getCurrentState().theme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current language
|
||||
*/
|
||||
getCurrentLanguage(): 'fa' | 'en' {
|
||||
return this.globalStore.getCurrentState().language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all application state (useful for logout)
|
||||
*/
|
||||
clearAllState(): void {
|
||||
this.authStore.reset();
|
||||
this.globalStore.reset();
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.USER_DATA);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.LAST_LOGIN_TIME);
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
||||
|
||||
function normalizeIban(input: string): string {
|
||||
return input.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function ibanChecksum(iban: string): boolean {
|
||||
// Move first four chars to the end
|
||||
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
||||
// Replace letters with numbers: A=10, B=11, ... Z=35
|
||||
let converted = '';
|
||||
for (let i = 0; i < rearranged.length; i++) {
|
||||
const ch = rearranged.charAt(i);
|
||||
if (/[A-Z]/.test(ch)) {
|
||||
converted += (ch.charCodeAt(0) - 55).toString();
|
||||
} else {
|
||||
converted += ch;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute mod 97 using chunking to avoid big integers
|
||||
let remainder = 0;
|
||||
const chunkSize = 9; // safe chunk length for JS numbers
|
||||
for (let offset = 0; offset < converted.length; offset += chunkSize) {
|
||||
const part = remainder.toString() + converted.substr(offset, chunkSize);
|
||||
remainder = parseInt(part, 10) % 97;
|
||||
}
|
||||
|
||||
return remainder === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iran-only IBAN (Sheba) validator.
|
||||
* Accepts values that normalize to a valid IR IBAN (country code IR, length 26,
|
||||
* and valid MOD-97 checksum).
|
||||
*/
|
||||
export function iranIbanValidator(): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const value = control.value;
|
||||
if (value == null || value === '') return null;
|
||||
if (typeof value !== 'string') return { iban: true };
|
||||
|
||||
const cleaned = normalizeIban(value);
|
||||
// IR IBAN total length is 26
|
||||
if (cleaned.length !== 26) return { iban: true };
|
||||
if (!cleaned.startsWith('IR')) return { iban: true };
|
||||
|
||||
// Basic pattern check: IR + 2 digits + rest alnum
|
||||
if (!/^IR[0-9]{2}[A-Z0-9]+$/.test(cleaned)) return { iban: true };
|
||||
|
||||
try {
|
||||
const ok = ibanChecksum(cleaned);
|
||||
return ok ? null : { iban: true };
|
||||
} catch (e) {
|
||||
return { iban: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default iranIbanValidator;
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './iban.validator';
|
||||
export * from './mobile.validator';
|
||||
export * from './must-match.validator';
|
||||
export * from './password.validator';
|
||||
export * from './postal-code.validator';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
||||
|
||||
/**
|
||||
* Validator for mobile phone numbers.
|
||||
*
|
||||
* Behavior/assumptions:
|
||||
* - Allows common Iranian mobile formats:
|
||||
* - 09xxxxxxxxx (leading 0, total 11 digits)
|
||||
* - +989xxxxxxxxx (international +98 followed by 9 and 9 digits)
|
||||
* - 9xxxxxxxxx (no leading 0, 10 digits)
|
||||
* - Treats empty / null / undefined values as valid (use Validators.required when needed).
|
||||
* - Returns `{ invalidMobile: true }` when the value does not match the pattern.
|
||||
*/
|
||||
export function mobileValidator(): ValidatorFn {
|
||||
const re = /^(?:(?:\+98)|0)?9\d{9}$/;
|
||||
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const v = control.value;
|
||||
if (v === null || v === undefined || v === '') {
|
||||
return null; // leave required validation to Validators.required
|
||||
}
|
||||
|
||||
if (typeof v !== 'string') {
|
||||
return { invalidMobile: true };
|
||||
}
|
||||
|
||||
const trimmed = v.trim();
|
||||
return re.test(trimmed) ? null : { invalidMobile: true };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
|
||||
|
||||
// Reusable validator that sets a 'mismatch' error on the matching control when values differ.
|
||||
export function MustMatch(controlName: string, matchingControlName: string): ValidatorFn {
|
||||
return (formGroup: AbstractControl): ValidationErrors | null => {
|
||||
const control = formGroup.get(controlName);
|
||||
const matchingControl = formGroup.get(matchingControlName);
|
||||
|
||||
if (!control || !matchingControl) return null;
|
||||
|
||||
// If another validator has already found an error on the matching control, don't overwrite it.
|
||||
const existingErrors = matchingControl.errors ? { ...matchingControl.errors } : null;
|
||||
if (existingErrors && !existingErrors['mismatch']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (control.value !== matchingControl.value) {
|
||||
matchingControl.setErrors({ ...(existingErrors ?? {}), mismatch: true });
|
||||
} else {
|
||||
if (existingErrors) {
|
||||
delete existingErrors['mismatch'];
|
||||
const remaining = Object.keys(existingErrors).length ? existingErrors : null;
|
||||
matchingControl.setErrors(remaining as any);
|
||||
} else {
|
||||
matchingControl.setErrors(null);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ValidatorFn } from '@angular/forms';
|
||||
|
||||
// Password must be minimum 8 characters, include at least one uppercase,
|
||||
// one lowercase, one number and one special character
|
||||
export const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
|
||||
|
||||
// Validator factory named `password` as requested. Returns `null` for empty
|
||||
// values so `Validators.required` can be used alongside it when needed.
|
||||
export function password(): ValidatorFn {
|
||||
return (control) => {
|
||||
const value = control?.value;
|
||||
if (value === null || value === undefined || String(value).length === 0) return null;
|
||||
return PASSWORD_PATTERN.test(String(value)) ? null : { password: 'معتبر نیست' };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ValidatorFn } from '@angular/forms';
|
||||
|
||||
export function postalCodeValidator(): ValidatorFn {
|
||||
return (control) => {
|
||||
if (control.value === null || control.value === undefined || control.value === '') {
|
||||
return null;
|
||||
}
|
||||
return control.value.length === 10 && /^[0-9]{10}$/.test(control.value)
|
||||
? null
|
||||
: { postalCode: true };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user