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,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' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user