This commit is contained in:
2026-03-10 13:36:45 +03:30
parent 45d48f79dc
commit b379787002
208 changed files with 2328 additions and 2510 deletions
+69 -54
View File
@@ -9,67 +9,82 @@ export const MENU_ITEMS = [
},
],
},
{
label: 'انبارها',
icon: 'pi pi-fw pi-box',
items: [
{
label: 'لیست انبارها',
icon: 'pi pi-fw pi-list',
routerLink: ['/inventories'],
},
],
},
{
label: 'کالاها',
icon: 'pi pi-fw pi-tags',
items: [
{
label: 'لیست کالاها',
icon: 'pi pi-fw pi-list',
routerLink: ['/products'],
},
{
label: 'برندهای کالا',
icon: 'pi pi-fw pi-tags',
routerLink: ['/product-brands'],
},
{
label: 'دسته‌بندی‌های کالا',
icon: 'pi pi-fw pi-list',
routerLink: ['/product-categories'],
},
],
},
{
label: 'تامین‌کنندگان',
icon: 'pi pi-fw pi-users',
items: [
{
label: 'لیست تامین‌کنندگان',
icon: 'pi pi-fw pi-list',
routerLink: ['/suppliers'],
},
],
},
// {
// label: 'انبارها',
// icon: 'pi pi-fw pi-box',
// items: [
// {
// label: 'لیست انبارها',
// icon: 'pi pi-fw pi-list',
// routerLink: ['/inventories'],
// },
// ],
// },
// {
// label: 'کالاها',
// icon: 'pi pi-fw pi-tags',
// items: [
// {
// label: 'لیست کالاها',
// icon: 'pi pi-fw pi-list',
// routerLink: ['/products'],
// },
// {
// label: 'برندهای کالا',
// icon: 'pi pi-fw pi-tags',
// routerLink: ['/product-brands'],
// },
// {
// label: 'دسته‌بندی‌های کالا',
// icon: 'pi pi-fw pi-list',
// routerLink: ['/product-categories'],
// },
// ],
// },
// {
// label: 'تامین‌کنندگان',
// icon: 'pi pi-fw pi-users',
// items: [
// {
// label: 'لیست تامین‌کنندگان',
// icon: 'pi pi-fw pi-list',
// routerLink: ['/suppliers'],
// },
// ],
// },
{
label: 'مدیریت سیستم',
icon: 'pi pi-fw pi-cog',
items: [
{
label: 'شعب بانک‌ها',
icon: 'pi pi-fw pi-building',
routerLink: ['/bankBranches'],
},
{
label: 'حساب‌های بانکی',
icon: 'pi pi-fw pi-credit-card',
routerLink: ['/bankAccounts'],
},
// {
// label: 'حساب‌های بانکی',
// icon: 'pi pi-fw pi-credit-card',
// routerLink: ['/bankAccounts'],
// },
{
label: 'کاربران',
icon: 'pi pi-fw pi-user',
routerLink: ['/users'],
routerLink: ['/super_admin/users'],
},
{
label: 'اصناف',
icon: 'pi pi-fw pi-building',
routerLink: ['/super_admin/guilds'],
},
{
label: 'شرکای تجاری',
icon: 'pi pi-fw pi-credit-card',
routerLink: ['/super_admin/partners'],
},
{
label: 'ارایه‌دهندگان',
icon: 'pi pi-fw pi-credit-card',
routerLink: ['/super_admin/providers'],
},
{
label: 'لایسنس‌ها',
icon: 'pi pi-fw pi-credit-card',
routerLink: ['/super_admin/licenses'],
},
],
},
@@ -1,115 +0,0 @@
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');
}
@@ -56,12 +56,12 @@ function handleTokenRefresh(authService: AuthService, req: any, next: any): Obse
return throwError(() => new Error('توکنی یافت نشد'));
}
return authService.refreshToken().pipe(
return authService.doRefreshToken().pipe(
switchMap((response) => {
isRefreshing.next(false);
const newAuthReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${response.token}`),
headers: req.headers.set('Authorization', `Bearer ${response.accessToken}`),
});
return next(newAuthReq);
-1
View File
@@ -1 +0,0 @@
export interface StockBalance {}
+27 -9
View File
@@ -1,9 +1,8 @@
export enum UserRole {
ADMIN = 'admin',
SCHOOL = 'school',
TEACHER = 'teacher',
STUDENTS = 'students',
GRADER = 'grader',
PARTNER = 'partner',
POS = 'pos',
PROVIDER = 'provider',
SUPERADMIN = 'superadmin',
}
@@ -32,12 +31,31 @@ export interface Permission {
export interface IAuthResponse {
// user: User;
token: string;
fullName: string;
mustChangePassword: boolean;
role: string;
accessToken: string;
refreshToken: string;
expiresIn: number;
account: IAuthAccountResponse;
}
export interface IAuthAccountResponse {
id: string;
username: string;
type: TRoles;
status: string;
created_at: string;
user_id: string;
partner_id?: string;
business_id?: string;
provider_id?: string;
pos_id?: string;
user: IAuthAccountUser;
}
interface IAuthAccountUser {
id: string;
created_at: string;
mobile_number: string;
national_code: string;
first_name: string;
last_name: string;
}
export interface LoginCredentials {
+277 -312
View File
@@ -9,336 +9,301 @@ 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,
IAuthAccountResponse,
IAuthResponse,
ISignupRequestPayload,
IUserLoginInfo,
LoginCredentials,
Maybe,
TRoles,
} from '../models';
import { CaptchaService } from './captcha.service';
@Injectable({
providedIn: 'root',
providedIn: 'root',
})
export class AuthService {
constructor() {
this.initializeAuth();
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 currentAccountSubject = new BehaviorSubject<Maybe<IAuthAccountResponse>>(null);
private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
readonly currentUser$ = this.currentAccountSubject.asObservable();
readonly isLoading$ = this.isLoadingSubject.asObservable();
// Signals for reactive state management
// readonly currentUser = signal<Maybe<User>>(null);
readonly currentAccount = signal<Maybe<IAuthAccountResponse>>(null);
readonly isLoading = signal<boolean>(false);
readonly token = signal<Maybe<string>>(null);
readonly refreshToken = signal<Maybe<string>>(null);
readonly isAuthenticated = computed(() => {
return Boolean(this.token());
});
readonly userRole = computed(() => this.currentAccount()?.type);
private initializeAuth(): void {
const token = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
const accountData = localStorage.getItem(LOCAL_STORAGE_KEYS.USER_DATA);
if (token && accountData) {
try {
this.token.set(token);
const account = JSON.parse(accountData) as IAuthAccountResponse;
this.setCurrentUser(account);
} catch (error) {
console.error('Error parsing stored user data:', error);
this.logout();
}
}
}
login(credentials: LoginCredentials): 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>(
'/api/v1/auth/login',
{ 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']);
}
doRefreshToken(): Observable<IAuthResponse> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
this.logout();
return throwError(() => new Error('No refresh token available'));
}
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,
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);
}),
);
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 {};
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);
// }),
// );
// }
/**
* Get current access token
*/
getToken(): Maybe<string> {
return localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
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.currentAccount()?.type === role;
}
hasAnyRole(roles: TRoles[]): boolean {
const currentRole = this.currentAccount()?.type;
return currentRole ? roles.includes(currentRole) : false;
}
hasPermission(permission: string): boolean {
const user = this.currentAccount();
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;
}
/**
* Get current refresh token
*/
getRefreshToken(): Maybe<string> {
return localStorage.getItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
if (requiredPermissions && requiredPermissions.length > 0) {
return requiredPermissions.every((permission) => this.hasPermission(permission));
}
/**
* Check if token is expired (optional - requires JWT parsing)
*/
isTokenExpired(): boolean {
const token = this.getToken();
if (!token) return true;
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;
}
}
private handleAuthSuccess(response: IAuthResponse): void {
localStorage.setItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN, response.accessToken);
this.token.set(response.accessToken);
this.refreshToken.set(response.refreshToken);
localStorage.setItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN, response.refreshToken);
/**
* Check if refresh token is expired
*/
isRefreshTokenExpired(): boolean {
const refreshToken = this.getRefreshToken();
if (!refreshToken) return true;
this.setCurrentUser(response.account);
this.isLoadingSubject.next(false);
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;
}
}
// Navigate based on user role
// this.navigateByRole(response.user.role);
}
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);
}
private setCurrentUser(account: Maybe<IAuthAccountResponse>): void {
this.currentAccount.set(account);
this.currentAccountSubject.next(account);
localStorage.setItem(LOCAL_STORAGE_KEYS.USER_DATA, JSON.stringify(account));
}
}
+30 -3
View File
@@ -6,13 +6,20 @@ import { IPaginatedQuery, IResponseMetadata } from '../models/service.model';
/**
* Base interface for all state objects
*/
export interface BaseState<T = any> {
export interface BaseState {
loading: boolean;
error: Maybe<string>;
isRefreshing?: boolean;
initialized: boolean;
}
/**
* Base interface for entity
*/
export interface EntityState<T = any> extends BaseState {
entity: Maybe<T>;
}
/**
* Base interface for paginated lists
*/
@@ -29,7 +36,7 @@ export interface PaginatedState<T = any> extends BaseState {
/**
* Base interface for entity states (CRUD operations)
*/
export interface EntityState<T = any> extends BaseState {
export interface EntitiesState<T = any> extends BaseState {
entities: Record<string | number, T>;
selectedId: Maybe<string | number>;
ids: (string | number)[];
@@ -158,11 +165,31 @@ export abstract class BaseStore<T extends BaseState> {
}
/**
* Entity store with CRUD operations
* Entity store
*/
export abstract class EntityStore<
T,
TState extends EntityState<T> = EntityState<T>,
> extends BaseStore<TState> {
// Computed selectors
readonly entity = computed(() => this._state().entity);
/**
* Set entity
*/
setEntity(entity: Maybe<T>): void {
this.patchState({
entity,
} as Partial<TState>);
}
}
/**
* Entity store with CRUD operations
*/
export abstract class EntitiesStore<
T,
TState extends EntitiesState<T> = EntitiesState<T>,
> extends BaseStore<TState> {
// Computed selectors
readonly entities = computed(() => this._state().entities);
+3 -2
View File
@@ -4,13 +4,14 @@ export * from './base-store';
// Global state management
export * from './global/global.store';
// State effects and utilities
export * from './state-effects.service';
// // State effects and utilities
// export * from './state-effects.service';
// State management interfaces
export type {
Action,
BaseState,
EntitiesState,
EntityState,
LoadingState,
PaginatedState,
-326
View File
@@ -1,326 +0,0 @@
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);
}
}