progress in base system
8
.env
|
|
@ -24,9 +24,9 @@ APP_SECRET=
|
||||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||||
#
|
#
|
||||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
|
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
|
||||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||||
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||||
###< doctrine/doctrine-bundle ###
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
###> symfony/messenger ###
|
###> symfony/messenger ###
|
||||||
|
|
@ -39,3 +39,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||||
###> symfony/mailer ###
|
###> symfony/mailer ###
|
||||||
MAILER_DSN=null://null
|
MAILER_DSN=null://null
|
||||||
###< symfony/mailer ###
|
###< symfony/mailer ###
|
||||||
|
|
||||||
|
###> nelmio/cors-bundle ###
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||||
|
###< nelmio/cors-bundle ###
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ return array (
|
||||||
'APP_ENV' => 'dev',
|
'APP_ENV' => 'dev',
|
||||||
'SYMFONY_DOTENV_PATH' => './../.env',
|
'SYMFONY_DOTENV_PATH' => './../.env',
|
||||||
'APP_SECRET' => '6c6ccf94990dea080eeba986bf7e23af',
|
'APP_SECRET' => '6c6ccf94990dea080eeba986bf7e23af',
|
||||||
'DATABASE_URL' => 'postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8',
|
'DATABASE_URL' => 'mysql://root:136431@127.0.0.1:3306/hsx?serverVersion=8.0.32&charset=utf8mb4',
|
||||||
'MESSENGER_TRANSPORT_DSN' => 'doctrine://default?auto_setup=0',
|
'MESSENGER_TRANSPORT_DSN' => 'doctrine://default?auto_setup=0',
|
||||||
'MAILER_DSN' => 'null://null',
|
'MAILER_DSN' => 'null://null',
|
||||||
|
'CORS_ALLOW_ORIGIN' => '^https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?$',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
40
README.md
|
|
@ -13,7 +13,9 @@
|
||||||
│ │ ├── views/ # صفحات اصلی
|
│ │ ├── views/ # صفحات اصلی
|
||||||
│ │ ├── router/ # Vue Router
|
│ │ ├── router/ # Vue Router
|
||||||
│ │ ├── store/ # Vuex store
|
│ │ ├── store/ # Vuex store
|
||||||
│ │ └── assets/ # فایلهای استاتیک
|
│ │ ├── assets/ # فایلهای استاتیک
|
||||||
|
│ │ │ └── styles/ # فایلهای CSS
|
||||||
|
│ │ └── i18n/ # بینالمللیسازی
|
||||||
│ ├── public/
|
│ ├── public/
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ └── webpack.config.js
|
│ └── webpack.config.js
|
||||||
|
|
@ -87,10 +89,33 @@ npm run build # Build production
|
||||||
## 🎨 UI/UX Features
|
## 🎨 UI/UX Features
|
||||||
|
|
||||||
- **RTL Support**: پشتیبانی کامل از راست به چپ
|
- **RTL Support**: پشتیبانی کامل از راست به چپ
|
||||||
- **Persian Font**: فونت Vazirmatn
|
- **Persian Font**: فونت Vazir و Tahoma
|
||||||
- **Material Design**: کامپوننتهای زیبا
|
- **Material Design**: کامپوننتهای زیبا
|
||||||
- **Responsive**: سازگار با همه دستگاهها
|
- **Responsive**: سازگار با همه دستگاهها
|
||||||
- **Dark/Light Theme**: تمهای مختلف
|
- **Dark/Light Theme**: تمهای مختلف
|
||||||
|
- **Multilingual**: پشتیبانی از فارسی و انگلیسی
|
||||||
|
|
||||||
|
## 🌍 پشتیبانی از RTL (راستچین)
|
||||||
|
|
||||||
|
### ویژگیهای RTL
|
||||||
|
- **تغییر خودکار جهت**: صفحه به صورت خودکار راستچین/چپچین میشود
|
||||||
|
- **فونتهای مناسب**: Vazir برای فارسی، Roboto برای انگلیسی
|
||||||
|
- **کامپوننتهای Vuetify**: تمام کامپوننتها از RTL پشتیبانی میکنند
|
||||||
|
- **Responsive RTL**: سازگار با تمام اندازههای صفحه
|
||||||
|
|
||||||
|
### فایلهای RTL
|
||||||
|
- `src/assets/styles/rtl-ltr.css` - استایلهای پایه RTL/LTR
|
||||||
|
- `src/assets/styles/vuetify-rtl.css` - پشتیبانی RTL برای Vuetify
|
||||||
|
- `src/assets/styles/components-rtl.css` - RTL برای کامپوننتهای خاص
|
||||||
|
- `src/components/RTLTest.vue` - کامپوننت تست RTL
|
||||||
|
|
||||||
|
### نحوه استفاده
|
||||||
|
```javascript
|
||||||
|
// تغییر زبان و جهت
|
||||||
|
import { changeLocale } from './i18n'
|
||||||
|
changeLocale('fa') // فارسی - راستچین
|
||||||
|
changeLocale('en') // انگلیسی - چپچین
|
||||||
|
```
|
||||||
|
|
||||||
## 📊 کامپوننتهای Vuetify
|
## 📊 کامپوننتهای Vuetify
|
||||||
|
|
||||||
|
|
@ -129,7 +154,8 @@ npm run build
|
||||||
- Build process کاملاً مستقل است
|
- Build process کاملاً مستقل است
|
||||||
- Symfony به عنوان API backend عمل میکند
|
- Symfony به عنوان API backend عمل میکند
|
||||||
- Vue Router برای client-side routing استفاده میشود
|
- Vue Router برای client-side routing استفاده میشود
|
||||||
- فونت فارسی Vazirmatn برای RTL استفاده شده
|
- فونت فارسی Vazir برای RTL استفاده شده
|
||||||
|
- پشتیبانی کامل از RTL در تمام کامپوننتها
|
||||||
|
|
||||||
## 🐛 عیبیابی
|
## 🐛 عیبیابی
|
||||||
|
|
||||||
|
|
@ -137,14 +163,22 @@ npm run build
|
||||||
1. **Node modules**: `rm -rf node_modules && npm install`
|
1. **Node modules**: `rm -rf node_modules && npm install`
|
||||||
2. **Build errors**: بررسی webpack.config.js
|
2. **Build errors**: بررسی webpack.config.js
|
||||||
3. **Routing issues**: بررسی .htaccess
|
3. **Routing issues**: بررسی .htaccess
|
||||||
|
4. **RTL issues**: بررسی فایلهای CSS و i18n
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
- Symfony: `core/var/log/`
|
- Symfony: `core/var/log/`
|
||||||
- Webpack: `frontend/` console
|
- Webpack: `frontend/` console
|
||||||
|
|
||||||
|
## 📚 مستندات اضافی
|
||||||
|
|
||||||
|
- `frontend/RTL_SETUP.md` - راهنمای کامل RTL
|
||||||
|
- `frontend/MULTILINGUAL_SETUP.md` - راهنمای چندزبانه
|
||||||
|
- `frontend/THEME_SETUP.md` - راهنمای تمها
|
||||||
|
|
||||||
## 📞 پشتیبانی
|
## 📞 پشتیبانی
|
||||||
|
|
||||||
برای سوالات و مشکلات:
|
برای سوالات و مشکلات:
|
||||||
- بررسی documentation
|
- بررسی documentation
|
||||||
- بررسی console errors
|
- بررسی console errors
|
||||||
- بررسی network tab
|
- بررسی network tab
|
||||||
|
- تست کامپوننت RTLTest
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"doctrine/doctrine-bundle": "^2.15",
|
"doctrine/doctrine-bundle": "^2.15",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||||
"doctrine/orm": "^3.5",
|
"doctrine/orm": "^3.5",
|
||||||
|
"nelmio/cors-bundle": "^2.5",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/apache-pack": "*",
|
"symfony/apache-pack": "*",
|
||||||
|
|
|
||||||
64
core/composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "0c02e701f0667561c6de51c5995abb1f",
|
"content-hash": "42015cabfb93805b806ccf473df84f01",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "doctrine/collections",
|
"name": "doctrine/collections",
|
||||||
|
|
@ -1300,6 +1300,68 @@
|
||||||
],
|
],
|
||||||
"time": "2025-03-24T10:02:05+00:00"
|
"time": "2025-03-24T10:02:05+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "nelmio/cors-bundle",
|
||||||
|
"version": "2.5.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/nelmio/NelmioCorsBundle.git",
|
||||||
|
"reference": "3a526fe025cd20e04a6a11370cf5ab28dbb5a544"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3a526fe025cd20e04a6a11370cf5ab28dbb5a544",
|
||||||
|
"reference": "3a526fe025cd20e04a6a11370cf5ab28dbb5a544",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"psr/log": "^1.0 || ^2.0 || ^3.0",
|
||||||
|
"symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.3.6",
|
||||||
|
"symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Nelmio\\CorsBundle\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nelmio",
|
||||||
|
"homepage": "http://nelm.io"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application",
|
||||||
|
"keywords": [
|
||||||
|
"api",
|
||||||
|
"cors",
|
||||||
|
"crossdomain"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/nelmio/NelmioCorsBundle/issues",
|
||||||
|
"source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.5.0"
|
||||||
|
},
|
||||||
|
"time": "2024-06-24T21:25:28+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpdocumentor/reflection-common",
|
"name": "phpdocumentor/reflection-common",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,5 @@ return [
|
||||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
|
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
|
||||||
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
13
core/config/packages/nelmio_cors.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
nelmio_cors:
|
||||||
|
defaults:
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization']
|
||||||
|
expose_headers: ['Link']
|
||||||
|
max_age: 3600
|
||||||
|
paths:
|
||||||
|
'^/api/':
|
||||||
|
allow_origin: ['*']
|
||||||
|
allow_headers: ['*']
|
||||||
|
allow_methods: ['POST', 'PUT', 'GET', 'DELETE', 'OPTIONS']
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
framework:
|
framework:
|
||||||
default_locale: en
|
default_locale: fa
|
||||||
translator:
|
translator:
|
||||||
default_path: '%kernel.project_dir%/translations'
|
default_path: '%kernel.project_dir%/translations'
|
||||||
|
fallbacks:
|
||||||
|
- fa
|
||||||
providers:
|
providers:
|
||||||
|
app:
|
||||||
|
dsn: 'app://translations'
|
||||||
|
set_content_language_from_locale: true
|
||||||
|
set_locale_from_accept_language: true
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
twig:
|
twig:
|
||||||
file_name_pattern: '*.twig'
|
file_name_pattern: '*.twig'
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
twig:
|
twig:
|
||||||
strict_variables: true
|
strict_variables: true
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,22 @@ services:
|
||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
|
||||||
|
# Locale Listener
|
||||||
|
App\EventListener\LocaleListener:
|
||||||
|
arguments:
|
||||||
|
$requestStack: '@request_stack'
|
||||||
|
$localeSwitcher: '@translation.locale_switcher'
|
||||||
|
tags:
|
||||||
|
- { name: kernel.event_listener, event: kernel.request, priority: 17 }
|
||||||
|
|
||||||
|
# Event Listeners
|
||||||
|
App\EventListener\Auth\LoginAuditListener:
|
||||||
|
tags:
|
||||||
|
- { name: kernel.event_listener, event: App\Event\Auth\UserLoginEvent, method: onEvent, priority: 100 }
|
||||||
|
|
||||||
|
App\EventListener\Auth\UserActivityListener:
|
||||||
|
tags:
|
||||||
|
- { name: kernel.event_listener, event: App\Event\Auth\UserLoginEvent, method: onEvent, priority: 200 }
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|
|
||||||
31
core/migrations/Version20241201000001.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20241201000001 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add lastLoginAt and loginCount fields to user table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE `user` ADD last_login_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE `user` ADD login_count INT NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE `user` DROP last_login_at');
|
||||||
|
$this->addSql('ALTER TABLE `user` DROP login_count');
|
||||||
|
}
|
||||||
|
}
|
||||||
282
core/src/Controller/Api/AuthController.php
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator;
|
||||||
|
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use App\Service\EventTransactionManager;
|
||||||
|
use App\Event\Auth\UserLoginEvent;
|
||||||
|
|
||||||
|
#[Route('/auth', name: 'api_auth_')]
|
||||||
|
class AuthController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private UserRepository $userRepository,
|
||||||
|
private UserPasswordHasherInterface $userPasswordHasher,
|
||||||
|
private TokenGeneratorInterface $tokenGenerator,
|
||||||
|
private MailerInterface $mailer,
|
||||||
|
private ValidatorInterface $validator,
|
||||||
|
private EventTransactionManager $eventTransactionManager
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/login', name: 'login', methods: ['POST'])]
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
if (!$data || !isset($data['email']) || !isset($data['password'])) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ایمیل و رمز عبور الزامی است'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userRepository->findByEmail($data['email']);
|
||||||
|
|
||||||
|
if (!$user || !$this->userPasswordHasher->isPasswordValid($user, $data['password'])) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ایمیل یا رمز عبور اشتباه است'
|
||||||
|
], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ایجاد رویداد ورود کاربر
|
||||||
|
$loginEvent = new UserLoginEvent(
|
||||||
|
$user,
|
||||||
|
$request->getClientIp() ?? 'unknown',
|
||||||
|
$request->headers->get('User-Agent') ?? 'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
// اجرای عملیات با رویدادها
|
||||||
|
$this->eventTransactionManager->executeWithEvents(
|
||||||
|
function () {
|
||||||
|
// عملیات اصلی (فعلاً خالی)
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[$loginEvent]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'ورود موفقیتآمیز',
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'fullName' => $user->getFullName(),
|
||||||
|
'mobile' => $user->getMobile(),
|
||||||
|
'roles' => $user->getRoles(),
|
||||||
|
'lastLoginAt' => $user->getLastLoginAt()?->format('Y-m-d H:i:s'),
|
||||||
|
'loginCount' => $user->getLoginCount()
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در ورود: ' . $e->getMessage()
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/register', name: 'register', methods: ['POST'])]
|
||||||
|
public function register(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
if (!$data || !isset($data['email']) || !isset($data['password']) || !isset($data['fullName']) || !isset($data['mobile'])) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'تمام فیلدها الزامی است'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// بررسی تکراری نبودن ایمیل و موبایل
|
||||||
|
$existingUser = $this->userRepository->findByEmail($data['email']);
|
||||||
|
if ($existingUser) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'این ایمیل قبلاً ثبت شده است'
|
||||||
|
], Response::HTTP_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingUser = $this->userRepository->findByMobile($data['mobile']);
|
||||||
|
if ($existingUser) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'این شماره موبایل قبلاً ثبت شده است'
|
||||||
|
], Response::HTTP_CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail($data['email']);
|
||||||
|
$user->setMobile($data['mobile']);
|
||||||
|
$user->setFullName($data['fullName']);
|
||||||
|
$user->setPassword($this->userPasswordHasher->hashPassword($user, $data['password']));
|
||||||
|
$user->setRoles(['ROLE_USER']);
|
||||||
|
$user->setIsVerified(true);
|
||||||
|
|
||||||
|
// اعتبارسنجی entity
|
||||||
|
$errors = $this->validator->validate($user);
|
||||||
|
if (count($errors) > 0) {
|
||||||
|
$errorMessages = [];
|
||||||
|
foreach ($errors as $error) {
|
||||||
|
$errorMessages[] = $error->getMessage();
|
||||||
|
}
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در اعتبارسنجی دادهها',
|
||||||
|
'errors' => $errorMessages
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($user);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'ثبتنام با موفقیت انجام شد',
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'fullName' => $user->getFullName(),
|
||||||
|
'mobile' => $user->getMobile()
|
||||||
|
]
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/forgot-password', name: 'forgot_password', methods: ['POST'])]
|
||||||
|
public function forgotPassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
if (!$data || !isset($data['email'])) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'ایمیل الزامی است'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userRepository->findByEmail($data['email']);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
// برای امنیت، همیشه پیام موفقیت نمایش میدهیم
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'اگر ایمیل در سیستم موجود باشد، لینک بازنشانی ارسال خواهد شد'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resetToken = $this->tokenGenerator->generateToken();
|
||||||
|
$user->setResetToken($resetToken);
|
||||||
|
$user->setResetTokenExpiresAt(new \DateTimeImmutable('+1 hour'));
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
// ارسال ایمیل (در حالت واقعی باید template مناسب ایجاد شود)
|
||||||
|
$email = (new TemplatedEmail())
|
||||||
|
->from('noreply@hesabix.ir')
|
||||||
|
->to($user->getEmail())
|
||||||
|
->subject('درخواست بازنشانی رمز عبور')
|
||||||
|
->htmlTemplate('emails/reset_password.html.twig')
|
||||||
|
->context([
|
||||||
|
'resetToken' => $resetToken,
|
||||||
|
'user' => $user,
|
||||||
|
])
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->mailer->send($email);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'لینک بازنشانی رمز عبور به ایمیل شما ارسال شد'
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در ارسال ایمیل بازنشانی'
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/reset-password', name: 'reset_password', methods: ['POST'])]
|
||||||
|
public function resetPassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
if (!$data || !isset($data['token']) || !isset($data['password'])) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'توکن و رمز عبور جدید الزامی است'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userRepository->findByResetToken($data['token']);
|
||||||
|
|
||||||
|
if (!$user || $user->isResetTokenExpired()) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'توکن بازنشانی نامعتبر یا منقضی شده است'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->setPassword($this->userPasswordHasher->hashPassword($user, $data['password']));
|
||||||
|
$user->setResetToken(null);
|
||||||
|
$user->setResetTokenExpiresAt(null);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'رمز عبور شما با موفقیت تغییر یافت'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/logout', name: 'logout', methods: ['POST'])]
|
||||||
|
public function logout(): JsonResponse
|
||||||
|
{
|
||||||
|
// در اینجا میتوانید JWT token را invalid کنید یا session را پاک کنید
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'خروج موفقیتآمیز'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/me', name: 'me', methods: ['GET'])]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function me(): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user->getId(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'fullName' => $user->getFullName(),
|
||||||
|
'mobile' => $user->getMobile(),
|
||||||
|
'roles' => $user->getRoles(),
|
||||||
|
'isVerified' => $user->isVerified(),
|
||||||
|
'createdAt' => $user->getCreatedAt()->format('Y-m-d H:i:s')
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
core/src/Controller/Api/LocaleApiController.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Translation\LocaleSwitcher;
|
||||||
|
|
||||||
|
#[Route('/api/locale')]
|
||||||
|
final class LocaleApiController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('', name: 'api_locale_get', methods: ['GET'])]
|
||||||
|
public function getCurrentLocale(SessionInterface $session): JsonResponse
|
||||||
|
{
|
||||||
|
$locale = $session->get('_locale', 'fa');
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'locale' => $locale,
|
||||||
|
'direction' => $locale === 'fa' ? 'rtl' : 'ltr',
|
||||||
|
'language' => $locale === 'fa' ? 'فارسی' : 'English'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/change/{locale}', name: 'api_locale_change', methods: ['POST'], requirements: ['locale' => 'fa|en'])]
|
||||||
|
public function changeLocale(
|
||||||
|
string $locale,
|
||||||
|
Request $request,
|
||||||
|
SessionInterface $session,
|
||||||
|
LocaleSwitcher $localeSwitcher
|
||||||
|
): JsonResponse {
|
||||||
|
// Set locale in session
|
||||||
|
$session->set('_locale', $locale);
|
||||||
|
|
||||||
|
// Set locale for current request
|
||||||
|
$localeSwitcher->setLocale($locale);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'locale' => $locale,
|
||||||
|
'direction' => $locale === 'fa' ? 'rtl' : 'ltr',
|
||||||
|
'language' => $locale === 'fa' ? 'فارسی' : 'English',
|
||||||
|
'message' => 'زبان با موفقیت تغییر کرد'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
core/src/Controller/Api/TestEventController.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Event\Auth\UserLoginEvent;
|
||||||
|
use App\Service\EventTransactionManager;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
|
||||||
|
#[Route('/test', name: 'api_test_')]
|
||||||
|
class TestEventController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EventTransactionManager $eventTransactionManager
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/event-system', name: 'event_system', methods: ['GET'])]
|
||||||
|
public function testEventSystem(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// ایجاد یک کاربر نمونه برای تست
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail('test@example.com');
|
||||||
|
$user->setFullName('کاربر تست');
|
||||||
|
$user->setMobile('09123456789');
|
||||||
|
$user->setPassword('password');
|
||||||
|
$user->setRoles(['ROLE_USER']);
|
||||||
|
|
||||||
|
// ایجاد رویداد ورود
|
||||||
|
$loginEvent = new UserLoginEvent(
|
||||||
|
$user,
|
||||||
|
'127.0.0.1',
|
||||||
|
'Test Browser'
|
||||||
|
);
|
||||||
|
|
||||||
|
// اجرای عملیات با رویدادها
|
||||||
|
$result = $this->eventTransactionManager->executeWithEvents(
|
||||||
|
function () {
|
||||||
|
// عملیات اصلی (فعلاً خالی)
|
||||||
|
return 'عملیات اصلی با موفقیت انجام شد';
|
||||||
|
},
|
||||||
|
[$loginEvent]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'سیستم رویدادها با موفقیت کار کرد',
|
||||||
|
'result' => $result,
|
||||||
|
'eventData' => $loginEvent->getData(),
|
||||||
|
'eventSuccess' => $loginEvent->isSuccessful(),
|
||||||
|
'eventErrors' => $loginEvent->getErrors()
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'خطا در تست سیستم رویدادها: ' . $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller\Auth;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Form\Auth\RegistrationFormType;
|
|
||||||
use App\Repository\UserRepository;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
|
||||||
use Symfony\Component\Security\Http\Authentication\UserAuthenticatorInterface;
|
|
||||||
use Symfony\Component\Security\Http\Authenticator\FormLoginAuthenticator;
|
|
||||||
|
|
||||||
class RegistrationController extends AbstractController
|
|
||||||
{
|
|
||||||
#[Route('/register', name: 'app_register')]
|
|
||||||
public function register(
|
|
||||||
Request $request,
|
|
||||||
UserPasswordHasherInterface $userPasswordHasher,
|
|
||||||
EntityManagerInterface $entityManager,
|
|
||||||
UserRepository $userRepository
|
|
||||||
): Response
|
|
||||||
{
|
|
||||||
// اگر کاربر قبلاً وارد شده باشد، به صفحه اصلی هدایت شود
|
|
||||||
if ($this->getUser()) {
|
|
||||||
return $this->redirectToRoute('app_home');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = new User();
|
|
||||||
$form = $this->createForm(RegistrationFormType::class, $user);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
// بررسی تکراری نبودن ایمیل و موبایل
|
|
||||||
$existingUser = $userRepository->findByEmail($user->getEmail());
|
|
||||||
if ($existingUser) {
|
|
||||||
$this->addFlash('error', 'این ایمیل قبلاً ثبت شده است');
|
|
||||||
return $this->render('auth/registration/register.html.twig', [
|
|
||||||
'registrationForm' => $form->createView(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingUser = $userRepository->findByMobile($user->getMobile());
|
|
||||||
if ($existingUser) {
|
|
||||||
$this->addFlash('error', 'این شماره موبایل قبلاً ثبت شده است');
|
|
||||||
return $this->render('auth/registration/register.html.twig', [
|
|
||||||
'registrationForm' => $form->createView(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// رمزگذاری رمز عبور
|
|
||||||
$user->setPassword(
|
|
||||||
$userPasswordHasher->hashPassword(
|
|
||||||
$user,
|
|
||||||
$form->get('plainPassword')->getData()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// تنظیم نقش کاربر
|
|
||||||
$user->setRoles(['ROLE_USER']);
|
|
||||||
$user->setIsVerified(true); // در حالت واقعی باید تایید ایمیل انجام شود
|
|
||||||
|
|
||||||
$entityManager->persist($user);
|
|
||||||
$entityManager->flush();
|
|
||||||
|
|
||||||
$this->addFlash('success', 'ثبتنام با موفقیت انجام شد. حالا میتوانید وارد شوید.');
|
|
||||||
|
|
||||||
return $this->redirectToRoute('app_login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('auth/registration/register.html.twig', [
|
|
||||||
'registrationForm' => $form->createView(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller\Auth;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
use App\Form\Auth\ChangePasswordFormType;
|
|
||||||
use App\Form\Auth\ResetPasswordRequestFormType;
|
|
||||||
use App\Repository\UserRepository;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Mailer\MailerInterface;
|
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
|
||||||
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
|
||||||
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
|
|
||||||
#[Route('/reset-password')]
|
|
||||||
class ResetPasswordController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private UserRepository $userRepository,
|
|
||||||
private TokenGeneratorInterface $tokenGenerator,
|
|
||||||
private MailerInterface $mailer
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('', name: 'app_forgot_password_request')]
|
|
||||||
public function request(Request $request): Response
|
|
||||||
{
|
|
||||||
$form = $this->createForm(ResetPasswordRequestFormType::class);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
return $this->processSendingPasswordResetEmail(
|
|
||||||
$form->get('email')->getData()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('auth/reset_password/request.html.twig', [
|
|
||||||
'requestForm' => $form->createView(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/check-email', name: 'app_check_email')]
|
|
||||||
public function checkEmail(): Response
|
|
||||||
{
|
|
||||||
// این صفحه فقط برای نمایش پیام استفاده میشود
|
|
||||||
return $this->render('auth/reset_password/check_email.html.twig');
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/reset/{token}', name: 'app_reset_password')]
|
|
||||||
public function reset(string $token, Request $request, UserPasswordHasherInterface $userPasswordHasher): Response
|
|
||||||
{
|
|
||||||
$user = $this->userRepository->findByResetToken($token);
|
|
||||||
|
|
||||||
if (null === $user || $user->isResetTokenExpired()) {
|
|
||||||
$this->addFlash('error', 'توکن بازنشانی نامعتبر یا منقضی شده است');
|
|
||||||
return $this->redirectToRoute('app_forgot_password_request');
|
|
||||||
}
|
|
||||||
|
|
||||||
$form = $this->createForm(ChangePasswordFormType::class);
|
|
||||||
$form->handleRequest($request);
|
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
$user->setPassword(
|
|
||||||
$userPasswordHasher->hashPassword(
|
|
||||||
$user,
|
|
||||||
$form->get('plainPassword')->getData()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$user->setResetToken(null);
|
|
||||||
$user->setResetTokenExpiresAt(null);
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
$this->addFlash('success', 'رمز عبور شما با موفقیت تغییر یافت');
|
|
||||||
|
|
||||||
return $this->redirectToRoute('app_login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('auth/reset_password/reset.html.twig', [
|
|
||||||
'resetForm' => $form->createView(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function processSendingPasswordResetEmail(string $emailFormData): RedirectResponse
|
|
||||||
{
|
|
||||||
$user = $this->userRepository->findByEmail($emailFormData);
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
// برای امنیت، همیشه پیام موفقیت نمایش میدهیم
|
|
||||||
return $this->redirectToRoute('app_check_email');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$resetToken = $this->tokenGenerator->generateToken();
|
|
||||||
$user->setResetToken($resetToken);
|
|
||||||
$user->setResetTokenExpiresAt(new \DateTimeImmutable('+1 hour'));
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->addFlash('error', 'خطا در ایجاد توکن بازنشانی');
|
|
||||||
return $this->redirectToRoute('app_forgot_password_request');
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = (new TemplatedEmail())
|
|
||||||
->from('noreply@hesabix.ir')
|
|
||||||
->to($user->getEmail())
|
|
||||||
->subject('درخواست بازنشانی رمز عبور')
|
|
||||||
->htmlTemplate('reset_password/email.html.twig')
|
|
||||||
->context([
|
|
||||||
'resetToken' => $resetToken,
|
|
||||||
'user' => $user,
|
|
||||||
])
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->mailer->send($email);
|
|
||||||
|
|
||||||
return $this->redirectToRoute('app_check_email');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller\Auth;
|
|
||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
|
||||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
|
||||||
|
|
||||||
class SecurityController extends AbstractController
|
|
||||||
{
|
|
||||||
#[Route(path: '/login', name: 'app_login')]
|
|
||||||
public function login(AuthenticationUtils $authenticationUtils): Response
|
|
||||||
{
|
|
||||||
// اگر کاربر قبلاً وارد شده باشد، به صفحه اصلی هدایت شود
|
|
||||||
if ($this->getUser()) {
|
|
||||||
return $this->redirectToRoute('app_home');
|
|
||||||
}
|
|
||||||
|
|
||||||
$error = $authenticationUtils->getLastAuthenticationError();
|
|
||||||
$lastUsername = $authenticationUtils->getLastUsername();
|
|
||||||
|
|
||||||
return $this->render('auth/security/login.html.twig', [
|
|
||||||
'last_username' => $lastUsername,
|
|
||||||
'error' => $error,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route(path: '/logout', name: 'app_logout')]
|
|
||||||
public function logout(): void
|
|
||||||
{
|
|
||||||
// این متد توسط Symfony Security Bundle مدیریت میشود
|
|
||||||
// کد اینجا اجرا نمیشود
|
|
||||||
throw new \LogicException('این متد باید توسط Symfony Security Bundle مدیریت شود');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
core/src/Controller/LocaleController.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Translation\LocaleSwitcher;
|
||||||
|
|
||||||
|
final class LocaleController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route('/locale/{locale}', name: 'app_locale_switch', requirements: ['locale' => 'fa|en'])]
|
||||||
|
public function switchLocale(
|
||||||
|
string $locale,
|
||||||
|
Request $request,
|
||||||
|
SessionInterface $session,
|
||||||
|
LocaleSwitcher $localeSwitcher
|
||||||
|
): Response {
|
||||||
|
// Set locale in session
|
||||||
|
$session->set('_locale', $locale);
|
||||||
|
|
||||||
|
// Set locale for current request
|
||||||
|
$localeSwitcher->setLocale($locale);
|
||||||
|
|
||||||
|
// Redirect back to previous page or home
|
||||||
|
$referer = $request->headers->get('referer');
|
||||||
|
if ($referer) {
|
||||||
|
return $this->redirect($referer);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('app_home');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/api/locale', name: 'api_locale_get', methods: ['GET'])]
|
||||||
|
public function getCurrentLocale(SessionInterface $session): Response
|
||||||
|
{
|
||||||
|
$locale = $session->get('_locale', 'fa');
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'locale' => $locale,
|
||||||
|
'direction' => $locale === 'fa' ? 'rtl' : 'ltr'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,39 +10,22 @@ final class UIController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/ui', name: 'app_ui_home')]
|
#[Route('/ui', name: 'app_ui_home')]
|
||||||
#[Route('/ui/{route}', name: 'app_ui_route', requirements: ['route' => '.+'])]
|
#[Route('/ui/{route}', name: 'app_ui_route', requirements: ['route' => '.+'])]
|
||||||
public function index(string $route = ''): Response
|
public function app_ui_home(string $route = ''): Response
|
||||||
{
|
{
|
||||||
// Extract the main page from route
|
|
||||||
$page = $this->extractPageFromRoute($route);
|
|
||||||
|
|
||||||
return $this->render('ui/app.html.twig', [
|
return $this->render('ui/app.html.twig', [
|
||||||
'page' => $page,
|
|
||||||
'route' => $route
|
'route' => $route
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractPageFromRoute(string $route): string
|
#[Route('/auth', name: 'app_auth_home')]
|
||||||
|
#[Route('/auth/{route}', name: 'app_auth_route', requirements: ['route' => '.+'])]
|
||||||
|
public function app_auth_home(string $route = ''): Response
|
||||||
{
|
{
|
||||||
// Remove leading slash and get first segment
|
|
||||||
$route = ltrim($route, '/');
|
|
||||||
|
|
||||||
if (empty($route)) {
|
return $this->render('ui/auth.html.twig', [
|
||||||
return 'dashboard';
|
'route' => $route
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split by slash and get first part
|
|
||||||
$segments = explode('/', $route);
|
|
||||||
$mainPage = $segments[0];
|
|
||||||
|
|
||||||
// Map route to page
|
|
||||||
$pageMap = [
|
|
||||||
'dashboard' => 'dashboard',
|
|
||||||
'accounts' => 'accounts',
|
|
||||||
'transactions' => 'transactions',
|
|
||||||
'reports' => 'reports',
|
|
||||||
'settings' => 'settings'
|
|
||||||
];
|
|
||||||
|
|
||||||
return $pageMap[$mainPage] ?? 'dashboard';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
#[ORM\Column(nullable: true)]
|
#[ORM\Column(nullable: true)]
|
||||||
private ?\DateTimeImmutable $resetTokenExpiresAt = null;
|
private ?\DateTimeImmutable $resetTokenExpiresAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $lastLoginAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private int $loginCount = 0;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new \DateTimeImmutable();
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
|
@ -205,4 +211,26 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
}
|
}
|
||||||
return $this->resetTokenExpiresAt < new \DateTimeImmutable();
|
return $this->resetTokenExpiresAt < new \DateTimeImmutable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getLastLoginAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->lastLoginAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastLoginAt(?\DateTimeImmutable $lastLoginAt): static
|
||||||
|
{
|
||||||
|
$this->lastLoginAt = $lastLoginAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLoginCount(): int
|
||||||
|
{
|
||||||
|
return $this->loginCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLoginCount(int $loginCount): static
|
||||||
|
{
|
||||||
|
$this->loginCount = $loginCount;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
core/src/Event/Auth/UserLoginEvent.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Event\Auth;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Event\BaseEvent;
|
||||||
|
|
||||||
|
class UserLoginEvent extends BaseEvent
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private User $user,
|
||||||
|
private string $ipAddress,
|
||||||
|
private string $userAgent
|
||||||
|
) {
|
||||||
|
$this->setData([
|
||||||
|
'userId' => $user->getId(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'ipAddress' => $ipAddress,
|
||||||
|
'userAgent' => $userAgent,
|
||||||
|
'timestamp' => new \DateTimeImmutable()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): string
|
||||||
|
{
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
core/src/Event/BaseEvent.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Event;
|
||||||
|
|
||||||
|
use Symfony\Contracts\EventDispatcher\Event;
|
||||||
|
|
||||||
|
abstract class BaseEvent extends Event
|
||||||
|
{
|
||||||
|
protected bool $success = true;
|
||||||
|
protected array $errors = [];
|
||||||
|
protected bool $canRollback = true;
|
||||||
|
protected array $data = [];
|
||||||
|
|
||||||
|
public function markAsFailed(string $error): void
|
||||||
|
{
|
||||||
|
$this->success = false;
|
||||||
|
$this->errors[] = $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsSuccessful(): void
|
||||||
|
{
|
||||||
|
$this->success = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSuccessful(): bool
|
||||||
|
{
|
||||||
|
return $this->success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRollback(): bool
|
||||||
|
{
|
||||||
|
return $this->canRollback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCanRollback(bool $canRollback): void
|
||||||
|
{
|
||||||
|
$this->canRollback = $canRollback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getErrors(): array
|
||||||
|
{
|
||||||
|
return $this->errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasErrors(): bool
|
||||||
|
{
|
||||||
|
return count($this->errors) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setData(array $data): void
|
||||||
|
{
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getData(): array
|
||||||
|
{
|
||||||
|
return $this->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addData(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$this->data[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
core/src/EventListener/Auth/LoginAuditListener.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener\Auth;
|
||||||
|
|
||||||
|
use App\Event\Auth\UserLoginEvent;
|
||||||
|
use App\Event\BaseEvent;
|
||||||
|
use App\EventListener\RollbackableEventListenerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class LoginAuditListener implements RollbackableEventListenerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onEvent(BaseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event instanceof UserLoginEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $event->getUser();
|
||||||
|
$data = $event->getData();
|
||||||
|
|
||||||
|
$this->logger->info('User login attempt', [
|
||||||
|
'userId' => $user->getId(),
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'ipAddress' => $data['ipAddress'],
|
||||||
|
'userAgent' => $data['userAgent'],
|
||||||
|
'timestamp' => $data['timestamp']->format('Y-m-d H:i:s')
|
||||||
|
]);
|
||||||
|
|
||||||
|
$event->markAsSuccessful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$event->markAsFailed('خطا در ثبت لاگ ورود: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollback(BaseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event instanceof UserLoginEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// برای لاگ نیازی به rollback نیست
|
||||||
|
$this->logger->warning('Login audit rollback - no action needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 100; // اولویت بالا
|
||||||
|
}
|
||||||
|
}
|
||||||
71
core/src/EventListener/Auth/UserActivityListener.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener\Auth;
|
||||||
|
|
||||||
|
use App\Event\Auth\UserLoginEvent;
|
||||||
|
use App\Event\BaseEvent;
|
||||||
|
use App\EventListener\RollbackableEventListenerInterface;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
class UserActivityListener implements RollbackableEventListenerInterface
|
||||||
|
{
|
||||||
|
private ?\DateTimeImmutable $previousLastLoginAt = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private UserRepository $userRepository
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onEvent(BaseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event instanceof UserLoginEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $event->getUser();
|
||||||
|
|
||||||
|
// ذخیره زمان ورود قبلی برای rollback
|
||||||
|
$this->previousLastLoginAt = $user->getLastLoginAt();
|
||||||
|
|
||||||
|
// بهروزرسانی زمان آخرین ورود
|
||||||
|
$user->setLastLoginAt(new \DateTimeImmutable());
|
||||||
|
$user->setLoginCount($user->getLoginCount() + 1);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$event->markAsSuccessful();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$event->markAsFailed('خطا در بهروزرسانی فعالیت کاربر: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rollback(BaseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event instanceof UserLoginEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $event->getUser();
|
||||||
|
|
||||||
|
// بازگرداندن مقادیر قبلی
|
||||||
|
if ($this->previousLastLoginAt) {
|
||||||
|
$user->setLastLoginAt($this->previousLastLoginAt);
|
||||||
|
}
|
||||||
|
$user->setLoginCount($user->getLoginCount() - 1);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// در صورت خطا در rollback، لاگ میکنیم
|
||||||
|
// این یک وضعیت بحرانی است
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPriority(): int
|
||||||
|
{
|
||||||
|
return 200; // اولویت متوسط
|
||||||
|
}
|
||||||
|
}
|
||||||
40
core/src/EventListener/LocaleListener.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\Translation\LocaleSwitcher;
|
||||||
|
|
||||||
|
final class LocaleListener
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private LocaleSwitcher $localeSwitcher
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
$request = $event->getRequest();
|
||||||
|
|
||||||
|
// Skip for API routes
|
||||||
|
if (str_starts_with($request->getPathInfo(), '/api/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session from request
|
||||||
|
$session = $request->getSession();
|
||||||
|
if (!$session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get locale from session or default to 'fa'
|
||||||
|
$locale = $session->get('_locale', 'fa');
|
||||||
|
|
||||||
|
// Set locale for current request
|
||||||
|
$this->localeSwitcher->setLocale($locale);
|
||||||
|
|
||||||
|
// Set locale in request attributes
|
||||||
|
$request->setLocale($locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\EventListener;
|
||||||
|
|
||||||
|
use App\Event\BaseEvent;
|
||||||
|
|
||||||
|
interface RollbackableEventListenerInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* اجرای عملیات اصلی Listener
|
||||||
|
*/
|
||||||
|
public function onEvent(BaseEvent $event): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بازگشت تغییرات در صورت شکست
|
||||||
|
*/
|
||||||
|
public function rollback(BaseEvent $event): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اولویت اجرای Listener (عدد کمتر = اولویت بالاتر)
|
||||||
|
*/
|
||||||
|
public function getPriority(): int;
|
||||||
|
}
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Form;
|
|
||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
|
||||||
|
|
||||||
class ChangePasswordFormType extends AbstractType
|
|
||||||
{
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
|
||||||
{
|
|
||||||
$builder
|
|
||||||
->add('plainPassword', RepeatedType::class, [
|
|
||||||
'type' => PasswordType::class,
|
|
||||||
'mapped' => false,
|
|
||||||
'first_options' => [
|
|
||||||
'label' => 'رمز عبور جدید',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => 'رمز عبور جدید را وارد کنید'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'second_options' => [
|
|
||||||
'label' => 'تکرار رمز عبور جدید',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => 'رمز عبور جدید را دوباره وارد کنید'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'invalid_message' => 'رمزهای عبور یکسان نیستند',
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank([
|
|
||||||
'message' => 'رمز عبور جدید الزامی است',
|
|
||||||
]),
|
|
||||||
new Length([
|
|
||||||
'min' => 6,
|
|
||||||
'minMessage' => 'رمز عبور باید حداقل {{ limit }} کاراکتر باشد',
|
|
||||||
'max' => 4096,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
|
||||||
{
|
|
||||||
$resolver->setDefaults([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Form;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
|
||||||
use Symfony\Component\Validator\Constraints\Length;
|
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
|
||||||
use Symfony\Component\Validator\Constraints\Regex;
|
|
||||||
|
|
||||||
class RegistrationFormType extends AbstractType
|
|
||||||
{
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
|
||||||
{
|
|
||||||
$builder
|
|
||||||
->add('fullName', TextType::class, [
|
|
||||||
'label' => 'نام و نام خانوادگی',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => 'نام و نام خانوادگی خود را وارد کنید'
|
|
||||||
],
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank([
|
|
||||||
'message' => 'نام و نام خانوادگی الزامی است',
|
|
||||||
]),
|
|
||||||
new Length([
|
|
||||||
'min' => 2,
|
|
||||||
'max' => 100,
|
|
||||||
'minMessage' => 'نام باید حداقل {{ limit }} کاراکتر باشد',
|
|
||||||
'maxMessage' => 'نام نمیتواند بیشتر از {{ limit }} کاراکتر باشد',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->add('email', EmailType::class, [
|
|
||||||
'label' => 'ایمیل',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => 'example@email.com'
|
|
||||||
],
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank([
|
|
||||||
'message' => 'ایمیل الزامی است',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->add('mobile', TextType::class, [
|
|
||||||
'label' => 'شماره موبایل',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => '09123456789'
|
|
||||||
],
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank([
|
|
||||||
'message' => 'شماره موبایل الزامی است',
|
|
||||||
]),
|
|
||||||
new Regex([
|
|
||||||
'pattern' => '/^09[0-9]{9}$/',
|
|
||||||
'message' => 'فرمت شماره موبایل صحیح نیست (مثال: 09123456789)',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->add('plainPassword', RepeatedType::class, [
|
|
||||||
'type' => PasswordType::class,
|
|
||||||
'mapped' => false,
|
|
||||||
'first_options' => [
|
|
||||||
'label' => 'رمز عبور',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => 'رمز عبور خود را وارد کنید'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'second_options' => [
|
|
||||||
'label' => 'تکرار رمز عبور',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => 'رمز عبور را دوباره وارد کنید'
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'invalid_message' => 'رمزهای عبور یکسان نیستند',
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank([
|
|
||||||
'message' => 'رمز عبور الزامی است',
|
|
||||||
]),
|
|
||||||
new Length([
|
|
||||||
'min' => 6,
|
|
||||||
'minMessage' => 'رمز عبور باید حداقل {{ limit }} کاراکتر باشد',
|
|
||||||
'max' => 4096,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
|
||||||
{
|
|
||||||
$resolver->setDefaults([
|
|
||||||
'data_class' => User::class,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Form;
|
|
||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
|
||||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
|
||||||
|
|
||||||
class ResetPasswordRequestFormType extends AbstractType
|
|
||||||
{
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
|
||||||
{
|
|
||||||
$builder
|
|
||||||
->add('email', EmailType::class, [
|
|
||||||
'label' => 'ایمیل',
|
|
||||||
'attr' => [
|
|
||||||
'class' => 'form-control',
|
|
||||||
'placeholder' => 'ایمیل خود را وارد کنید'
|
|
||||||
],
|
|
||||||
'constraints' => [
|
|
||||||
new NotBlank([
|
|
||||||
'message' => 'لطفاً ایمیل خود را وارد کنید',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
])
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
|
||||||
{
|
|
||||||
$resolver->setDefaults([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
123
core/src/Service/EventTransactionManager.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Event\BaseEvent;
|
||||||
|
use App\EventListener\RollbackableEventListenerInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
|
|
||||||
|
class EventTransactionManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private EventDispatcherInterface $eventDispatcher,
|
||||||
|
private LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* اجرای عملیات اصلی با انتشار رویدادها و قابلیت rollback
|
||||||
|
*/
|
||||||
|
public function executeWithEvents(callable $operation, array $events): mixed
|
||||||
|
{
|
||||||
|
$this->entityManager->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// اجرای عملیات اصلی
|
||||||
|
$result = $operation();
|
||||||
|
|
||||||
|
// انتشار رویدادها
|
||||||
|
$this->dispatchEvents($events);
|
||||||
|
|
||||||
|
// بررسی موفقیت همه رویدادها
|
||||||
|
if ($this->areAllEventsSuccessful($events)) {
|
||||||
|
$this->entityManager->commit();
|
||||||
|
$this->logger->info('Transaction committed successfully');
|
||||||
|
return $result;
|
||||||
|
} else {
|
||||||
|
// rollback در صورت شکست رویدادها
|
||||||
|
$this->rollbackEvents($events);
|
||||||
|
$this->entityManager->rollback();
|
||||||
|
$this->logger->warning('Transaction rolled back due to event failures');
|
||||||
|
throw new \RuntimeException('عملیات به دلیل شکست در رویدادها لغو شد');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->entityManager->rollback();
|
||||||
|
$this->rollbackEvents($events);
|
||||||
|
$this->logger->error('Transaction rolled back due to exception: ' . $e->getMessage());
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* انتشار رویدادها
|
||||||
|
*/
|
||||||
|
private function dispatchEvents(array $events): void
|
||||||
|
{
|
||||||
|
foreach ($events as $event) {
|
||||||
|
$this->eventDispatcher->dispatch($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بررسی موفقیت همه رویدادها
|
||||||
|
*/
|
||||||
|
private function areAllEventsSuccessful(array $events): bool
|
||||||
|
{
|
||||||
|
foreach ($events as $event) {
|
||||||
|
if (!$event->isSuccessful()) {
|
||||||
|
$this->logger->warning('Event failed: ' . get_class($event), [
|
||||||
|
'errors' => $event->getErrors()
|
||||||
|
]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback رویدادها
|
||||||
|
*/
|
||||||
|
private function rollbackEvents(array $events): void
|
||||||
|
{
|
||||||
|
foreach ($events as $event) {
|
||||||
|
if ($event->canRollback()) {
|
||||||
|
$this->rollbackEvent($event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback یک رویداد خاص
|
||||||
|
*/
|
||||||
|
private function rollbackEvent(BaseEvent $event): void
|
||||||
|
{
|
||||||
|
$listeners = $this->getEventListeners($event);
|
||||||
|
|
||||||
|
foreach ($listeners as $listener) {
|
||||||
|
if ($listener instanceof RollbackableEventListenerInterface) {
|
||||||
|
try {
|
||||||
|
$listener->rollback($event);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Error during event rollback: ' . $e->getMessage(), [
|
||||||
|
'event' => get_class($event),
|
||||||
|
'listener' => get_class($listener)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* دریافت Listener های یک رویداد
|
||||||
|
*/
|
||||||
|
private function getEventListeners(BaseEvent $event): array
|
||||||
|
{
|
||||||
|
// این متد باید بر اساس Event Dispatcher پیادهسازی شود
|
||||||
|
// فعلاً یک پیادهسازی ساده
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,18 @@
|
||||||
"migrations/.gitignore"
|
"migrations/.gitignore"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"nelmio/cors-bundle": {
|
||||||
|
"version": "2.5",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.5",
|
||||||
|
"ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/nelmio_cors.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"phpunit/phpunit": {
|
"phpunit/phpunit": {
|
||||||
"version": "12.3",
|
"version": "12.3",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|
|
||||||
|
|
@ -1,514 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fa" dir="rtl">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{% block title %}سیستم احراز هویت - حسابیکس{% endblock %}</title>
|
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
|
|
||||||
|
|
||||||
<!-- Bootstrap CSS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
|
||||||
|
|
||||||
<!-- Bootstrap Icons -->
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
|
||||||
|
|
||||||
<!-- Custom CSS -->
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: 'Tahoma', 'Arial', sans-serif;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-container {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
border-radius: 24px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
animation: slideUp 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 30px 30px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -50%;
|
|
||||||
left: -50%;
|
|
||||||
width: 200%;
|
|
||||||
height: 200%;
|
|
||||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
|
||||||
animation: rotate 20s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header h1 {
|
|
||||||
font-size: 2.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header p {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin: 10px 0 0;
|
|
||||||
opacity: 0.9;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-body {
|
|
||||||
padding: 40px 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
border: 2px solid #e9ecef;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 15px 20px;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25);
|
|
||||||
background-color: white;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control.is-invalid {
|
|
||||||
border-color: #dc3545;
|
|
||||||
background-color: rgba(220, 53, 69, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control.is-valid {
|
|
||||||
border-color: #28a745;
|
|
||||||
background-color: rgba(40, 167, 69, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalid-feedback {
|
|
||||||
color: #dc3545;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-top: 8px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valid-feedback {
|
|
||||||
color: #28a745;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-top: 8px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 15px 30px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
border: none;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: -100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
|
||||||
transition: left 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover::before {
|
|
||||||
left: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 10px 30px rgba(40, 167, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: linear-gradient(135deg, #dc3545 0%, #fd7e14 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 10px 30px rgba(220, 53, 69, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
border: none;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-right: 4px solid;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
animation: slideIn 0.5s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-success {
|
|
||||||
background-color: rgba(40, 167, 69, 0.1);
|
|
||||||
color: #155724;
|
|
||||||
border-right-color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-danger {
|
|
||||||
background-color: rgba(220, 53, 69, 0.1);
|
|
||||||
color: #721c24;
|
|
||||||
border-right-color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-info {
|
|
||||||
background-color: rgba(23, 162, 184, 0.1);
|
|
||||||
color: #0c5460;
|
|
||||||
border-right-color: #17a2b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-warning {
|
|
||||||
background-color: rgba(255, 193, 7, 0.1);
|
|
||||||
color: #856404;
|
|
||||||
border-right-color: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer {
|
|
||||||
text-align: center;
|
|
||||||
padding: 20px 30px;
|
|
||||||
border-top: 1px solid #e9ecef;
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer a {
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-footer a:hover {
|
|
||||||
color: #5a6fd8;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-links {
|
|
||||||
margin-top: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-links a {
|
|
||||||
color: #6c757d;
|
|
||||||
text-decoration: none;
|
|
||||||
margin: 0 15px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-links a:hover {
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
text-align: center;
|
|
||||||
margin: 30px 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 1px;
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider span {
|
|
||||||
background: white;
|
|
||||||
padding: 0 20px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animations */
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(50px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.auth-container {
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card {
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header {
|
|
||||||
padding: 30px 20px 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header h1 {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-body {
|
|
||||||
padding: 30px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 14px 25px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading spinner */
|
|
||||||
.spinner-border-sm {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form validation enhancement */
|
|
||||||
.was-validated .form-control:valid {
|
|
||||||
border-color: #28a745;
|
|
||||||
background-color: rgba(40, 167, 69, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.was-validated .form-control:invalid {
|
|
||||||
border-color: #dc3545;
|
|
||||||
background-color: rgba(220, 53, 69, 0.05);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
{% block stylesheets %}{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="auth-container">
|
|
||||||
<div class="auth-card">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="auth-header">
|
|
||||||
<h1>
|
|
||||||
<i class="bi bi-shield-lock me-3"></i>
|
|
||||||
{% block auth_title %}احراز هویت{% endblock %}
|
|
||||||
</h1>
|
|
||||||
<p>{% block auth_subtitle %}به سیستم حسابیکس خوش آمدید{% endblock %}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="auth-body">
|
|
||||||
<!-- Flash Messages -->
|
|
||||||
{% for message in app.flashes('success') %}
|
|
||||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-check-circle me-2"></i>{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% for message in app.flashes('error') %}
|
|
||||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% for message in app.flashes('info') %}
|
|
||||||
<div class="alert alert-info alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% for message in app.flashes('warning') %}
|
|
||||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
|
||||||
<i class="bi bi-exclamation-circle me-2"></i>{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
{% block auth_body %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="auth-footer">
|
|
||||||
{% block auth_footer %}
|
|
||||||
<div class="auth-links">
|
|
||||||
<a href="{{ path('app_home') }}">
|
|
||||||
<i class="bi bi-house me-1"></i>بازگشت به صفحه اصلی
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bootstrap JavaScript -->
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
|
||||||
|
|
||||||
<!-- Custom JavaScript -->
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Auto-hide alerts after 8 seconds
|
|
||||||
const alerts = document.querySelectorAll('.alert');
|
|
||||||
alerts.forEach(function(alert) {
|
|
||||||
setTimeout(function() {
|
|
||||||
const bsAlert = new bootstrap.Alert(alert);
|
|
||||||
bsAlert.close();
|
|
||||||
}, 8000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enhanced form validation
|
|
||||||
const forms = document.querySelectorAll('form');
|
|
||||||
forms.forEach(function(form) {
|
|
||||||
form.addEventListener('submit', function(event) {
|
|
||||||
if (!form.checkValidity()) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
form.classList.add('was-validated');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add loading state to submit buttons
|
|
||||||
const submitButtons = document.querySelectorAll('button[type="submit"]');
|
|
||||||
submitButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
if (this.form && this.form.checkValidity()) {
|
|
||||||
this.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>در حال پردازش...';
|
|
||||||
this.disabled = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enhanced input focus effects
|
|
||||||
const inputs = document.querySelectorAll('.form-control');
|
|
||||||
inputs.forEach(input => {
|
|
||||||
input.addEventListener('focus', function() {
|
|
||||||
this.parentElement.classList.add('focused');
|
|
||||||
});
|
|
||||||
|
|
||||||
input.addEventListener('blur', function() {
|
|
||||||
this.parentElement.classList.remove('focused');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Smooth animations for form elements
|
|
||||||
const formElements = document.querySelectorAll('.form-group');
|
|
||||||
formElements.forEach((element, index) => {
|
|
||||||
element.style.opacity = '0';
|
|
||||||
element.style.transform = 'translateY(20px)';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
element.style.transition = 'all 0.5s ease';
|
|
||||||
element.style.opacity = '1';
|
|
||||||
element.style.transform = 'translateY(0)';
|
|
||||||
}, index * 100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% block javascripts %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
{% extends 'auth/base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}ثبتنام - سیستم حسابداری{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_title %}ثبتنام در سیستم{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_subtitle %}حساب کاربری جدید ایجاد کنید{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_body %}
|
|
||||||
<form method="post" class="needs-validation" novalidate>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="registration_form_fullName" class="form-label">
|
|
||||||
<i class="bi bi-person me-2"></i>نام و نام خانوادگی
|
|
||||||
</label>
|
|
||||||
{{ form_widget(registrationForm.fullName, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'نام و نام خانوادگی خود را وارد کنید',
|
|
||||||
'id': 'registration_form_fullName'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(registrationForm.fullName) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="registration_form_email" class="form-label">
|
|
||||||
<i class="bi bi-envelope me-2"></i>ایمیل
|
|
||||||
</label>
|
|
||||||
{{ form_widget(registrationForm.email, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'example@email.com',
|
|
||||||
'id': 'registration_form_email'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(registrationForm.email) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="registration_form_mobile" class="form-label">
|
|
||||||
<i class="bi bi-phone me-2"></i>شماره موبایل
|
|
||||||
</label>
|
|
||||||
{{ form_widget(registrationForm.mobile, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': '09123456789',
|
|
||||||
'id': 'registration_form_mobile'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(registrationForm.mobile) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="registration_form_plainPassword_first" class="form-label">
|
|
||||||
<i class="bi bi-lock me-2"></i>رمز عبور
|
|
||||||
</label>
|
|
||||||
{{ form_widget(registrationForm.plainPassword.first, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'رمز عبور خود را وارد کنید',
|
|
||||||
'id': 'registration_form_plainPassword_first'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(registrationForm.plainPassword.first) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="registration_form_plainPassword_second" class="form-label">
|
|
||||||
<i class="bi bi-lock-fill me-2"></i>تکرار رمز عبور
|
|
||||||
</label>
|
|
||||||
{{ form_widget(registrationForm.plainPassword.second, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'رمز عبور را دوباره وارد کنید',
|
|
||||||
'id': 'registration_form_plainPassword_second'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(registrationForm.plainPassword.second) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<button type="submit" class="btn btn-success">
|
|
||||||
<i class="bi bi-person-plus me-2"></i>ثبتنام
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="divider">
|
|
||||||
<span>قبلاً ثبتنام کردهاید؟</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="{{ path('app_login') }}" class="btn btn-primary">
|
|
||||||
<i class="bi bi-box-arrow-in-right me-2"></i>وارد شوید
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_footer %}
|
|
||||||
<div class="auth-links">
|
|
||||||
<a href="{{ path('app_home') }}">
|
|
||||||
<i class="bi bi-house me-1"></i>بازگشت به صفحه اصلی
|
|
||||||
</a>
|
|
||||||
<span class="mx-2">|</span>
|
|
||||||
<a href="{{ path('app_login') }}">
|
|
||||||
<i class="bi bi-box-arrow-in-right me-1"></i>ورود
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
{% extends 'auth/base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}بررسی ایمیل - سیستم حسابداری{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_title %}بررسی ایمیل{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_subtitle %}لینک بازنشانی رمز عبور ارسال شد{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_body %}
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-4">
|
|
||||||
<i class="bi bi-envelope-check text-success" style="font-size: 4rem;"></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h5 class="alert-heading">
|
|
||||||
<i class="bi bi-info-circle me-2"></i>ایمیل ارسال شد!
|
|
||||||
</h5>
|
|
||||||
<p class="mb-0">
|
|
||||||
اگر ایمیلی با آدرس وارد شده در سیستم ثبت شده باشد،
|
|
||||||
لینک بازنشانی رمز عبور برای شما ارسال شده است.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<h6 class="alert-heading">
|
|
||||||
<i class="bi bi-clock me-2"></i>مهم!
|
|
||||||
</h6>
|
|
||||||
<ul class="mb-0 text-start">
|
|
||||||
<li>لینک ارسال شده تا 1 ساعت معتبر است</li>
|
|
||||||
<li>پوشه اسپم خود را بررسی کنید</li>
|
|
||||||
<li>اگر ایمیل دریافت نکردید، دوباره تلاش کنید</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider">
|
|
||||||
<span>مراحل بعدی</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="{{ path('app_login') }}" class="btn btn-primary me-2">
|
|
||||||
<i class="bi bi-box-arrow-in-right me-2"></i>بازگشت به صفحه ورود
|
|
||||||
</a>
|
|
||||||
<a href="{{ path('app_forgot_password_request') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-clockwise me-2"></i>تلاش مجدد
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_footer %}
|
|
||||||
<div class="auth-links">
|
|
||||||
<a href="{{ path('app_home') }}">
|
|
||||||
<i class="bi bi-house me-1"></i>بازگشت به صفحه اصلی
|
|
||||||
</a>
|
|
||||||
<span class="mx-2">|</span>
|
|
||||||
<a href="{{ path('app_register') }}">
|
|
||||||
<i class="bi bi-person-plus me-1"></i>ثبتنام
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
{% extends 'auth/base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}فراموشی رمز عبور - سیستم حسابداری{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_title %}فراموشی رمز عبور{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_subtitle %}ایمیل خود را وارد کنید تا لینک بازنشانی ارسال شود{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_body %}
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<i class="bi bi-question-circle text-primary" style="font-size: 3rem;"></i>
|
|
||||||
<p class="text-muted mt-3">
|
|
||||||
نگران نباشید! ما لینک بازنشانی رمز عبور را برای شما ارسال خواهیم کرد.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" class="needs-validation" novalidate>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="request_form_email" class="form-label">
|
|
||||||
<i class="bi bi-envelope me-2"></i>ایمیل
|
|
||||||
</label>
|
|
||||||
{{ form_widget(requestForm.email, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'ایمیل خود را وارد کنید',
|
|
||||||
'id': 'request_form_email'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(requestForm.email) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
<i class="bi bi-send me-2"></i>ارسال لینک بازنشانی
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="divider">
|
|
||||||
<span>یا</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="{{ path('app_login') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-right me-2"></i>بازگشت به صفحه ورود
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_footer %}
|
|
||||||
<div class="auth-links">
|
|
||||||
<a href="{{ path('app_home') }}">
|
|
||||||
<i class="bi bi-house me-1"></i>بازگشت به صفحه اصلی
|
|
||||||
</a>
|
|
||||||
<span class="mx-2">|</span>
|
|
||||||
<a href="{{ path('app_login') }}">
|
|
||||||
<i class="bi bi-box-arrow-in-right me-1"></i>ورود
|
|
||||||
</a>
|
|
||||||
<span class="mx-2">|</span>
|
|
||||||
<a href="{{ path('app_register') }}">
|
|
||||||
<i class="bi bi-person-plus me-1"></i>ثبتنام
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
{% extends 'auth/base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}تغییر رمز عبور - سیستم حسابداری{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_title %}تغییر رمز عبور{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_subtitle %}رمز عبور جدید خود را وارد کنید{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_body %}
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<i class="bi bi-key text-warning" style="font-size: 3rem;"></i>
|
|
||||||
<p class="text-muted mt-3">
|
|
||||||
رمز عبور جدید خود را انتخاب کنید. این رمز عبور جایگزین رمز عبور قبلی خواهد شد.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" class="needs-validation" novalidate>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="change_password_form_plainPassword_first" class="form-label">
|
|
||||||
<i class="bi bi-lock me-2"></i>رمز عبور جدید
|
|
||||||
</label>
|
|
||||||
{{ form_widget(resetForm.plainPassword.first, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'رمز عبور جدید را وارد کنید',
|
|
||||||
'id': 'change_password_form_plainPassword_first'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(resetForm.plainPassword.first) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="change_password_form_plainPassword_second" class="form-label">
|
|
||||||
<i class="bi bi-lock-fill me-2"></i>تکرار رمز عبور جدید
|
|
||||||
</label>
|
|
||||||
{{ form_widget(resetForm.plainPassword.second, {
|
|
||||||
'attr': {
|
|
||||||
'class': 'form-control',
|
|
||||||
'placeholder': 'رمز عبور جدید را دوباره وارد کنید',
|
|
||||||
'id': 'change_password_form_plainPassword_second'
|
|
||||||
}
|
|
||||||
}) }}
|
|
||||||
{{ form_errors(resetForm.plainPassword.second) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<button type="submit" class="btn btn-success">
|
|
||||||
<i class="bi bi-check-circle me-2"></i>تغییر رمز عبور
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="divider">
|
|
||||||
<span>نکات امنیتی</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<h6 class="alert-heading">
|
|
||||||
<i class="bi bi-shield-check me-2"></i>رمز عبور قوی:
|
|
||||||
</h6>
|
|
||||||
<ul class="mb-0 text-start">
|
|
||||||
<li>حداقل 8 کاراکتر</li>
|
|
||||||
<li>ترکیبی از حروف بزرگ و کوچک</li>
|
|
||||||
<li>شامل اعداد و نمادهای خاص</li>
|
|
||||||
<li>عدم استفاده از اطلاعات شخصی</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider">
|
|
||||||
<span>یا</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="{{ path('app_login') }}" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-arrow-right me-2"></i>بازگشت به صفحه ورود
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_footer %}
|
|
||||||
<div class="auth-links">
|
|
||||||
<a href="{{ path('app_home') }}">
|
|
||||||
<i class="bi bi-house me-1"></i>بازگشت به صفحه اصلی
|
|
||||||
</a>
|
|
||||||
<span class="mx-2">|</span>
|
|
||||||
<a href="{{ path('app_register') }}">
|
|
||||||
<i class="bi bi-person-plus me-1"></i>ثبتنام
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
{% extends 'auth/base.html.twig' %}
|
|
||||||
|
|
||||||
{% block title %}ورود - سیستم حسابداری{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_title %}ورود به سیستم{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_subtitle %}برای دسترسی به سیستم حسابداری وارد شوید{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_body %}
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
||||||
{{ error.messageKey|trans(error.messageData, 'security') }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" class="needs-validation" novalidate>
|
|
||||||
{% if csrf_token('authenticate') %}
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="inputEmail" class="form-label">
|
|
||||||
<i class="bi bi-envelope me-2"></i>ایمیل
|
|
||||||
</label>
|
|
||||||
<input type="email"
|
|
||||||
value="{{ last_username }}"
|
|
||||||
name="_username"
|
|
||||||
id="inputEmail"
|
|
||||||
class="form-control"
|
|
||||||
autocomplete="email"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="ایمیل خود را وارد کنید">
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
لطفاً یک ایمیل معتبر وارد کنید
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="inputPassword" class="form-label">
|
|
||||||
<i class="bi bi-lock me-2"></i>رمز عبور
|
|
||||||
</label>
|
|
||||||
<input type="password"
|
|
||||||
name="_password"
|
|
||||||
id="inputPassword"
|
|
||||||
class="form-control"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
placeholder="رمز عبور خود را وارد کنید">
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
لطفاً رمز عبور خود را وارد کنید
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="form-check">
|
|
||||||
<input type="checkbox" class="form-check-input" id="remember_me" name="_remember_me">
|
|
||||||
<label class="form-check-label" for="remember_me">
|
|
||||||
<i class="bi bi-clock me-1"></i>مرا به خاطر بسپار
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<button class="btn btn-primary" type="submit">
|
|
||||||
<i class="bi bi-box-arrow-in-right me-2"></i>ورود
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="divider">
|
|
||||||
<span>یا</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="{{ path('app_forgot_password_request') }}" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-question-circle me-2"></i>فراموشی رمز عبور
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider">
|
|
||||||
<span>حساب کاربری ندارید؟</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center">
|
|
||||||
<a href="{{ path('app_register') }}" class="btn btn-success">
|
|
||||||
<i class="bi bi-person-plus me-2"></i>ثبتنام کنید
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block auth_footer %}
|
|
||||||
<div class="auth-links">
|
|
||||||
<a href="{{ path('app_home') }}">
|
|
||||||
<i class="bi bi-house me-1"></i>بازگشت به صفحه اصلی
|
|
||||||
</a>
|
|
||||||
<span class="mx-2">|</span>
|
|
||||||
<a href="{{ path('app_register') }}">
|
|
||||||
<i class="bi bi-person-plus me-1"></i>ثبتنام
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fa" dir="rtl">
|
<html lang="{{ app.request.locale }}" dir="{{ app.request.locale == 'fa' ? 'rtl' : 'ltr' }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}سیستم حسابداری{% endblock %}</title>
|
<title>{% block title %}{{ 'common.brand_name'|trans }}{% endblock %}</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
|
<link rel="icon" href="{{asset('favicon.ico')}}">
|
||||||
|
|
||||||
{% block stylesheets %}
|
{% block stylesheets %}
|
||||||
{{ encore_entry_link_tags('app') }}
|
{{ encore_entry_link_tags('main') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ encore_entry_script_tags('app') }}
|
{{ encore_entry_script_tags('main') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,36 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fa" dir="rtl">
|
<html dir="rtl" lang="fa">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>بازنشانی رمز عبور</title>
|
<title>بازنشانی رمز عبور</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
font-family: 'Tahoma', Arial, sans-serif;
|
font-family: 'Tahoma', 'Arial', sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #333;
|
||||||
background-color: #f4f4f4;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
background-color: #fff;
|
padding: 20px;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: #ffffff;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
border-bottom: 2px solid #007bff;
|
border-bottom: 2px solid #007bff;
|
||||||
padding-bottom: 20px;
|
padding-bottom: 20px;
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
}
|
||||||
.header h1 {
|
.logo {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
margin: 0;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
|
|
@ -37,27 +38,24 @@
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background-color: #007bff;
|
background-color: #007bff;
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
padding: 12px 30px;
|
padding: 12px 30px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
font-weight: bold;
|
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
text-align: center;
|
||||||
.button:hover {
|
|
||||||
background-color: #0056b3;
|
|
||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
margin-top: 30px;
|
||||||
font-size: 14px;
|
|
||||||
border-top: 1px solid #eee;
|
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
}
|
}
|
||||||
.warning {
|
.warning {
|
||||||
background-color: #fff3cd;
|
background-color: #fff3cd;
|
||||||
border: 1px solid #ffeaa7;
|
border: 1px solid #ffeaa7;
|
||||||
color: #856404;
|
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
|
|
@ -67,17 +65,20 @@
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>بازنشانی رمز عبور</h1>
|
<div class="logo">حسابیکس</div>
|
||||||
|
<h2>بازنشانی رمز عبور</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>سلام {{ user.fullName }}،</p>
|
<p>سلام {{ user.fullName }}،</p>
|
||||||
|
|
||||||
<p>شما درخواست بازنشانی رمز عبور کردهاید. برای ادامه، روی دکمه زیر کلیک کنید:</p>
|
<p>شما درخواست بازنشانی رمز عبور حساب کاربری خود را کردهاید.</p>
|
||||||
|
|
||||||
|
<p>برای بازنشانی رمز عبور، روی لینک زیر کلیک کنید:</p>
|
||||||
|
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<a href="{{ url('app_reset_password', {token: resetToken}) }}" class="button">
|
<a href="{{ app.request.schemeAndHttpHost }}/auth/reset-password?token={{ resetToken }}" class="button">
|
||||||
تغییر رمز عبور
|
بازنشانی رمز عبور
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -85,15 +86,15 @@
|
||||||
<strong>توجه:</strong> این لینک فقط تا 1 ساعت معتبر است. اگر درخواست شما نبوده، این ایمیل را نادیده بگیرید.
|
<strong>توجه:</strong> این لینک فقط تا 1 ساعت معتبر است. اگر درخواست شما نبوده، این ایمیل را نادیده بگیرید.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>اگر دکمه بالا کار نمیکند، میتوانید این لینک را در مرورگر خود کپی کنید:</p>
|
<p>اگر لینک بالا کار نمیکند، میتوانید این آدرس را در مرورگر خود کپی کنید:</p>
|
||||||
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 5px;">
|
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 5px;">
|
||||||
{{ url('app_reset_password', {token: resetToken}) }}
|
{{ app.request.schemeAndHttpHost }}/auth/reset-password?token={{ resetToken }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
|
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
|
||||||
<p>© {{ "now"|date("Y") }} سیستم حسابداری حسابیکس</p>
|
<p>© {{ "now"|date("Y") }} حسابیکس. تمامی حقوق محفوظ است.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -3,21 +3,5 @@
|
||||||
{% block title %}صفحه اصلی - سیستم حسابداری{% endblock %}
|
{% block title %}صفحه اصلی - سیستم حسابداری{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="jumbotron">
|
|
||||||
<h1 class="display-4">به سیستم حسابداری حسابیکس خوش آمدید!</h1>
|
|
||||||
<p class="lead">این سیستم برای مدیریت مالی و حسابداری طراحی شده است.</p>
|
|
||||||
<hr class="my-4">
|
|
||||||
{% if app.user %}
|
|
||||||
<p>شما با موفقیت وارد شدهاید. میتوانید از امکانات سیستم استفاده کنید.</p>
|
|
||||||
<a class="btn btn-primary btn-lg" href="#" role="button">شروع کار</a>
|
|
||||||
{% else %}
|
|
||||||
<p>برای استفاده از سیستم، لطفاً وارد شوید یا ثبتنام کنید.</p>
|
|
||||||
<a class="btn btn-primary btn-lg me-2" href="{{ path('app_login') }}" role="button">ورود</a>
|
|
||||||
<a class="btn btn-success btn-lg" href="{{ path('app_register') }}" role="button">ثبتنام</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,10 @@
|
||||||
{% block title %}سیستم حسابداری{% endblock %}
|
{% block title %}سیستم حسابداری{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div id="app" data-page="{{ page }}" data-route="{{ route }}"></div>
|
<div id="app" data-route="{{ route }}"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
<script src="{{ asset('build/main.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
17
core/templates/ui/auth.html.twig
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{% extends 'base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}{{ 'common.brand_name'|trans }}{% endblock %}
|
||||||
|
|
||||||
|
{% block stylesheets %}
|
||||||
|
{{ parent() }}
|
||||||
|
{{ encore_entry_link_tags('rtl-ltr') }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div id="auth" data-route="{{ route }}"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javascripts %}
|
||||||
|
{{ parent() }}
|
||||||
|
<script src="{{ asset('build/main.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
59
core/translations/messages.en.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Authentication
|
||||||
|
auth:
|
||||||
|
welcome: 'Welcome'
|
||||||
|
subtitle: 'Sign in to your account to continue'
|
||||||
|
email: 'Email'
|
||||||
|
password: 'Password'
|
||||||
|
remember_me: 'Remember me'
|
||||||
|
forgot_password: 'Forgot password'
|
||||||
|
login: 'Login'
|
||||||
|
register: 'Register'
|
||||||
|
no_account: 'Do not have an account'
|
||||||
|
register_here: 'Sign up here'
|
||||||
|
or: 'or'
|
||||||
|
email_required: 'Email is required'
|
||||||
|
email_invalid: 'Invalid email format'
|
||||||
|
password_required: 'Password is required'
|
||||||
|
password_min_length: 'Password must be at least 6 characters'
|
||||||
|
placeholder:
|
||||||
|
email: 'example at email dot com'
|
||||||
|
password: 'Enter your password'
|
||||||
|
|
||||||
|
# Common
|
||||||
|
common:
|
||||||
|
brand_name: 'Hesabix'
|
||||||
|
loading: 'Loading'
|
||||||
|
error: 'Error'
|
||||||
|
success: 'Success'
|
||||||
|
cancel: 'Cancel'
|
||||||
|
save: 'Save'
|
||||||
|
edit: 'Edit'
|
||||||
|
delete: 'Delete'
|
||||||
|
search: 'Search'
|
||||||
|
filter: 'Filter'
|
||||||
|
sort: 'Sort'
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
nav:
|
||||||
|
dashboard: 'Dashboard'
|
||||||
|
accounts: 'Accounts'
|
||||||
|
transactions: 'Transactions'
|
||||||
|
reports: 'Reports'
|
||||||
|
settings: 'Settings'
|
||||||
|
profile: 'Profile'
|
||||||
|
logout: 'Logout'
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
dashboard:
|
||||||
|
title: 'Dashboard'
|
||||||
|
total_balance: 'Total Balance'
|
||||||
|
monthly_income: 'Monthly Income'
|
||||||
|
monthly_expense: 'Monthly Expense'
|
||||||
|
recent_transactions: 'Recent Transactions'
|
||||||
|
quick_actions: 'Quick Actions'
|
||||||
|
|
||||||
|
# Language
|
||||||
|
language:
|
||||||
|
fa: 'Farsi'
|
||||||
|
en: 'English'
|
||||||
|
change_language: 'Change Language'
|
||||||
59
core/translations/messages.fa.yaml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Authentication
|
||||||
|
auth:
|
||||||
|
welcome: 'Welcome'
|
||||||
|
subtitle: 'Sign in to your account to continue'
|
||||||
|
email: 'Email'
|
||||||
|
password: 'Password'
|
||||||
|
remember_me: 'Remember me'
|
||||||
|
forgot_password: 'Forgot password'
|
||||||
|
login: 'Login'
|
||||||
|
register: 'Register'
|
||||||
|
no_account: 'No account'
|
||||||
|
register_here: 'Sign up here'
|
||||||
|
or: 'or'
|
||||||
|
email_required: 'Email required'
|
||||||
|
email_invalid: 'Invalid email'
|
||||||
|
password_required: 'Password required'
|
||||||
|
password_min_length: 'Password must be at least 6 characters'
|
||||||
|
placeholder:
|
||||||
|
email: 'example at email dot com'
|
||||||
|
password: 'Enter your password'
|
||||||
|
|
||||||
|
# Common
|
||||||
|
common:
|
||||||
|
brand_name: 'Hesabix'
|
||||||
|
loading: 'Loading'
|
||||||
|
error: 'Error'
|
||||||
|
success: 'Success'
|
||||||
|
cancel: 'Cancel'
|
||||||
|
save: 'Save'
|
||||||
|
edit: 'Edit'
|
||||||
|
delete: 'Delete'
|
||||||
|
search: 'Search'
|
||||||
|
filter: 'Filter'
|
||||||
|
sort: 'Sort'
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
nav:
|
||||||
|
dashboard: 'Dashboard'
|
||||||
|
accounts: 'Accounts'
|
||||||
|
transactions: 'Transactions'
|
||||||
|
reports: 'Reports'
|
||||||
|
settings: 'Settings'
|
||||||
|
profile: 'Profile'
|
||||||
|
logout: 'Logout'
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
dashboard:
|
||||||
|
title: 'Dashboard'
|
||||||
|
total_balance: 'Total Balance'
|
||||||
|
monthly_income: 'Monthly Income'
|
||||||
|
monthly_expense: 'Monthly Expense'
|
||||||
|
recent_transactions: 'Recent Transactions'
|
||||||
|
quick_actions: 'Quick Actions'
|
||||||
|
|
||||||
|
# Language
|
||||||
|
language:
|
||||||
|
fa: 'Farsi'
|
||||||
|
en: 'English'
|
||||||
|
change_language: 'Change Language'
|
||||||
61
frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"@symfony/webpack-encore": "^4.7.0",
|
"@symfony/webpack-encore": "^4.7.0",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
|
"vue-i18n": "^9.14.5",
|
||||||
"vue-loader": "^17.4.2",
|
"vue-loader": "^17.4.2",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vuetify": "^3.5.8",
|
"vuetify": "^3.5.8",
|
||||||
|
|
@ -1467,6 +1468,47 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/message-compiler": "9.14.5",
|
||||||
|
"@intlify/shared": "9.14.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "9.14.5",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/balanced-match": {
|
"node_modules/@isaacs/balanced-match": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||||
|
|
@ -7351,6 +7393,25 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "9.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
|
||||||
|
"integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "9.14.5",
|
||||||
|
"@intlify/shared": "9.14.5",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-loader": {
|
"node_modules/vue-loader": {
|
||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-17.4.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"@symfony/webpack-encore": "^4.7.0",
|
"@symfony/webpack-encore": "^4.7.0",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
|
"vue-i18n": "^9.14.5",
|
||||||
"vue-loader": "^17.4.2",
|
"vue-loader": "^17.4.2",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
"vuetify": "^3.5.8",
|
"vuetify": "^3.5.8",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fa" dir="rtl">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>سیستم حسابداری</title>
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
7
frontend/src/app.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// This file is now simplified and only contains app-specific logic
|
||||||
|
// The main initialization is handled in main.js
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Add any app-specific functionality here if needed
|
||||||
|
name: 'App'
|
||||||
|
}
|
||||||
387
frontend/src/assets/styles/components-rtl.css
Normal file
|
|
@ -0,0 +1,387 @@
|
||||||
|
/* Component-specific RTL Support */
|
||||||
|
|
||||||
|
/* Language Switcher RTL */
|
||||||
|
[dir="rtl"] .language-switcher {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .language-btn {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .language-btn .v-icon {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .language-btn .v-icon:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme Switcher RTL */
|
||||||
|
[dir="rtl"] .theme-switcher {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .theme-switcher .v-btn {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation RTL */
|
||||||
|
[dir="rtl"] .v-navigation-drawer {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-navigation-drawer .v-list {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-navigation-drawer .v-list-item {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header and Toolbar RTL */
|
||||||
|
[dir="rtl"] .v-toolbar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-toolbar .v-toolbar-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-toolbar .v-btn {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-toolbar .v-btn:first-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form elements RTL */
|
||||||
|
[dir="rtl"] .v-form {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-form .v-text-field {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-form .v-text-field input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-form .v-textarea textarea {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-form .v-select .v-field__input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card layouts RTL */
|
||||||
|
[dir="rtl"] .v-card {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-card .v-card-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-card .v-card-text {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-card .v-card-actions {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog and Modal RTL */
|
||||||
|
[dir="rtl"] .v-dialog {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-dialog .v-card {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-dialog .v-card-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-dialog .v-card-text {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table RTL */
|
||||||
|
[dir="rtl"] .v-data-table {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-data-table th {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-data-table td {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-data-table .v-data-table-header {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination RTL */
|
||||||
|
[dir="rtl"] .v-pagination {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-pagination .v-btn {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-pagination .v-btn:first-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alert and Notification RTL */
|
||||||
|
[dir="rtl"] .v-alert {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-alert .v-alert__content {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-snackbar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-snackbar .v-snackbar__content {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress indicators RTL */
|
||||||
|
[dir="rtl"] .v-progress-linear {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-progress-circular {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chip and Badge RTL */
|
||||||
|
[dir="rtl"] .v-chip {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-chip .v-icon {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-badge {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expansion panels RTL */
|
||||||
|
[dir="rtl"] .v-expansion-panels {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-expansion-panels .v-expansion-panel-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-expansion-panels .v-expansion-panel-text {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs RTL */
|
||||||
|
[dir="rtl"] .v-tabs {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-tabs .v-tab {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-tabs .v-tab__content {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stepper RTL */
|
||||||
|
[dir="rtl"] .v-stepper {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-stepper .v-stepper-header {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-stepper .v-stepper-content {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline RTL */
|
||||||
|
[dir="rtl"] .v-timeline {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-timeline .v-timeline-item {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar RTL */
|
||||||
|
[dir="rtl"] .v-calendar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-calendar .v-calendar-header {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Treeview RTL */
|
||||||
|
[dir="rtl"] .v-treeview {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-treeview .v-treeview-node {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File input RTL */
|
||||||
|
[dir="rtl"] .v-file-input {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-file-input .v-field__input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rating RTL */
|
||||||
|
[dir="rtl"] .v-rating {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-rating .v-icon {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slider RTL */
|
||||||
|
[dir="rtl"] .v-slider {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch RTL */
|
||||||
|
[dir="rtl"] .v-switch {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox and Radio RTL */
|
||||||
|
[dir="rtl"] .v-checkbox {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-checkbox .v-label {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-radio-group {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-radio {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-radio .v-label {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autocomplete RTL */
|
||||||
|
[dir="rtl"] .v-autocomplete {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-autocomplete .v-field__input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Combobox RTL */
|
||||||
|
[dir="rtl"] .v-combobox {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-combobox .v-field__input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Date picker RTL */
|
||||||
|
[dir="rtl"] .v-date-picker {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-date-picker .v-calendar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Time picker RTL */
|
||||||
|
[dir="rtl"] .v-time-picker {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color picker RTL */
|
||||||
|
[dir="rtl"] .v-color-picker {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image RTL */
|
||||||
|
[dir="rtl"] .v-img {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar RTL */
|
||||||
|
[dir="rtl"] .v-avatar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Divider RTL */
|
||||||
|
[dir="rtl"] .v-divider {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacer RTL */
|
||||||
|
[dir="rtl"] .v-spacer {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for RTL */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
[dir="rtl"] .v-toolbar .v-btn {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-navigation-drawer {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
[dir="rtl"] .language-switcher {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .theme-switcher {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
frontend/src/assets/styles/rtl-ltr.css
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
/* RTL Support */
|
||||||
|
[dir="rtl"] {
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .input-icon {
|
||||||
|
right: auto !important;
|
||||||
|
left: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .form-input {
|
||||||
|
padding: 16px 16px 16px 50px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .password-toggle {
|
||||||
|
left: auto !important;
|
||||||
|
right: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .checkmark {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .language-switcher-container {
|
||||||
|
right: 20px !important;
|
||||||
|
left: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vuetify RTL overrides */
|
||||||
|
[dir="rtl"] .v-btn {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-card {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-text-field {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-select {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-menu {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-list {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-list-item {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-list-item-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container and layout RTL support */
|
||||||
|
[dir="rtl"] .v-container {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-main {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-app {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LTR Support */
|
||||||
|
[dir="ltr"] {
|
||||||
|
direction: ltr;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .input-icon {
|
||||||
|
left: auto !important;
|
||||||
|
right: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .form-input {
|
||||||
|
padding: 16px 50px 16px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .password-toggle {
|
||||||
|
right: auto !important;
|
||||||
|
left: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .checkmark {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
margin-left: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .language-switcher-container {
|
||||||
|
left: 20px !important;
|
||||||
|
right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font family for different languages */
|
||||||
|
[lang="fa"] {
|
||||||
|
font-family: 'Vazir', 'Tahoma', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[lang="en"] {
|
||||||
|
font-family: 'Roboto', 'Arial', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation direction for RTL */
|
||||||
|
[dir="rtl"] .floating-shapes .shape {
|
||||||
|
animation-direction: reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
[dir="rtl"] .language-switcher-container {
|
||||||
|
right: 15px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .language-switcher-container {
|
||||||
|
left: 15px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional RTL support for common elements */
|
||||||
|
[dir="rtl"] .v-icon {
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-btn .v-icon {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-btn .v-icon:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form elements RTL support */
|
||||||
|
[dir="rtl"] .v-text-field input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-textarea textarea {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-select .v-field__input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation and menu RTL support */
|
||||||
|
[dir="rtl"] .v-navigation-drawer {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-toolbar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-toolbar .v-toolbar-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
65
frontend/src/assets/styles/theme.css
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/* Theme-specific styles */
|
||||||
|
.v-theme--dark {
|
||||||
|
--v-theme-background: #000000;
|
||||||
|
--v-theme-surface: #121212;
|
||||||
|
--v-theme-primary: #90caf9;
|
||||||
|
--v-theme-secondary: #bdbdbd;
|
||||||
|
--v-theme-on-background: #ffffff;
|
||||||
|
--v-theme-on-surface: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-theme--light {
|
||||||
|
--v-theme-background: #fafafa;
|
||||||
|
--v-theme-surface: #ffffff;
|
||||||
|
--v-theme-primary: #1976d2;
|
||||||
|
--v-theme-secondary: #424242;
|
||||||
|
--v-theme-on-background: #000000;
|
||||||
|
--v-theme-on-surface: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force theme colors */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--v-theme-background: #000000 !important;
|
||||||
|
--v-theme-surface: #121212 !important;
|
||||||
|
--v-theme-primary: #90caf9 !important;
|
||||||
|
--v-theme-secondary: #bdbdbd !important;
|
||||||
|
--v-theme-on-background: #ffffff !important;
|
||||||
|
--v-theme-on-surface: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--v-theme-background: #fafafa !important;
|
||||||
|
--v-theme-surface: #ffffff !important;
|
||||||
|
--v-theme-primary: #1976d2 !important;
|
||||||
|
--v-theme-secondary: #424242 !important;
|
||||||
|
--v-theme-on-background: #000000 !important;
|
||||||
|
--v-theme-on-surface: #000000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme specific overrides */
|
||||||
|
.v-theme--dark .v-card {
|
||||||
|
background-color: #121212 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-theme--dark .v-text-field {
|
||||||
|
background-color: #1e1e1e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-theme--dark .v-btn {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme specific overrides */
|
||||||
|
.v-theme--light .v-card {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-theme--light .v-text-field {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-theme--light .v-btn {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
171
frontend/src/assets/styles/vuetify-rtl.css
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
/* Vuetify RTL Support */
|
||||||
|
[dir="rtl"] .v-btn {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-card {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-text-field {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-select {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-menu {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-list {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-list-item {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-list-item-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-container {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-main {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-app {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-toolbar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-toolbar .v-toolbar-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-navigation-drawer {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-text-field input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-textarea textarea {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-select .v-field__input {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-btn .v-icon {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-btn .v-icon:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL specific spacing adjustments */
|
||||||
|
[dir="rtl"] .v-card .v-card-text {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-card .v-card-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-dialog .v-card {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-sheet {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-expansion-panels .v-expansion-panel-title {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-tabs .v-tab {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-tabs .v-tab__content {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form validation messages RTL */
|
||||||
|
[dir="rtl"] .v-messages {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-input__details {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data table RTL support */
|
||||||
|
[dir="rtl"] .v-data-table {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-data-table th {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-data-table td {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chip and badge RTL support */
|
||||||
|
[dir="rtl"] .v-chip {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-badge {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress indicators RTL */
|
||||||
|
[dir="rtl"] .v-progress-linear {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-progress-circular {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Snackbar and notifications RTL */
|
||||||
|
[dir="rtl"] .v-snackbar {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-snackbar .v-snackbar__content {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip RTL support */
|
||||||
|
[dir="rtl"] .v-tooltip {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu positioning RTL */
|
||||||
|
[dir="rtl"] .v-menu__content {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="rtl"] .v-overlay__content {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
7
frontend/src/auth.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// This file is now simplified and only contains auth-specific logic
|
||||||
|
// The main initialization is handled in main.js
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Add any auth-specific functionality here if needed
|
||||||
|
name: 'Auth'
|
||||||
|
}
|
||||||
153
frontend/src/components/LanguageSwitcher.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<template>
|
||||||
|
<div class="language-switcher">
|
||||||
|
<v-menu offset-y>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="language-btn"
|
||||||
|
:prepend-icon="currentLocale === 'fa' ? 'mdi-flag' : 'mdi-flag-variant'"
|
||||||
|
>
|
||||||
|
{{ currentLocale === 'fa' ? 'فارسی' : 'English' }}
|
||||||
|
<v-icon end>mdi-chevron-down</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
v-for="locale in availableLocales"
|
||||||
|
:key="locale.code"
|
||||||
|
@click="changeLanguage(locale.code)"
|
||||||
|
:class="{ 'active-locale': currentLocale === locale.code }"
|
||||||
|
>
|
||||||
|
<v-list-item-title>
|
||||||
|
<v-icon start :icon="locale.icon"></v-icon>
|
||||||
|
{{ locale.name }}
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { changeLocale } from '../i18n'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'LanguageSwitcher',
|
||||||
|
setup() {
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const currentLocale = computed(() => locale.value)
|
||||||
|
|
||||||
|
const availableLocales = ref([
|
||||||
|
{
|
||||||
|
code: 'fa',
|
||||||
|
name: 'فارسی',
|
||||||
|
icon: 'mdi-flag'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'en',
|
||||||
|
name: 'English',
|
||||||
|
icon: 'mdi-flag-variant'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const changeLanguage = async (newLocale) => {
|
||||||
|
try {
|
||||||
|
// Update frontend locale first
|
||||||
|
changeLocale(newLocale)
|
||||||
|
|
||||||
|
// Send request to backend to update session
|
||||||
|
const response = await fetch(`/api/locale/change/${newLocale}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
console.log('Language changed successfully')
|
||||||
|
// Apply RTL/LTR changes immediately
|
||||||
|
applyDirectionalChanges(newLocale)
|
||||||
|
} else {
|
||||||
|
console.error('Failed to change language')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing language:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDirectionalChanges = (locale) => {
|
||||||
|
const isRTL = locale === 'fa'
|
||||||
|
|
||||||
|
// Set document direction
|
||||||
|
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
|
||||||
|
// Set body direction
|
||||||
|
document.body.style.direction = isRTL ? 'rtl' : 'ltr'
|
||||||
|
|
||||||
|
// Apply font family
|
||||||
|
if (isRTL) {
|
||||||
|
document.body.style.fontFamily = "'Vazir', 'Tahoma', sans-serif"
|
||||||
|
} else {
|
||||||
|
document.body.style.fontFamily = "'Roboto', 'Arial', sans-serif"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force Vuetify components to update
|
||||||
|
setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for locale changes and apply directional changes
|
||||||
|
watch(currentLocale, (newLocale) => {
|
||||||
|
applyDirectionalChanges(newLocale)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentLocale,
|
||||||
|
availableLocales,
|
||||||
|
changeLanguage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.language-switcher {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-btn {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-locale {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL Support */
|
||||||
|
[dir="rtl"] .language-btn {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
[dir="ltr"] .language-btn {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font adjustments for different languages */
|
||||||
|
[lang="fa"] .language-btn {
|
||||||
|
font-family: 'Vazir', 'Tahoma', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[lang="en"] .language-btn {
|
||||||
|
font-family: 'Roboto', 'Arial', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
151
frontend/src/components/ThemeSwitcher.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
<template>
|
||||||
|
<v-menu offset-y>
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="props"
|
||||||
|
icon="mdi-theme-light-dark"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
class="theme-switcher-btn"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
@click="setTheme('light')"
|
||||||
|
:active="currentTheme === 'light'"
|
||||||
|
prepend-icon="mdi-white-balance-sunny"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ t('theme.light') }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
@click="setTheme('dark')"
|
||||||
|
:active="currentTheme === 'dark'"
|
||||||
|
prepend-icon="mdi-weather-night"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ t('theme.dark') }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
@click="setTheme('system')"
|
||||||
|
:active="currentTheme === 'system'"
|
||||||
|
prepend-icon="mdi-monitor"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ t('theme.system') }}</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ThemeSwitcher',
|
||||||
|
setup() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
|
const currentTheme = ref('light')
|
||||||
|
|
||||||
|
const detectSystemTheme = () => {
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCurrentTheme = () => {
|
||||||
|
const savedTheme = localStorage.getItem('vuetify-theme')
|
||||||
|
if (savedTheme === 'system') {
|
||||||
|
currentTheme.value = detectSystemTheme()
|
||||||
|
} else {
|
||||||
|
currentTheme.value = savedTheme || 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTheme = (themeName) => {
|
||||||
|
console.log('Setting theme to:', themeName)
|
||||||
|
localStorage.setItem('vuetify-theme', themeName)
|
||||||
|
|
||||||
|
let actualTheme = themeName
|
||||||
|
if (themeName === 'system') {
|
||||||
|
actualTheme = detectSystemTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Actual theme to apply:', actualTheme)
|
||||||
|
|
||||||
|
// Force theme change
|
||||||
|
theme.global.name = actualTheme
|
||||||
|
|
||||||
|
// Update current theme display
|
||||||
|
updateCurrentTheme()
|
||||||
|
|
||||||
|
console.log('Current theme after change:', theme.global.name)
|
||||||
|
|
||||||
|
// Force re-render
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.setAttribute('data-theme', actualTheme)
|
||||||
|
if (actualTheme === 'dark') {
|
||||||
|
document.body.classList.add('v-theme--dark')
|
||||||
|
document.body.classList.remove('v-theme--light')
|
||||||
|
} else {
|
||||||
|
document.body.classList.add('v-theme--light')
|
||||||
|
document.body.classList.remove('v-theme--dark')
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Initialize theme on mount
|
||||||
|
const savedTheme = localStorage.getItem('vuetify-theme')
|
||||||
|
console.log('Saved theme on mount:', savedTheme)
|
||||||
|
|
||||||
|
if (savedTheme) {
|
||||||
|
setTheme(savedTheme)
|
||||||
|
} else {
|
||||||
|
// Set default theme
|
||||||
|
setTheme('light')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
if (localStorage.getItem('vuetify-theme') === 'system') {
|
||||||
|
console.log('System theme changed to:', e.matches ? 'dark' : 'light')
|
||||||
|
setTheme('system')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for theme changes
|
||||||
|
watch(() => theme.global.name, (newTheme) => {
|
||||||
|
console.log('Theme changed to:', newTheme)
|
||||||
|
updateCurrentTheme()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTheme,
|
||||||
|
setTheme,
|
||||||
|
t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.theme-switcher-btn {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher-btn:hover {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
47
frontend/src/i18n/index.js
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import fa from '../locales/fa'
|
||||||
|
import en from '../locales/en'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'fa',
|
||||||
|
fallbackLocale: 'fa',
|
||||||
|
messages: {
|
||||||
|
fa,
|
||||||
|
en
|
||||||
|
},
|
||||||
|
allowComposition: true,
|
||||||
|
useScope: 'global'
|
||||||
|
})
|
||||||
|
|
||||||
|
export const changeLocale = (locale) => {
|
||||||
|
if (['fa', 'en'].includes(locale)) {
|
||||||
|
i18n.global.locale.value = locale
|
||||||
|
localStorage.setItem('locale', locale)
|
||||||
|
|
||||||
|
const isRTL = locale === 'fa'
|
||||||
|
|
||||||
|
// Set document direction and language
|
||||||
|
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
document.body.style.direction = isRTL ? 'rtl' : 'ltr'
|
||||||
|
|
||||||
|
// Apply font family
|
||||||
|
if (isRTL) {
|
||||||
|
document.body.style.fontFamily = "'Vazir', 'Tahoma', sans-serif"
|
||||||
|
} else {
|
||||||
|
document.body.style.fontFamily = "'Roboto', 'Arial', sans-serif"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force Vuetify components to update
|
||||||
|
setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
const savedLocale = localStorage.getItem('locale') || 'fa'
|
||||||
|
changeLocale(savedLocale)
|
||||||
|
|
||||||
|
export default i18n
|
||||||
74
frontend/src/locales/en.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
export default {
|
||||||
|
// Authentication
|
||||||
|
auth: {
|
||||||
|
welcome: 'Welcome',
|
||||||
|
subtitle: 'Sign in to your account to continue',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
rememberMe: 'Remember me',
|
||||||
|
forgotPassword: 'Forgot password',
|
||||||
|
login: 'Login',
|
||||||
|
register: 'Register',
|
||||||
|
noAccount: 'Do not have an account',
|
||||||
|
registerHere: 'Sign up here',
|
||||||
|
or: 'or',
|
||||||
|
emailRequired: 'Email is required',
|
||||||
|
emailInvalid: 'Invalid email format',
|
||||||
|
passwordRequired: 'Password is required',
|
||||||
|
passwordMinLength: 'Password must be at least 6 characters',
|
||||||
|
placeholder: {
|
||||||
|
email: 'example at email dot com',
|
||||||
|
password: 'Enter your password'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common
|
||||||
|
common: {
|
||||||
|
brandName: 'Hesabix',
|
||||||
|
loading: 'Loading',
|
||||||
|
error: 'Error',
|
||||||
|
success: 'Success',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
save: 'Save',
|
||||||
|
edit: 'Edit',
|
||||||
|
delete: 'Delete',
|
||||||
|
search: 'Search',
|
||||||
|
filter: 'Filter',
|
||||||
|
sort: 'Sort'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
nav: {
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
accounts: 'Accounts',
|
||||||
|
transactions: 'Transactions',
|
||||||
|
reports: 'Reports',
|
||||||
|
settings: 'Settings',
|
||||||
|
profile: 'Profile',
|
||||||
|
logout: 'Logout'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
dashboard: {
|
||||||
|
title: 'Dashboard',
|
||||||
|
totalBalance: 'Total Balance',
|
||||||
|
monthlyIncome: 'Monthly Income',
|
||||||
|
monthlyExpense: 'Monthly Expense',
|
||||||
|
recentTransactions: 'Recent Transactions',
|
||||||
|
quickActions: 'Quick Actions'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Language
|
||||||
|
language: {
|
||||||
|
fa: 'Farsi',
|
||||||
|
en: 'English',
|
||||||
|
changeLanguage: 'Change Language'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
theme: {
|
||||||
|
light: 'Light',
|
||||||
|
dark: 'Dark',
|
||||||
|
system: 'System'
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/src/locales/fa.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
export default {
|
||||||
|
// Authentication
|
||||||
|
auth: {
|
||||||
|
welcome: 'خوش آمدید',
|
||||||
|
subtitle: 'برای ادامه وارد حساب کاربری خود شوید',
|
||||||
|
email: 'ایمیل',
|
||||||
|
password: 'رمز عبور',
|
||||||
|
rememberMe: 'مرا به خاطر بسپار',
|
||||||
|
forgotPassword: 'فراموشی رمز عبور',
|
||||||
|
login: 'ورود',
|
||||||
|
register: 'ثبت نام',
|
||||||
|
noAccount: 'حساب کاربری ندارید؟',
|
||||||
|
registerHere: 'اینجا ثبت نام کنید',
|
||||||
|
or: 'یا',
|
||||||
|
emailRequired: 'ایمیل الزامی است',
|
||||||
|
emailInvalid: 'ایمیل نامعتبر است',
|
||||||
|
passwordRequired: 'رمز عبور الزامی است',
|
||||||
|
passwordMinLength: 'رمز عبور باید حداقل 6 کاراکتر باشد',
|
||||||
|
placeholder: {
|
||||||
|
email: 'example at email dot com',
|
||||||
|
password: 'رمز عبور خود را وارد کنید'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common
|
||||||
|
common: {
|
||||||
|
brandName: 'حسابیکس',
|
||||||
|
loading: 'در حال بارگذاری',
|
||||||
|
error: 'خطا',
|
||||||
|
success: 'موفقیت',
|
||||||
|
cancel: 'انصراف',
|
||||||
|
save: 'ذخیره',
|
||||||
|
edit: 'ویرایش',
|
||||||
|
delete: 'حذف',
|
||||||
|
search: 'جستجو',
|
||||||
|
filter: 'فیلتر',
|
||||||
|
sort: 'مرتبسازی'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
nav: {
|
||||||
|
dashboard: 'داشبورد',
|
||||||
|
accounts: 'حسابها',
|
||||||
|
transactions: 'تراکنشها',
|
||||||
|
reports: 'گزارشها',
|
||||||
|
settings: 'تنظیمات',
|
||||||
|
profile: 'پروفایل',
|
||||||
|
logout: 'خروج'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
dashboard: {
|
||||||
|
title: 'داشبورد',
|
||||||
|
totalBalance: 'موجودی کل',
|
||||||
|
monthlyIncome: 'درآمد ماهانه',
|
||||||
|
monthlyExpense: 'هزینه ماهانه',
|
||||||
|
recentTransactions: 'تراکنشهای اخیر',
|
||||||
|
quickActions: 'عملیات سریع'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Language
|
||||||
|
language: {
|
||||||
|
fa: 'فارسی',
|
||||||
|
en: 'انگلیسی',
|
||||||
|
changeLanguage: 'تغییر زبان'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
theme: {
|
||||||
|
light: 'روشن',
|
||||||
|
dark: 'تاریک',
|
||||||
|
system: 'سیستم'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,70 @@
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import i18n from './i18n'
|
||||||
|
import vuetify from './plugins/vuetify'
|
||||||
|
import './assets/styles/theme.css'
|
||||||
|
import './assets/styles/rtl-ltr.css'
|
||||||
|
import './assets/styles/vuetify-rtl.css'
|
||||||
|
import './assets/styles/components-rtl.css'
|
||||||
|
|
||||||
// Vuetify
|
// Theme detection and management
|
||||||
import 'vuetify/styles'
|
function detectSystemTheme() {
|
||||||
import { createVuetify } from 'vuetify'
|
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
import * as components from 'vuetify/components'
|
return 'dark'
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import '@mdi/font/css/materialdesignicons.css'
|
|
||||||
|
|
||||||
const vuetify = createVuetify({
|
|
||||||
components,
|
|
||||||
directives,
|
|
||||||
theme: {
|
|
||||||
defaultTheme: 'light'
|
|
||||||
}
|
}
|
||||||
})
|
return 'light'
|
||||||
|
}
|
||||||
|
|
||||||
const app = createApp({
|
function initializeTheme() {
|
||||||
|
// Check for saved theme preference or default to light
|
||||||
|
const savedTheme = localStorage.getItem('vuetify-theme')
|
||||||
|
let currentTheme = savedTheme || 'light'
|
||||||
|
|
||||||
|
if (currentTheme === 'system') {
|
||||||
|
currentTheme = detectSystemTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme - Vuetify 3 syntax
|
||||||
|
vuetify.theme.global.name = currentTheme
|
||||||
|
|
||||||
|
// Apply CSS classes for better theme support
|
||||||
|
if (currentTheme === 'dark') {
|
||||||
|
document.body.classList.add('v-theme--dark')
|
||||||
|
document.body.classList.remove('v-theme--light')
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
} else {
|
||||||
|
document.body.classList.add('v-theme--light')
|
||||||
|
document.body.classList.remove('v-theme--dark')
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
if (window.matchMedia) {
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
if (localStorage.getItem('vuetify-theme') === 'system') {
|
||||||
|
const newTheme = e.matches ? 'dark' : 'light'
|
||||||
|
vuetify.theme.global.name = newTheme
|
||||||
|
|
||||||
|
// Update CSS classes
|
||||||
|
if (newTheme === 'dark') {
|
||||||
|
document.body.classList.add('v-theme--dark')
|
||||||
|
document.body.classList.remove('v-theme--light')
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
} else {
|
||||||
|
document.body.classList.add('v-theme--light')
|
||||||
|
document.body.classList.remove('v-theme--dark')
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common function to create and configure app
|
||||||
|
function createAndMountApp(mountElement, initialRoute, defaultRoute) {
|
||||||
|
const app = createApp({
|
||||||
template: `
|
template: `
|
||||||
<v-app>
|
<v-app>
|
||||||
<v-main>
|
<v-main>
|
||||||
|
|
@ -26,23 +74,37 @@ const app = createApp({
|
||||||
</v-main>
|
</v-main>
|
||||||
</v-app>
|
</v-app>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get initial page and route from data attributes
|
// Set initial route if provided
|
||||||
const appElement = document.getElementById('app')
|
if (initialRoute && initialRoute !== '') {
|
||||||
const initialPage = appElement ? appElement.dataset.page : 'dashboard'
|
router.push(`${defaultRoute}/${initialRoute}`)
|
||||||
const initialRoute = appElement ? appElement.dataset.route : ''
|
} else {
|
||||||
|
router.push(defaultRoute)
|
||||||
|
}
|
||||||
|
|
||||||
// Set initial route based on page and route
|
app.use(i18n)
|
||||||
if (initialPage && initialPage !== 'dashboard') {
|
app.use(router)
|
||||||
// Use router.push for programmatic navigation
|
app.use(vuetify)
|
||||||
router.push(`/${initialPage}`)
|
|
||||||
} else if (initialRoute && initialRoute !== 'dashboard') {
|
// Initialize theme after mounting
|
||||||
// Handle nested routes
|
app.mount(mountElement)
|
||||||
router.push(`/${initialRoute}`)
|
initializeTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
app.use(router)
|
// Check which section we're in
|
||||||
app.use(vuetify)
|
const appElement = document.getElementById('app')
|
||||||
|
const authElement = document.getElementById('auth')
|
||||||
|
|
||||||
app.mount('#app')
|
if (appElement) {
|
||||||
|
// App section
|
||||||
|
const initialRoute = appElement.dataset.route || ''
|
||||||
|
createAndMountApp('#app', initialRoute, '/ui')
|
||||||
|
} else if (authElement) {
|
||||||
|
// Auth section
|
||||||
|
const initialRoute = authElement.dataset.route || ''
|
||||||
|
createAndMountApp('#auth', initialRoute, '/auth')
|
||||||
|
} else {
|
||||||
|
// Fallback - create main app
|
||||||
|
createAndMountApp('#app', '', '/ui')
|
||||||
|
}
|
||||||
|
|
|
||||||
63
frontend/src/plugins/vuetify.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import 'vuetify/styles'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
import '@mdi/font/css/materialdesignicons.css'
|
||||||
|
|
||||||
|
// Theme configuration for Vuetify 3
|
||||||
|
const vuetify = createVuetify({
|
||||||
|
components,
|
||||||
|
directives,
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'system',
|
||||||
|
themes: {
|
||||||
|
light: {
|
||||||
|
dark: false,
|
||||||
|
colors: {
|
||||||
|
primary: '#1976d2',
|
||||||
|
secondary: '#424242',
|
||||||
|
accent: '#82b1ff',
|
||||||
|
error: '#ff5252',
|
||||||
|
info: '#2196f3',
|
||||||
|
success: '#4caf50',
|
||||||
|
warning: '#ff9800',
|
||||||
|
surface: '#ffffff',
|
||||||
|
background: '#fafafa',
|
||||||
|
'on-surface': '#000000',
|
||||||
|
'on-background': '#000000',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
dark: true,
|
||||||
|
colors: {
|
||||||
|
primary: '#90caf9',
|
||||||
|
secondary: '#bdbdbd',
|
||||||
|
accent: '#82b1ff',
|
||||||
|
error: '#ff5252',
|
||||||
|
info: '#2196f3',
|
||||||
|
success: '#4caf50',
|
||||||
|
warning: '#ff9800',
|
||||||
|
surface: '#121212',
|
||||||
|
background: '#000000',
|
||||||
|
'on-surface': '#ffffff',
|
||||||
|
'on-background': '#ffffff',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
VCard: {
|
||||||
|
rounded: 'lg',
|
||||||
|
elevation: 2
|
||||||
|
},
|
||||||
|
VTextField: {
|
||||||
|
variant: 'outlined',
|
||||||
|
density: 'comfortable'
|
||||||
|
},
|
||||||
|
VBtn: {
|
||||||
|
rounded: 'lg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default vuetify
|
||||||
75
frontend/src/router/app.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
|
||||||
|
// Placeholder component for app pages
|
||||||
|
const AppPlaceholderComponent = {
|
||||||
|
template: `
|
||||||
|
<v-container>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ $route.name }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p>این صفحه در حال توسعه است.</p>
|
||||||
|
<p>Route: {{ $route.path }}</p>
|
||||||
|
<p>Page: {{ $route.meta.page }}</p>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const appRoutes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'DashboardAlt',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/accounts',
|
||||||
|
name: 'Accounts',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'accounts' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/transactions',
|
||||||
|
name: 'Transactions',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'transactions' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/reports',
|
||||||
|
name: 'Reports',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'reports' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'Settings',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'settings' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
name: 'Users',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'users' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'Profile',
|
||||||
|
component: AppPlaceholderComponent,
|
||||||
|
meta: { page: 'profile' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Export routes array instead of router instance
|
||||||
|
export default appRoutes
|
||||||
68
frontend/src/router/auth.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import LoginPage from '../views/auth/login.vue'
|
||||||
|
import RegisterPage from '../views/auth/register.vue'
|
||||||
|
import ForgotPasswordPage from '../views/auth/forgot-password.vue'
|
||||||
|
|
||||||
|
// Placeholder component for other auth pages
|
||||||
|
const AuthPlaceholderComponent = {
|
||||||
|
template: `
|
||||||
|
<v-container>
|
||||||
|
<v-row justify="center" align="center" style="min-height: 100vh;">
|
||||||
|
<v-col cols="12" sm="8" md="6" lg="4">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-center">
|
||||||
|
{{ $route.name }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="text-center">
|
||||||
|
<p>صفحه احراز هویت در حال توسعه است.</p>
|
||||||
|
<p>Route: {{ $route.path }}</p>
|
||||||
|
<p>Page: {{ $route.meta.page }}</p>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRoutes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Login',
|
||||||
|
component: LoginPage,
|
||||||
|
meta: { page: 'login' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'LoginAlt',
|
||||||
|
component: LoginPage,
|
||||||
|
meta: { page: 'login' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: RegisterPage,
|
||||||
|
meta: { page: 'register' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/forgot-password',
|
||||||
|
name: 'ForgotPassword',
|
||||||
|
component: ForgotPasswordPage,
|
||||||
|
meta: { page: 'forgot-password' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/reset-password',
|
||||||
|
name: 'ResetPassword',
|
||||||
|
component: AuthPlaceholderComponent,
|
||||||
|
meta: { page: 'reset-password' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/verify-email',
|
||||||
|
name: 'VerifyEmail',
|
||||||
|
component: AuthPlaceholderComponent,
|
||||||
|
meta: { page: 'verify-email' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Export routes array instead of router instance
|
||||||
|
export default authRoutes
|
||||||
|
|
@ -1,76 +1,34 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import appRoutes from './app'
|
||||||
|
import authRoutes from './auth'
|
||||||
|
|
||||||
// Placeholder component for now
|
// Main router that handles both app and auth sections
|
||||||
const PlaceholderComponent = {
|
const mainRouter = createRouter({
|
||||||
template: `
|
history: createWebHistory(),
|
||||||
<v-container>
|
routes: [
|
||||||
<v-row>
|
// App routes with /ui prefix
|
||||||
<v-col cols="12">
|
...appRoutes.map(route => ({
|
||||||
<v-card>
|
...route,
|
||||||
<v-card-title>{{ $route.name }}</v-card-title>
|
path: `/ui${route.path === '/' ? '' : route.path}`
|
||||||
<v-card-text>
|
})),
|
||||||
<p>این صفحه در حال توسعه است.</p>
|
// Auth routes with /auth prefix
|
||||||
<p>Route: {{ $route.path }}</p>
|
...authRoutes.map(route => ({
|
||||||
<p>Page: {{ $route.meta.page }}</p>
|
...route,
|
||||||
</v-card-text>
|
path: `/auth${route.path === '/' ? '' : route.path}`
|
||||||
</v-card>
|
})),
|
||||||
</v-col>
|
// Default redirect
|
||||||
</v-row>
|
|
||||||
</v-container>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
const routes = [
|
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'Dashboard',
|
redirect: '/ui'
|
||||||
component: PlaceholderComponent,
|
|
||||||
meta: { page: 'dashboard' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/dashboard',
|
|
||||||
name: 'DashboardAlt',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
meta: { page: 'dashboard' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/accounts',
|
|
||||||
name: 'Accounts',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
meta: { page: 'accounts' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/transactions',
|
|
||||||
name: 'Transactions',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
meta: { page: 'transactions' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/reports',
|
|
||||||
name: 'Reports',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
meta: { page: 'reports' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings',
|
|
||||||
name: 'Settings',
|
|
||||||
component: PlaceholderComponent,
|
|
||||||
meta: { page: 'settings' }
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory('/ui/'),
|
|
||||||
routes
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigation guard to handle page changes
|
// Navigation guard for main router
|
||||||
router.beforeEach((to, from, next) => {
|
mainRouter.beforeEach((to, from, next) => {
|
||||||
// Update current page in meta
|
console.log('Main router navigating to:', to.path)
|
||||||
if (to.meta.page) {
|
console.log('Route matched:', to.matched)
|
||||||
console.log('Navigating to:', to.meta.page)
|
|
||||||
}
|
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default mainRouter
|
||||||
|
|
|
||||||
107
frontend/src/views/auth/forgot-password.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="fill-height">
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="6" lg="4">
|
||||||
|
<v-card class="elevation-12">
|
||||||
|
<v-toolbar color="warning" dark flat>
|
||||||
|
<v-toolbar-title>فراموشی رمز عبور</v-toolbar-title>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-card-text class="pt-4">
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
ایمیل خود را وارد کنید تا لینک بازنشانی رمز عبور برای شما ارسال شود.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<v-form @submit.prevent="handleForgotPassword" ref="forgotPasswordForm">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.email"
|
||||||
|
label="ایمیل"
|
||||||
|
name="email"
|
||||||
|
prepend-icon="mdi-email"
|
||||||
|
type="email"
|
||||||
|
:rules="emailRules"
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="warning"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
ارسال لینک بازنشانی
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="px-4 py-4">
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="primary"
|
||||||
|
@click="$router.push('/auth/login')"
|
||||||
|
>
|
||||||
|
بازگشت به ورود
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ForgotPasswordPage',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
email: ''
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
emailRules: [
|
||||||
|
v => !!v || 'ایمیل الزامی است',
|
||||||
|
v => /.+@.+\..+/.test(v) || 'ایمیل معتبر نیست'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async handleForgotPassword() {
|
||||||
|
if (!this.$refs.forgotPasswordForm.validate()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// اینجا API call برای فراموشی رمز عبور انجام میشود
|
||||||
|
console.log('Forgot password attempt:', this.form)
|
||||||
|
|
||||||
|
// شبیهسازی delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// نمایش پیام موفقیت
|
||||||
|
console.log('Reset link sent successfully')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Forgot password error:', error)
|
||||||
|
// نمایش پیام خطا
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fill-height {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
226
frontend/src/views/auth/login.vue
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="fill-height">
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="6" lg="4">
|
||||||
|
<v-card class="elevation-12">
|
||||||
|
<!-- Header -->
|
||||||
|
<v-card-title class="text-center pt-6">
|
||||||
|
<v-row justify="center" align="center">
|
||||||
|
<v-col cols="auto">
|
||||||
|
<v-avatar size="64" color="primary" class="mb-3">
|
||||||
|
<v-icon size="32" color="white">mdi-shield-account</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row justify="center">
|
||||||
|
<v-col cols="12">
|
||||||
|
<h1 class="text-h4 font-weight-bold primary--text">
|
||||||
|
{{ t('common.brandName') }}
|
||||||
|
</h1>
|
||||||
|
<h2 class="text-h5 mt-2">{{ t('auth.welcome') }}</h2>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mt-1">
|
||||||
|
{{ t('auth.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<!-- Language Switcher and Theme Switcher -->
|
||||||
|
<v-card-text class="text-center pb-0">
|
||||||
|
<div class="d-flex justify-center align-center gap-2">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<v-card-text>
|
||||||
|
<v-form @submit.prevent="handleLogin" ref="loginForm">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.email"
|
||||||
|
:label="t('auth.email')"
|
||||||
|
:placeholder="t('auth.placeholder.email')"
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
type="email"
|
||||||
|
variant="outlined"
|
||||||
|
:error-messages="emailError"
|
||||||
|
:rules="emailRules"
|
||||||
|
required
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.password"
|
||||||
|
:label="t('auth.password')"
|
||||||
|
:placeholder="t('auth.placeholder.password')"
|
||||||
|
prepend-inner-icon="mdi-lock"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
variant="outlined"
|
||||||
|
:error-messages="passwordError"
|
||||||
|
:rules="passwordRules"
|
||||||
|
required
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<template v-slot:append-inner>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-eye"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-text-field>
|
||||||
|
|
||||||
|
<div class="d-flex justify-space-between align-center mb-6">
|
||||||
|
<v-checkbox
|
||||||
|
v-model="form.rememberMe"
|
||||||
|
:label="t('auth.rememberMe')"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
:href="'/auth/forgot-password'"
|
||||||
|
class="text-none"
|
||||||
|
>
|
||||||
|
{{ t('auth.forgotPassword') }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
{{ t('auth.login') }}
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<v-card-text class="text-center">
|
||||||
|
<p class="text-body-2 text-medium-emphasis">
|
||||||
|
{{ t('auth.noAccount') }}
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
:href="'/auth/register'"
|
||||||
|
class="text-none px-1"
|
||||||
|
>
|
||||||
|
{{ t('auth.registerHere') }}
|
||||||
|
</v-btn>
|
||||||
|
</p>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import LanguageSwitcher from '../../components/LanguageSwitcher.vue'
|
||||||
|
import ThemeSwitcher from '../../components/ThemeSwitcher.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'LoginPage',
|
||||||
|
components: {
|
||||||
|
LanguageSwitcher,
|
||||||
|
ThemeSwitcher
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
rememberMe: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showPassword = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const emailError = ref('')
|
||||||
|
const passwordError = ref('')
|
||||||
|
const loginForm = ref(null)
|
||||||
|
|
||||||
|
// Validation rules
|
||||||
|
const emailRules = computed(() => [
|
||||||
|
v => !!v || t('auth.emailRequired'),
|
||||||
|
v => /.+@.+\..+/.test(v) || t('auth.emailInvalid')
|
||||||
|
])
|
||||||
|
|
||||||
|
const passwordRules = computed(() => [
|
||||||
|
v => !!v || t('auth.passwordRequired'),
|
||||||
|
v => v.length >= 6 || t('auth.passwordMinLength')
|
||||||
|
])
|
||||||
|
|
||||||
|
const validateForm = async () => {
|
||||||
|
const { valid } = await loginForm.value.validate()
|
||||||
|
return valid
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!(await validateForm())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// اینجا API call برای لاگین انجام میشود
|
||||||
|
console.log('Login attempt:', form.value)
|
||||||
|
|
||||||
|
// شبیهسازی delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
|
||||||
|
// در صورت موفقیت، کاربر را به dashboard هدایت کنید
|
||||||
|
// this.$router.push('/ui/dashboard')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
// نمایش پیام خطا
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
showPassword,
|
||||||
|
loading,
|
||||||
|
emailError,
|
||||||
|
passwordError,
|
||||||
|
loginForm,
|
||||||
|
emailRules,
|
||||||
|
passwordRules,
|
||||||
|
validateForm,
|
||||||
|
handleLogin,
|
||||||
|
t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* RTL support */
|
||||||
|
.v-container {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom primary color if needed */
|
||||||
|
:deep(.v-theme--light) {
|
||||||
|
--v-theme-primary: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.v-theme--dark) {
|
||||||
|
--v-theme-primary: #90caf9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
171
frontend/src/views/auth/register.vue
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<template>
|
||||||
|
<v-container fluid class="fill-height">
|
||||||
|
<v-row align="center" justify="center">
|
||||||
|
<v-col cols="12" sm="8" md="6" lg="4">
|
||||||
|
<v-card class="elevation-12">
|
||||||
|
<v-toolbar color="success" dark flat>
|
||||||
|
<v-toolbar-title>ثبت نام</v-toolbar-title>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-card-text class="pt-4">
|
||||||
|
<v-form @submit.prevent="handleRegister" ref="registerForm">
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.fullName"
|
||||||
|
label="نام و نام خانوادگی"
|
||||||
|
name="fullName"
|
||||||
|
prepend-icon="mdi-account"
|
||||||
|
:rules="fullNameRules"
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.email"
|
||||||
|
label="ایمیل"
|
||||||
|
name="email"
|
||||||
|
prepend-icon="mdi-email"
|
||||||
|
type="email"
|
||||||
|
:rules="emailRules"
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.password"
|
||||||
|
label="رمز عبور"
|
||||||
|
name="password"
|
||||||
|
prepend-icon="mdi-lock"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||||
|
@click:append="showPassword = !showPassword"
|
||||||
|
:rules="passwordRules"
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
label="تکرار رمز عبور"
|
||||||
|
name="confirmPassword"
|
||||||
|
prepend-icon="mdi-lock-check"
|
||||||
|
:type="showConfirmPassword ? 'text' : 'password'"
|
||||||
|
:append-icon="showConfirmPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||||
|
@click:append="showConfirmPassword = !showConfirmPassword"
|
||||||
|
:rules="confirmPasswordRules"
|
||||||
|
required
|
||||||
|
variant="outlined"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-checkbox
|
||||||
|
v-model="form.agreeTerms"
|
||||||
|
label="قوانین و شرایط را میپذیرم"
|
||||||
|
color="success"
|
||||||
|
:rules="[v => !!v || 'پذیرش قوانین الزامی است']"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
type="submit"
|
||||||
|
color="success"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
ثبت نام
|
||||||
|
</v-btn>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="px-4 py-4">
|
||||||
|
<v-spacer />
|
||||||
|
<span class="text-body-2 text-medium-emphasis">
|
||||||
|
قبلاً ثبت نام کردهاید؟
|
||||||
|
</span>
|
||||||
|
<v-btn
|
||||||
|
text
|
||||||
|
color="success"
|
||||||
|
@click="$router.push('/auth/login')"
|
||||||
|
>
|
||||||
|
ورود
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'RegisterPage',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
fullName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
agreeTerms: false
|
||||||
|
},
|
||||||
|
showPassword: false,
|
||||||
|
showConfirmPassword: false,
|
||||||
|
loading: false,
|
||||||
|
fullNameRules: [
|
||||||
|
v => !!v || 'نام و نام خانوادگی الزامی است',
|
||||||
|
v => v.length >= 3 || 'نام باید حداقل 3 کاراکتر باشد'
|
||||||
|
],
|
||||||
|
emailRules: [
|
||||||
|
v => !!v || 'ایمیل الزامی است',
|
||||||
|
v => /.+@.+\..+/.test(v) || 'ایمیل معتبر نیست'
|
||||||
|
],
|
||||||
|
passwordRules: [
|
||||||
|
v => !!v || 'رمز عبور الزامی است',
|
||||||
|
v => v.length >= 6 || 'رمز عبور باید حداقل 6 کاراکتر باشد'
|
||||||
|
],
|
||||||
|
confirmPasswordRules: [
|
||||||
|
v => !!v || 'تکرار رمز عبور الزامی است',
|
||||||
|
v => v === this.form.password || 'رمز عبور و تکرار آن یکسان نیستند'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async handleRegister() {
|
||||||
|
if (!this.$refs.registerForm.validate()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// اینجا API call برای ثبت نام انجام میشود
|
||||||
|
console.log('Register attempt:', this.form)
|
||||||
|
|
||||||
|
// شبیهسازی delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// در صورت موفقیت، کاربر را به صفحه لاگین هدایت کنید
|
||||||
|
this.$router.push('/auth/login')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Register error:', error)
|
||||||
|
// نمایش پیام خطا
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fill-height {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
const Encore = require('@symfony/webpack-encore');
|
const Encore = require('@symfony/webpack-encore');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
|
||||||
Encore
|
Encore
|
||||||
.setOutputPath('../public_html/build/')
|
.setOutputPath('../public_html/build/')
|
||||||
.setPublicPath('/build/')
|
.setPublicPath('/build/')
|
||||||
.addEntry('app', './src/main.js')
|
.addEntry('main', './src/main.js')
|
||||||
.enableVueLoader()
|
.enableVueLoader()
|
||||||
.enableSassLoader()
|
.enableSassLoader()
|
||||||
.enableVersioning()
|
.enableVersioning()
|
||||||
.disableSingleRuntimeChunk()
|
.disableSingleRuntimeChunk()
|
||||||
.splitEntryChunks()
|
.splitEntryChunks()
|
||||||
.enableSourceMaps(!Encore.isProduction())
|
.enableSourceMaps(!Encore.isProduction())
|
||||||
.enableBuildNotifications();
|
.enableBuildNotifications()
|
||||||
|
.addStyleEntry('rtl-ltr', './src/assets/styles/rtl-ltr.css')
|
||||||
|
.addStyleEntry('vuetify-rtl', './src/assets/styles/vuetify-rtl.css')
|
||||||
|
.addStyleEntry('components-rtl', './src/assets/styles/components-rtl.css')
|
||||||
|
.addPlugin(new webpack.DefinePlugin({
|
||||||
|
__VUE_OPTIONS_API__: JSON.stringify(true),
|
||||||
|
__VUE_PROD_DEVTOOLS__: JSON.stringify(false),
|
||||||
|
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(false)
|
||||||
|
}));
|
||||||
|
|
||||||
module.exports = Encore.getWebpackConfig();
|
module.exports = Encore.getWebpackConfig();
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ DirectoryIndex index.php
|
||||||
RewriteCond %{REQUEST_URI} ^/ui
|
RewriteCond %{REQUEST_URI} ^/ui
|
||||||
RewriteRule ^ %{ENV:BASE}/index.php [L]
|
RewriteRule ^ %{ENV:BASE}/index.php [L]
|
||||||
|
|
||||||
|
# Handle Auth routes - let Symfony handle /auth/* routes
|
||||||
|
RewriteCond %{REQUEST_URI} ^/auth
|
||||||
|
RewriteRule ^ %{ENV:BASE}/index.php [L]
|
||||||
|
|
||||||
|
# Handle API routes - let Symfony handle /api/* routes
|
||||||
|
RewriteCond %{REQUEST_URI} ^/api
|
||||||
|
RewriteRule ^ %{ENV:BASE}/index.php [L]
|
||||||
|
|
||||||
# If the requested filename exists, simply serve it
|
# If the requested filename exists, simply serve it
|
||||||
RewriteCond %{REQUEST_FILENAME} !-f
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
RewriteRule ^ %{ENV:BASE}/index.php [L]
|
RewriteRule ^ %{ENV:BASE}/index.php [L]
|
||||||
|
|
|
||||||
BIN
public_html/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public_html/img/avatar.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public_html/img/avatar.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public_html/img/logo-blue.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public_html/img/logo-light.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
public_html/img/logo.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public_html/img/logo32.png
Normal file
|
After Width: | Height: | Size: 858 B |
3
public_html/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
|
Disallow: /cgi-bin/
|
||||||