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,19 @@
|
||||
import { NamedRoutes } from '@/core';
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export type AuthRouteNames = 'auth';
|
||||
|
||||
export const authNamedRoutes: NamedRoutes<AuthRouteNames> = {
|
||||
auth: {
|
||||
path: '',
|
||||
loadComponent: () => import('./pages/auth.component').then((m) => m.AuthComponent),
|
||||
meta: { title: 'احراز هویت' },
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTH_ROUTES: Routes = [
|
||||
{
|
||||
...authNamedRoutes.auth,
|
||||
// children: [authNamedRoutes.login],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,5 @@
|
||||
import rolesConst from '@/core/constants/roles.const';
|
||||
|
||||
export const CENTRAL_AUTH_ROLES = [
|
||||
...Object.values(rolesConst).filter((role) => role.key !== 'ADMIN'),
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="bg-white h-svh relative">
|
||||
<div class="absolute inset-0 opacity-30 blur-xs flex items-center justify-center">
|
||||
<img [src]="authVector" alt="background image" class="object-cover max-w-full max-h-full" />
|
||||
</div>
|
||||
<div class="flex justify-center items-center relative z-10 h-svh mx-auto bg-gray-800/80">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center px-12 py-10 h-auto bg-surface-card border border-surface-border rounded-lg shadow w-full max-w-md"
|
||||
>
|
||||
<div class="flex flex-col gap-4 text-center mb-10 items-center justify-center">
|
||||
<img [src]="logo" alt="امتحانات شبه نهایی سنجش" class="w-20 h-auto" />
|
||||
<span class="text-lg font-bold"> به پنل امتحانات شبه نهایی سنجش خوش آمدید. </span>
|
||||
</div>
|
||||
|
||||
@if (activeStep() === "login") {
|
||||
<app-login
|
||||
[redirectUrl]="redirectUrl"
|
||||
[loginApiUrl]="loginApiUrl"
|
||||
[defaultRole]="role"
|
||||
class="w-full"
|
||||
(onSuccessfullySubmit)="onLoggedIn($event)"
|
||||
(onToSignup)="toSignUp()"
|
||||
/>
|
||||
} @else if (activeStep() === "signup") {
|
||||
<auth-signup role="TEACHER" class="w-full" />
|
||||
} @else if (activeStep() === "otp") {
|
||||
<app-otp class="w-full" />
|
||||
} @else if (activeStep() === "modifyLoginInfo") {
|
||||
<app-modify-login-info
|
||||
[userLoginInfo]="{}"
|
||||
[role]="selectedRole()!"
|
||||
(onSubmit)="onModifyLoginInfo()"
|
||||
class="w-full"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { IAuthResponse, TRoles } from '@/core';
|
||||
import { ToastService } from '@/core/services/toast.service';
|
||||
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import images from 'src/assets/images';
|
||||
import { LoginComponent } from './login/login.component';
|
||||
import { ModifyLoginInfoComponent } from './modifyLoginInfo/modify-login-info.component';
|
||||
import { OTPComponent } from './otp/otp.component';
|
||||
import { SignupComponent } from './signup/signup.component';
|
||||
|
||||
type TSteps = 'login' | 'otp' | 'modifyLoginInfo' | 'signup';
|
||||
|
||||
@Component({
|
||||
selector: 'app-auth',
|
||||
templateUrl: './auth.component.html',
|
||||
imports: [LoginComponent, OTPComponent, ModifyLoginInfoComponent, SignupComponent],
|
||||
})
|
||||
export class AuthComponent {
|
||||
@Input() redirectUrl!: string;
|
||||
@Input() loginApiUrl!: string;
|
||||
@Input() role?: TRoles;
|
||||
@Input() defaultStep: TSteps = 'login';
|
||||
|
||||
@Output() submit = new EventEmitter<Promise<boolean>>();
|
||||
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly logo = images.logo;
|
||||
readonly authVector = images.login;
|
||||
|
||||
activeStep = signal<TSteps>(this.defaultStep);
|
||||
|
||||
selectedRole = signal<TRoles | undefined>(this.role);
|
||||
|
||||
toSignUp = () => {
|
||||
this.activeStep.set('signup');
|
||||
};
|
||||
|
||||
onLoggedIn = (data: IAuthResponse) => {
|
||||
this.toastService.success({ text: 'شما با موفقیت وارد شدید.' });
|
||||
this.selectedRole.set(data.role.toUpperCase() as TRoles);
|
||||
if (data.mustChangePassword) {
|
||||
this.activeStep.set('modifyLoginInfo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.submit.observed) {
|
||||
return this.submit.emit(Promise.resolve(true));
|
||||
}
|
||||
this.redirectToDashboard();
|
||||
};
|
||||
|
||||
onModifyLoginInfo = () => {
|
||||
if (this.submit.observed) {
|
||||
return this.submit.emit(Promise.resolve(true));
|
||||
}
|
||||
this.redirectToDashboard();
|
||||
};
|
||||
|
||||
private redirectToDashboard() {
|
||||
let redirectUrl = this.redirectUrl;
|
||||
|
||||
if (!redirectUrl) {
|
||||
switch (this.selectedRole()) {
|
||||
case 'SCHOOL':
|
||||
redirectUrl = '/schools';
|
||||
break;
|
||||
case 'ADMIN':
|
||||
redirectUrl = '/admin';
|
||||
break;
|
||||
case 'TEACHER':
|
||||
redirectUrl = '/teachers';
|
||||
break;
|
||||
case 'STUDENTS':
|
||||
redirectUrl = '/students';
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.router.navigateByUrl(redirectUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<form [formGroup]="loginForm" class="w-full flex flex-col gap-4" (ngSubmit)="submit()">
|
||||
@if (!defaultRole) {
|
||||
<div class="flex items-center gap-4 justify-center mb-10">
|
||||
@for (role of centralAuthRoles; track role.key) {
|
||||
<div class="flex items-center gap-1">
|
||||
<p-radiobutton [inputId]="role.key" name="role" [value]="role.key" formControlName="role" size="small" />
|
||||
<uikit-label [name]="role.key" class="cursor-pointer">{{ role.title }}</uikit-label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<uikit-field name="username" pSize="large" [control]="loginForm.controls.username" label="نام کاربری">
|
||||
<input
|
||||
pInputText
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
class="w-full placeholder:text-right"
|
||||
pSize="large"
|
||||
dir="ltr"
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
[invalid]="loginForm.controls.username.touched && loginForm.controls.username.invalid"
|
||||
/>
|
||||
</uikit-field>
|
||||
|
||||
<uikit-field name="password1" pSize="large" [control]="loginForm.controls.password" label="رمز عبور">
|
||||
<p-password
|
||||
id="password1"
|
||||
size="large"
|
||||
name="password"
|
||||
formControlName="password"
|
||||
autocomplete="password"
|
||||
placeholder="رمز عبور"
|
||||
[toggleMask]="true"
|
||||
[fluid]="true"
|
||||
[feedback]="false"
|
||||
inputStyleClass="placeholder:text-right"
|
||||
[inputStyle]="{
|
||||
direction: 'ltr',
|
||||
'padding-inline-start': 'calc((var(--p-form-field-padding-x) * 2) + var(--p-icon-size))',
|
||||
'padding-inline-end': 'var(--p-inputtext-lg-padding-x)',
|
||||
}"
|
||||
[invalid]="loginForm.controls.password.touched && loginForm.controls.password.invalid"
|
||||
/>
|
||||
</uikit-field>
|
||||
|
||||
<!-- <div class="flex items-center justify-between mt-2 mb-8 gap-8">
|
||||
<div class="flex items-center">
|
||||
<p-checkbox formControlName="rememberMe" id="rememberMe" binary class="me-2"></p-checkbox>
|
||||
<uikit-label name="rememberMe" pSize="small">مرا به خاطر بسپار</uikit-label>
|
||||
</div>
|
||||
<span class="text-sm font-medium no-underline ms-2 text-right cursor-pointer text-primary">
|
||||
رمز را فراموش کردید؟
|
||||
</span>
|
||||
</div> -->
|
||||
<div class="mb-6 flex flex-col items-center gap-4 w-[16rem] mx-auto">
|
||||
<div
|
||||
class="relative bg-surface-ground rounded-xl overflow-hidden shadow shrink-0 border border-[var(--p-inputtext-border-color)] w-full aspect-[2]"
|
||||
>
|
||||
<img p-image id="captchaImage" [src]="captchaImageSrc()" class="w-full object-contain" />
|
||||
@if (isCaptchaLoading()) {
|
||||
<div class="!absolute inset-0 bg-surface-hover/50 flex items-center justify-center">
|
||||
<p-progress-spinner
|
||||
strokeWidth="4"
|
||||
fill="transparent"
|
||||
animationDuration="1s"
|
||||
[style]="{ width: '48px', height: '48px' }"
|
||||
/>
|
||||
</div>
|
||||
} @else if (isCaptchaExpired()) {
|
||||
<div
|
||||
class="!absolute inset-0 bg-surface-hover/70 flex items-center justify-center text-center p-2 text-sm font-medium cursor-pointer"
|
||||
(click)="onRefreshCaptcha()"
|
||||
>
|
||||
<i class="pi pi-refresh !text-3xl text-gray-500"></i>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<p-inputgroup>
|
||||
<input
|
||||
pInputText
|
||||
id="captcha"
|
||||
type="text"
|
||||
placeholder="کد کپچا را وارد کنید"
|
||||
class="w-full"
|
||||
formControlName="captcha"
|
||||
/>
|
||||
|
||||
<p-inputgroup-addon>
|
||||
<p-button icon="pi pi-refresh" severity="secondary" variant="text" (click)="onRefreshCaptchaImage()" />
|
||||
</p-inputgroup-addon>
|
||||
</p-inputgroup>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
@if (loginForm.controls.role.value === "TEACHER") {
|
||||
<span class="w-full text-center text-sm select-none">
|
||||
برای ثبت نام در سامانه <a class="text-primary cursor-pointer" (click)="toSignup()">اینجا</a> کلیک کنید.
|
||||
</span>
|
||||
}
|
||||
<p-button label="ورود" styleClass="w-full" size="large" [loading]="isLoading()" type="submit"></p-button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AuthService } from '@/core';
|
||||
import { IAuthResponse, LoginCredentials, TRoles } from '@/core/models';
|
||||
import { ToastService } from '@/core/services/toast.service';
|
||||
import { UikitLabelComponent } from '@/uikit';
|
||||
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, EventEmitter, inject, Input, Output, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Button } from 'primeng/button';
|
||||
import { ImageModule } from 'primeng/image';
|
||||
import { InputGroupModule } from 'primeng/inputgroup';
|
||||
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
|
||||
import { InputText } from 'primeng/inputtext';
|
||||
import { Password } from 'primeng/password';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { RadioButtonModule } from 'primeng/radiobutton';
|
||||
import { CaptchaService } from 'src/app/core/services/captcha.service';
|
||||
import { CENTRAL_AUTH_ROLES } from '../../constants/central-auth-roles.const';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
templateUrl: './login.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
Button,
|
||||
Password,
|
||||
InputText,
|
||||
ImageModule,
|
||||
ProgressSpinnerModule,
|
||||
UikitLabelComponent,
|
||||
RadioButtonModule,
|
||||
InputGroupModule,
|
||||
InputGroupAddonModule,
|
||||
UikitFieldComponent,
|
||||
],
|
||||
})
|
||||
export class LoginComponent {
|
||||
@Input() redirectUrl!: string;
|
||||
@Input() loginApiUrl!: string;
|
||||
@Input() defaultRole?: TRoles;
|
||||
|
||||
@Output() onSuccessfullySubmit = new EventEmitter<IAuthResponse>();
|
||||
@Output() onToSignup = new EventEmitter<void>();
|
||||
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly captchaService = inject(CaptchaService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
|
||||
readonly isLoading = computed(() => this.authService.isLoading());
|
||||
readonly errorMessage = signal<string>('');
|
||||
readonly hidePassword = signal<boolean>(true);
|
||||
readonly isCaptchaExpired = this.captchaService.captchaIsExpired;
|
||||
readonly isCaptchaLoading = this.captchaService.loading;
|
||||
readonly captchaImageSrc = this.captchaService.captchaImageSrc;
|
||||
readonly centralAuthRoles = CENTRAL_AUTH_ROLES;
|
||||
|
||||
readonly loginForm = this.fb.group({
|
||||
username: ['', [Validators.required]],
|
||||
password: ['', [Validators.required]],
|
||||
rememberMe: [false],
|
||||
captcha: ['', [Validators.required]],
|
||||
role: [this.defaultRole || this.centralAuthRoles[0].key, [Validators.required]],
|
||||
});
|
||||
|
||||
selectedRole = signal<TRoles>('SCHOOL');
|
||||
ngOnInit() {
|
||||
this.captchaService.requestNewCaptcha();
|
||||
}
|
||||
|
||||
togglePasswordVisibility(): void {
|
||||
this.hidePassword.set(!this.hidePassword());
|
||||
}
|
||||
|
||||
resetCaptcha(): void {
|
||||
this.fb.control('captcha').setValue('');
|
||||
this.captchaService.renewCaptcha();
|
||||
}
|
||||
|
||||
onRefreshCaptcha(): void {
|
||||
this.resetCaptcha();
|
||||
}
|
||||
onRefreshCaptchaImage(): void {
|
||||
this.resetCaptcha();
|
||||
// document
|
||||
// .getElementById('captchaImage')
|
||||
// ?.setAttribute('src', this.captchaService.captchaImageSrc()! + `&${new Date().getTime()}`);
|
||||
}
|
||||
|
||||
toSignup() {
|
||||
this.onToSignup.emit();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.loginForm.markAllAsTouched();
|
||||
if (this.loginForm.invalid) return;
|
||||
if (!this.captchaService.captchaId()) {
|
||||
this.toastService.error({ text: 'مقدار کپچا را وارد کنید' });
|
||||
}
|
||||
const credentials: LoginCredentials = this.loginForm.value as LoginCredentials;
|
||||
|
||||
this.authService
|
||||
.login(credentials, (this.defaultRole || this.loginForm.value.role)!)
|
||||
.subscribe({
|
||||
next: (data) => {
|
||||
this.errorMessage.set('');
|
||||
if (this.redirectUrl) {
|
||||
this.router.navigateByUrl(this.redirectUrl.replace(/\/[^/]*$/, ''));
|
||||
} else {
|
||||
this.onSuccessfullySubmit.emit(data);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.resetCaptcha();
|
||||
this.errorMessage.set('نام کاربری یا رمز عبور اشتباه است');
|
||||
console.error('Login failed:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<form [formGroup]="infoForm" class="w-full flex flex-col gap-4" (ngSubmit)="submit()">
|
||||
<p-message severity="warn" [closable]="false" variant="text" icon="pi pi-info-circle" class="mb-5">
|
||||
برای ادامه میبایست اطلاعات ورود خود را به روز کنید
|
||||
</p-message>
|
||||
<uikit-field name="username" label="نام کاربری" pSize="large" class="" [control]="infoForm.get('username')">
|
||||
<input
|
||||
pInputText
|
||||
id="username"
|
||||
pSize="large"
|
||||
formControlName="username"
|
||||
[invalid]="infoForm.get('username')?.touched && infoForm.get('username')?.invalid"
|
||||
/>
|
||||
</uikit-field>
|
||||
|
||||
<uikit-field label="رمز عبور" pSize="large" class="" [control]="infoForm.get('password')">
|
||||
<p-password
|
||||
id="password1"
|
||||
size="large"
|
||||
name="password"
|
||||
formControlName="password"
|
||||
autocomplete="password"
|
||||
[toggleMask]="true"
|
||||
[fluid]="true"
|
||||
[feedback]="false"
|
||||
[invalid]="infoForm.get('password')?.touched && infoForm.get('password')?.invalid"
|
||||
/>
|
||||
<span class="text-xs mt-2 text-muted-color">
|
||||
رمز باید حداقل ۸ کاراکتر بوده و شامل حداقل یک حرف بزرگ، یک حرف کوچک، یک عدد و یک کاراکتر ویژه باشد.
|
||||
</span>
|
||||
</uikit-field>
|
||||
|
||||
<uikit-field label="تکرار رمز عبور" pSize="large" class="" [control]="infoForm.get('confirmPassword')">
|
||||
<p-password
|
||||
id="confirmPassword"
|
||||
size="large"
|
||||
name="confirmPassword"
|
||||
formControlName="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
[toggleMask]="true"
|
||||
[fluid]="true"
|
||||
[feedback]="false"
|
||||
[invalid]="infoForm.get('confirmPassword')?.touched && infoForm.get('confirmPassword')?.invalid"
|
||||
/>
|
||||
</uikit-field>
|
||||
|
||||
<uikit-field label="شماره تلفن همراه" pSize="large" class="" [control]="infoForm.get('mobile')">
|
||||
<input
|
||||
pInputText
|
||||
pSize="large"
|
||||
formControlName="mobile"
|
||||
[invalid]="infoForm.get('mobile')?.touched && infoForm.get('mobile')?.invalid"
|
||||
/>
|
||||
</uikit-field>
|
||||
|
||||
<uikit-field label="آدرس ایمیل" pSize="large" class="" [control]="infoForm.get('email')">
|
||||
<input
|
||||
pInputText
|
||||
pSize="large"
|
||||
formControlName="email"
|
||||
[invalid]="infoForm.get('email')?.touched && infoForm.get('email')?.invalid"
|
||||
/>
|
||||
</uikit-field>
|
||||
|
||||
<p-button label="تایید" styleClass="w-full mt-4" size="large" [loading]="isLoading()" type="submit"></p-button>
|
||||
</form>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { AuthService } from '@/core';
|
||||
import { IUserLoginInfo, TRoles } from '@/core/models';
|
||||
import { ToastService } from '@/core/services/toast.service';
|
||||
import { MustMatch } from '@/core/validators';
|
||||
import { password } from '@/core/validators/password.validator';
|
||||
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Button } from 'primeng/button';
|
||||
import { ImageModule } from 'primeng/image';
|
||||
import { InputGroupModule } from 'primeng/inputgroup';
|
||||
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
|
||||
import { InputText } from 'primeng/inputtext';
|
||||
import { Message } from 'primeng/message';
|
||||
import { Password } from 'primeng/password';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { RadioButtonModule } from 'primeng/radiobutton';
|
||||
|
||||
@Component({
|
||||
selector: 'app-modify-login-info',
|
||||
templateUrl: './modify-login-info.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
Button,
|
||||
Password,
|
||||
InputText,
|
||||
ImageModule,
|
||||
ProgressSpinnerModule,
|
||||
RadioButtonModule,
|
||||
InputGroupModule,
|
||||
InputGroupAddonModule,
|
||||
UikitFieldComponent,
|
||||
Message,
|
||||
],
|
||||
})
|
||||
export class ModifyLoginInfoComponent {
|
||||
@Input() userLoginInfo!: Partial<IUserLoginInfo>;
|
||||
@Input() role!: TRoles;
|
||||
|
||||
@Output() onSubmit = new EventEmitter<IUserLoginInfo>();
|
||||
@Output() toLogin = new EventEmitter<void>();
|
||||
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly toastService = inject(ToastService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
readonly isLoading = signal<boolean>(false);
|
||||
readonly errorMessage = signal<string>('');
|
||||
readonly hidePassword = signal<boolean>(true);
|
||||
|
||||
readonly infoForm = this.fb.group(
|
||||
{
|
||||
username: [this.userLoginInfo?.username, [Validators.required]],
|
||||
password: ['', [Validators.required, password()]],
|
||||
confirmPassword: ['', [Validators.required]],
|
||||
mobile: [this.userLoginInfo?.mobile, [Validators.required]],
|
||||
email: [this.userLoginInfo?.email, [Validators.required, Validators.email]],
|
||||
},
|
||||
{
|
||||
validators: [MustMatch('password', 'confirmPassword')],
|
||||
},
|
||||
);
|
||||
|
||||
ngOnInit() {
|
||||
this.authService.getInfo(this.role).subscribe({
|
||||
next: (info) => {
|
||||
this.infoForm.patchValue(info);
|
||||
},
|
||||
error: () => {
|
||||
this.toLogin.emit();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.infoForm.markAllAsTouched();
|
||||
if (this.infoForm.invalid) return;
|
||||
const credentials: IUserLoginInfo = this.infoForm.value as IUserLoginInfo;
|
||||
this.isLoading.set(true);
|
||||
this.authService.changeInfo(credentials, this.role).subscribe({
|
||||
next: () => {
|
||||
this.toastService.success({
|
||||
text: 'اطلاعات با موفقیت بهروزرسانی شد',
|
||||
});
|
||||
this.errorMessage.set('');
|
||||
this.onSubmit?.emit();
|
||||
this.isLoading.set(false);
|
||||
// this.router.navigateByUrl(this.redirectUrl.replace(/\/[^/]*$/, ''));
|
||||
},
|
||||
error: (error) => {
|
||||
this.isLoading.set(false);
|
||||
// this.errorMessage.set('نام کاربری یا رمز عبور اشتباه است');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<form class="flex justify-center flex-col gap-3 items-center px-6" (ngSubmit)="submit()">
|
||||
<p-inputotp name="otp" size="large" [length]="5" [(ngModel)]="otpCode" />
|
||||
<span class="text-muted-color"> کد ارسال شده را وارد کنید. </span>
|
||||
<button pButton class="w-full mt-5" [loading]="submitLoading()">ثبت</button>
|
||||
</form>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonDirective } from 'primeng/button';
|
||||
import { InputOtpModule } from 'primeng/inputotp';
|
||||
|
||||
@Component({
|
||||
selector: 'app-otp',
|
||||
standalone: true,
|
||||
imports: [CommonModule, InputOtpModule, FormsModule, ButtonDirective],
|
||||
templateUrl: './otp.component.html',
|
||||
})
|
||||
export class OTPComponent {
|
||||
otpCode = signal<string>('');
|
||||
submitLoading = signal<boolean>(false);
|
||||
|
||||
submit = () => {};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<form [formGroup]="form" class="w-full flex flex-col gap-4" (ngSubmit)="submit()">
|
||||
<app-input label="نام" name="firstName" size="large" [control]="form.controls.firstName" />
|
||||
<app-input label="نام خانوادگی" name="lastName" size="large" [control]="form.controls.lastName" />
|
||||
<app-gender-select [control]="form.controls.gender" size="large" />
|
||||
<app-input
|
||||
label="شماره تلفن همراه"
|
||||
name="mobile"
|
||||
size="large"
|
||||
autocomplete="tel"
|
||||
[control]="form.controls.mobile"
|
||||
type="mobile"
|
||||
/>
|
||||
<app-input
|
||||
label="آدرس ایمیل"
|
||||
name="email"
|
||||
size="large"
|
||||
autocomplete="email"
|
||||
[control]="form.controls.email"
|
||||
type="email"
|
||||
/>
|
||||
<app-input
|
||||
label="نام کاربری"
|
||||
name="username"
|
||||
size="large"
|
||||
autocomplete="username"
|
||||
[control]="form.controls.username"
|
||||
/>
|
||||
<uikit-field label="رمز عبور" name="password" pSize="large" [control]="form.controls.password">
|
||||
<p-password
|
||||
id="password1"
|
||||
size="large"
|
||||
name="password"
|
||||
formControlName="password"
|
||||
autocomplete="password"
|
||||
[toggleMask]="true"
|
||||
[fluid]="true"
|
||||
[feedback]="false"
|
||||
[invalid]="form.controls.password.touched && form.controls.password.invalid"
|
||||
/>
|
||||
<span class="text-xs mt-2 text-muted-color">
|
||||
رمز باید حداقل ۸ کاراکتر بوده و شامل حداقل یک حرف بزرگ، یک حرف کوچک، یک عدد و یک کاراکتر ویژه باشد.
|
||||
</span>
|
||||
</uikit-field>
|
||||
|
||||
<uikit-field label="تکرار رمز عبور" pSize="large" name="confirmPassword" [control]="form.controls.confirmPassword">
|
||||
<p-password
|
||||
id="confirmPassword"
|
||||
size="large"
|
||||
name="confirmPassword"
|
||||
formControlName="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
[toggleMask]="true"
|
||||
[fluid]="true"
|
||||
[feedback]="false"
|
||||
[invalid]="form.controls.confirmPassword.touched && form.controls.confirmPassword.invalid"
|
||||
/>
|
||||
</uikit-field>
|
||||
|
||||
<p-button label="تایید" styleClass="w-full mt-4" size="large" [loading]="isLoading()" type="submit"></p-button>
|
||||
</form>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { AuthService } from '@/core';
|
||||
import { ISignupRequestPayload, IUserLoginInfo, Maybe, TRoles } from '@/core/models';
|
||||
import { ToastService } from '@/core/services/toast.service';
|
||||
import { mobileValidator, MustMatch } from '@/core/validators';
|
||||
import { password } from '@/core/validators/password.validator';
|
||||
import { GenderSelectComponent } from '@/shared/catalog';
|
||||
import { InputComponent } from '@/shared/components';
|
||||
import { UikitFieldComponent } from '@/uikit/uikit-field.component';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, inject, Input, Output, signal } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Button } from 'primeng/button';
|
||||
import { ImageModule } from 'primeng/image';
|
||||
import { InputGroupModule } from 'primeng/inputgroup';
|
||||
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
|
||||
import { Password } from 'primeng/password';
|
||||
import { ProgressSpinnerModule } from 'primeng/progressspinner';
|
||||
import { RadioButtonModule } from 'primeng/radiobutton';
|
||||
|
||||
@Component({
|
||||
selector: 'auth-signup',
|
||||
templateUrl: './signup.component.html',
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
Button,
|
||||
Password,
|
||||
ImageModule,
|
||||
ProgressSpinnerModule,
|
||||
RadioButtonModule,
|
||||
InputGroupModule,
|
||||
InputGroupAddonModule,
|
||||
UikitFieldComponent,
|
||||
InputComponent,
|
||||
GenderSelectComponent,
|
||||
],
|
||||
})
|
||||
export class SignupComponent {
|
||||
@Input() userLoginInfo!: Partial<IUserLoginInfo>;
|
||||
@Input() role!: TRoles;
|
||||
|
||||
@Output() onSubmit = new EventEmitter<IUserLoginInfo>();
|
||||
@Output() toLogin = new EventEmitter<void>();
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly toastService: ToastService,
|
||||
private readonly router: Router,
|
||||
) {}
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
readonly isLoading = signal<boolean>(false);
|
||||
readonly errorMessage = signal<string>('');
|
||||
readonly hidePassword = signal<boolean>(true);
|
||||
|
||||
readonly form = this.fb.group(
|
||||
{
|
||||
firstName: ['', [Validators.required]],
|
||||
lastName: ['', [Validators.required]],
|
||||
gender: [null as Maybe<boolean>, [Validators.required]],
|
||||
mobile: ['', [Validators.required, mobileValidator()]],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
username: ['', [Validators.required]],
|
||||
password: ['', [Validators.required, password()]],
|
||||
confirmPassword: ['', [Validators.required]],
|
||||
},
|
||||
{
|
||||
validators: [MustMatch('password', 'confirmPassword')],
|
||||
},
|
||||
);
|
||||
|
||||
submit(): void {
|
||||
this.form.markAllAsTouched();
|
||||
|
||||
if (this.form.invalid) return;
|
||||
const credentials = this.form.value as ISignupRequestPayload;
|
||||
this.isLoading.set(true);
|
||||
this.authService.signup(credentials, this.role).subscribe({
|
||||
next: () => {
|
||||
this.toastService.success({
|
||||
text: 'ثبت نام با موفقیت انجام شد. لطفا وارد شوید.',
|
||||
});
|
||||
this.errorMessage.set('');
|
||||
this.onSubmit?.emit();
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
error: (error) => {
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { computed, inject, Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, of, tap } from 'rxjs';
|
||||
import { LOCAL_STORAGE_KEYS, ROLES } from 'src/assets/constants';
|
||||
import { IAuthResponse, LoginCredentials, Maybe, TRoles, User } from '../../../core/models';
|
||||
import { BaseState, BaseStore } from '../../../core/state/base-store';
|
||||
|
||||
/**
|
||||
* Authentication state
|
||||
*/
|
||||
export interface AuthState extends BaseState {
|
||||
user: Maybe<User>;
|
||||
token: Maybe<string>;
|
||||
refreshToken: Maybe<string>;
|
||||
isAuthenticated: boolean;
|
||||
permissions: string[];
|
||||
loginAttempts: number;
|
||||
lastLoginTime: Maybe<number>;
|
||||
sessionExpiry: Maybe<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Login state for UI feedback
|
||||
*/
|
||||
export interface LoginState {
|
||||
isLoggingIn: boolean;
|
||||
isRefreshing: boolean;
|
||||
loginError: Maybe<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication state store
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthStore extends BaseStore<AuthState> {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// Login-specific state
|
||||
private readonly _loginState = {
|
||||
isLoggingIn: false,
|
||||
isRefreshing: false,
|
||||
loginError: null as Maybe<string>,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
loading: false,
|
||||
error: null,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
permissions: [],
|
||||
loginAttempts: 0,
|
||||
lastLoginTime: null,
|
||||
sessionExpiry: null,
|
||||
});
|
||||
|
||||
this.initializeAuth();
|
||||
}
|
||||
|
||||
// Computed selectors
|
||||
readonly user = computed(() => this._state().user);
|
||||
readonly token = computed(() => this._state().token);
|
||||
readonly refreshToken = computed(() => this._state().refreshToken);
|
||||
readonly isAuthenticated = computed(() => this._state().isAuthenticated);
|
||||
readonly userRole = computed(() => this._state().user?.role);
|
||||
readonly permissions = computed(() => this._state().permissions);
|
||||
readonly loginAttempts = computed(() => this._state().loginAttempts);
|
||||
readonly lastLoginTime = computed(() => this._state().lastLoginTime);
|
||||
readonly sessionExpiry = computed(() => this._state().sessionExpiry);
|
||||
|
||||
// Login state selectors
|
||||
readonly isLoggingIn = computed(() => this._loginState.isLoggingIn);
|
||||
readonly isRefreshing = computed(() => this._loginState.isRefreshing);
|
||||
readonly loginError = computed(() => this._loginState.loginError);
|
||||
|
||||
/**
|
||||
* Initialize authentication from stored data
|
||||
*/
|
||||
private initializeAuth(): void {
|
||||
const token = localStorage.getItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN);
|
||||
const refreshToken = localStorage.getItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN);
|
||||
const userData = localStorage.getItem(LOCAL_STORAGE_KEYS.USER_DATA);
|
||||
const loginAttempts = parseInt(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS) || '0');
|
||||
const lastLoginTime = parseInt(localStorage.getItem(LOCAL_STORAGE_KEYS.LAST_LOGIN_TIME) || '0');
|
||||
|
||||
if (token && refreshToken && userData) {
|
||||
try {
|
||||
const user = JSON.parse(userData) as User;
|
||||
const sessionExpiry = this.getTokenExpiry(token);
|
||||
|
||||
this.patchState({
|
||||
user,
|
||||
token,
|
||||
refreshToken,
|
||||
isAuthenticated: true,
|
||||
permissions: [],
|
||||
// permissions: user.permissions?.map((p) => `${p.resource}:${p.action}`) || [],
|
||||
loginAttempts,
|
||||
lastLoginTime: lastLoginTime || null,
|
||||
sessionExpiry,
|
||||
});
|
||||
|
||||
// Check if token is expired
|
||||
if (this.isTokenExpired()) {
|
||||
this.refreshAuthToken().subscribe();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored user data:', error);
|
||||
this.logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with credentials
|
||||
*/
|
||||
login(credentials: LoginCredentials) {
|
||||
this._loginState.isLoggingIn = true;
|
||||
this._loginState.loginError = null;
|
||||
this.setLoading(true);
|
||||
|
||||
return this.http.post<IAuthResponse>('/api/auth/login', credentials).pipe(
|
||||
tap((response) => {
|
||||
this.handleAuthSuccess(response);
|
||||
this._loginState.isLoggingIn = false;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.handleAuthError(error);
|
||||
return of(null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
logout(): void {
|
||||
// Clear stored data
|
||||
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);
|
||||
|
||||
// Reset state
|
||||
this.patchState({
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
permissions: [],
|
||||
lastLoginTime: null,
|
||||
sessionExpiry: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Reset login state
|
||||
this._loginState.isLoggingIn = false;
|
||||
this._loginState.isRefreshing = false;
|
||||
this._loginState.loginError = null;
|
||||
|
||||
// Navigate to login
|
||||
this.router.navigate(['/auth']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh authentication token
|
||||
*/
|
||||
refreshAuthToken() {
|
||||
const refreshToken = this._state().refreshToken;
|
||||
if (!refreshToken) {
|
||||
this.logout();
|
||||
return of(null);
|
||||
}
|
||||
|
||||
this._loginState.isRefreshing = true;
|
||||
this.setLoading(true);
|
||||
|
||||
return this.http.post<IAuthResponse>('/api/auth/refresh', { refreshToken }).pipe(
|
||||
tap((response) => {
|
||||
this.handleAuthSuccess(response);
|
||||
this._loginState.isRefreshing = false;
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Token refresh failed:', error);
|
||||
this.logout();
|
||||
return of(null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific role
|
||||
*/
|
||||
hasRole(role: TRoles): boolean {
|
||||
return this._state().user?.role === role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified roles
|
||||
*/
|
||||
hasAnyRole(roles: TRoles[]): boolean {
|
||||
const userRole = this._state().user?.role;
|
||||
return userRole ? roles.includes(userRole) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific permission
|
||||
*/
|
||||
hasPermission(permission: string): boolean {
|
||||
const permissions = this._state().permissions;
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has all specified permissions
|
||||
*/
|
||||
hasAllPermissions(permissions: string[]): boolean {
|
||||
const userPermissions = this._state().permissions;
|
||||
return permissions.every((permission) => userPermissions.includes(permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified permissions
|
||||
*/
|
||||
hasAnyPermission(permissions: string[]): boolean {
|
||||
const userPermissions = this._state().permissions;
|
||||
return permissions.some((permission) => userPermissions.includes(permission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access resource
|
||||
*/
|
||||
canAccess(requiredRoles?: TRoles[], requiredPermissions?: string[]): boolean {
|
||||
if (!this._state().isAuthenticated) return false;
|
||||
|
||||
if (requiredRoles && !this.hasAnyRole(requiredRoles)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requiredPermissions && !this.hasAllPermissions(requiredPermissions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*/
|
||||
getToken(): string | null {
|
||||
return this._state().token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current refresh token
|
||||
*/
|
||||
getRefreshToken(): string | null {
|
||||
return this._state().refreshToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
isTokenExpired(): boolean {
|
||||
const sessionExpiry = this._state().sessionExpiry;
|
||||
if (!sessionExpiry) return true;
|
||||
|
||||
return Date.now() > sessionExpiry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until token expires (in minutes)
|
||||
*/
|
||||
getTimeUntilExpiry(): number {
|
||||
const sessionExpiry = this._state().sessionExpiry;
|
||||
if (!sessionExpiry) return 0;
|
||||
|
||||
return Math.max(0, Math.floor((sessionExpiry - Date.now()) / 60000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
updateUserProfile(updates: Partial<User>): void {
|
||||
const currentUser = this._state().user;
|
||||
if (!currentUser) return;
|
||||
|
||||
const updatedUser = { ...currentUser, ...updates };
|
||||
|
||||
this.patchState({ user: updatedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.USER_DATA, JSON.stringify(updatedUser));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful authentication
|
||||
*/
|
||||
private handleAuthSuccess(response: IAuthResponse): void {
|
||||
const sessionExpiry = this.getTokenExpiry(response.token);
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Store in localStorage
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.AUTH_TOKEN, response.token);
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.REFRESH_TOKEN, response.refreshToken);
|
||||
// localStorage.setItem(LOCAL_STORAGE_KEYS.USER_DATA, JSON.stringify(response.user));
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LAST_LOGIN_TIME, currentTime.toString());
|
||||
|
||||
// Update state
|
||||
this.patchState({
|
||||
user: { fullName: response.fullName, role: response.role as TRoles },
|
||||
token: response.token,
|
||||
refreshToken: response.refreshToken,
|
||||
isAuthenticated: true,
|
||||
// permissions: response.user.permissions?.map((p) => `${p.resource}:${p.action}`) || [],
|
||||
lastLoginTime: currentTime,
|
||||
sessionExpiry,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Reset login attempts on successful login
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS);
|
||||
|
||||
// Navigate based on user role
|
||||
// this.navigateByRole(response.user.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication error
|
||||
*/
|
||||
private handleAuthError(error: any): void {
|
||||
console.error('Authentication error:', error);
|
||||
|
||||
// Increment login attempts
|
||||
const attempts = this._state().loginAttempts + 1;
|
||||
this.patchState({ loginAttempts: attempts });
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_ATTEMPTS, attempts.toString());
|
||||
|
||||
// Set login error
|
||||
this._loginState.isLoggingIn = false;
|
||||
this._loginState.loginError = error.error?.message || 'خطا در ورود به سیستم';
|
||||
|
||||
if (this._loginState.loginError) {
|
||||
this.setError(this._loginState.loginError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate user based on their role
|
||||
*/
|
||||
private navigateByRole(role: TRoles): void {
|
||||
const roleRoutes = {
|
||||
[ROLES.ADMIN]: '/admin',
|
||||
[ROLES.SCHOOL]: '/schools',
|
||||
[ROLES.TEACHER]: '/teachers',
|
||||
[ROLES.STUDENTS]: '/students',
|
||||
[ROLES.GRADER]: '/grader',
|
||||
[ROLES.SUPERADMIN]: '/superadmin',
|
||||
};
|
||||
|
||||
this.router.navigate([roleRoutes[role]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token expiry from JWT
|
||||
*/
|
||||
private getTokenExpiry(token: string): number | null {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp * 1000; // Convert to milliseconds
|
||||
} catch (error) {
|
||||
console.error('Error parsing token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state to initial values
|
||||
*/
|
||||
reset(): void {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: null,
|
||||
user: null,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
permissions: [],
|
||||
loginAttempts: 0,
|
||||
lastLoginTime: null,
|
||||
sessionExpiry: null,
|
||||
});
|
||||
|
||||
this._loginState.isLoggingIn = false;
|
||||
this._loginState.isRefreshing = false;
|
||||
this._loginState.loginError = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Authentication state management
|
||||
export * from './auth.store';
|
||||
|
||||
// Types
|
||||
export type { AuthState, LoginState } from './auth.store';
|
||||
Reference in New Issue
Block a user