progress in base system

This commit is contained in:
Hesabix 2025-09-01 13:21:04 -04:00
parent 9a67d736dc
commit b9964bad21
76 changed files with 3493 additions and 1592 deletions

8
.env
View file

@ -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 ###

View file

@ -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]+)?$',
); );

View file

@ -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

View file

@ -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
View file

@ -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",

View file

@ -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],
]; ];

View 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']

View file

@ -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

View file

@ -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

View file

@ -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

View 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');
}
}

View 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')
]
]);
}
}

View 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' => 'زبان با موفقیت تغییر کرد'
]);
}
}

View 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);
}
}
}

View file

@ -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(),
]);
}
}

View file

@ -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');
}
}

View file

@ -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 مدیریت شود');
}
}

View 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'
]);
}
}

View file

@ -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';
}
} }

View file

@ -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;
}
} }

View 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;
}
}

View 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;
}
}

View 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; // اولویت بالا
}
}

View 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; // اولویت متوسط
}
}

View 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);
}
}

View file

@ -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;
}

View file

@ -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([]);
}
}

View file

@ -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,
]);
}
}

View file

@ -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([]);
}
}

View 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 [];
}
}

View file

@ -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": {

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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>&copy; {{ "now"|date("Y") }} سیستم حسابداری حسابی‌کس</p> <p>&copy; {{ "now"|date("Y") }} حسابیکس. تمامی حقوق محفوظ است.</p>
</div> </div>
</div> </div>
</body> </body>

View file

@ -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 %}

View file

@ -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 %}

View 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 %}

View 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'

View 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'

View file

@ -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",

View file

@ -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",

View file

@ -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
View 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'
}

View 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;
}
}

View 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;
}

View 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;
}

View 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
View 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'
}

View 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>

View 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>

View 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

View 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'
}
}

View 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: 'سیستم'
}
}

View file

@ -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')
}

View 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

View 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

View 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

View file

@ -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

View 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>

View 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>

View 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>

View file

@ -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();

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public_html/img/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
public_html/img/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

BIN
public_html/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
public_html/img/logo32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

3
public_html/robots.txt Normal file
View file

@ -0,0 +1,3 @@
User-agent: *
Disallow:
Disallow: /cgi-bin/