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:
2025-12-04 21:07:18 +03:30
parent c58210cdbd
commit 07fec02ea1
164 changed files with 20175 additions and 735 deletions
@@ -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);
},
});
}
}