diff --git a/.env b/.env index 42ad71d..f2ba6d2 100644 --- a/.env +++ b/.env @@ -24,9 +24,9 @@ APP_SECRET= # 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="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="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 ### ###> symfony/messenger ### @@ -39,3 +39,7 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### diff --git a/.env.local.php b/.env.local.php index c8c314e..d91628d 100644 --- a/.env.local.php +++ b/.env.local.php @@ -6,7 +6,8 @@ return array ( 'APP_ENV' => 'dev', 'SYMFONY_DOTENV_PATH' => './../.env', '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', 'MAILER_DSN' => 'null://null', + 'CORS_ALLOW_ORIGIN' => '^https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?$', ); diff --git a/README.md b/README.md index 172be4f..62df869 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ │ │ ├── views/ # صفحات اصلی │ │ ├── router/ # Vue Router │ │ ├── store/ # Vuex store -│ │ └── assets/ # فایلهای استاتیک +│ │ ├── assets/ # فایلهای استاتیک +│ │ │ └── styles/ # فایلهای CSS +│ │ └── i18n/ # بینالمللیسازی │ ├── public/ │ ├── package.json │ └── webpack.config.js @@ -87,10 +89,33 @@ npm run build # Build production ## 🎨 UI/UX Features - **RTL Support**: پشتیبانی کامل از راست به چپ -- **Persian Font**: فونت Vazirmatn +- **Persian Font**: فونت Vazir و Tahoma - **Material Design**: کامپوننتهای زیبا - **Responsive**: سازگار با همه دستگاهها - **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 @@ -129,7 +154,8 @@ npm run build - Build process کاملاً مستقل است - Symfony به عنوان API backend عمل میکند - 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` 2. **Build errors**: بررسی webpack.config.js 3. **Routing issues**: بررسی .htaccess +4. **RTL issues**: بررسی فایلهای CSS و i18n ### Logs - Symfony: `core/var/log/` - Webpack: `frontend/` console +## 📚 مستندات اضافی + +- `frontend/RTL_SETUP.md` - راهنمای کامل RTL +- `frontend/MULTILINGUAL_SETUP.md` - راهنمای چندزبانه +- `frontend/THEME_SETUP.md` - راهنمای تمها + ## 📞 پشتیبانی برای سوالات و مشکلات: - بررسی documentation - بررسی console errors - بررسی network tab +- تست کامپوننت RTLTest diff --git a/core/composer.json b/core/composer.json index bde38e4..a9d3e16 100644 --- a/core/composer.json +++ b/core/composer.json @@ -11,6 +11,7 @@ "doctrine/doctrine-bundle": "^2.15", "doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/orm": "^3.5", + "nelmio/cors-bundle": "^2.5", "phpdocumentor/reflection-docblock": "^5.6", "phpstan/phpdoc-parser": "^2.3", "symfony/apache-pack": "*", diff --git a/core/composer.lock b/core/composer.lock index 2db0a93..b683e48 100644 --- a/core/composer.lock +++ b/core/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0c02e701f0667561c6de51c5995abb1f", + "content-hash": "42015cabfb93805b806ccf473df84f01", "packages": [ { "name": "doctrine/collections", @@ -1300,6 +1300,68 @@ ], "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", "version": "2.2.0", diff --git a/core/config/bundles.php b/core/config/bundles.php index 3325715..57b43cb 100644 --- a/core/config/bundles.php +++ b/core/config/bundles.php @@ -12,4 +12,5 @@ return [ Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], ]; diff --git a/core/config/packages/nelmio_cors.yaml b/core/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..92c5b67 --- /dev/null +++ b/core/config/packages/nelmio_cors.yaml @@ -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'] diff --git a/core/config/packages/translation.yaml b/core/config/packages/translation.yaml index 490bfc2..43fe1da 100644 --- a/core/config/packages/translation.yaml +++ b/core/config/packages/translation.yaml @@ -1,5 +1,11 @@ framework: - default_locale: en + default_locale: fa translator: default_path: '%kernel.project_dir%/translations' + fallbacks: + - fa providers: + app: + dsn: 'app://translations' + set_content_language_from_locale: true + set_locale_from_accept_language: true diff --git a/core/config/packages/twig.yaml b/core/config/packages/twig.yaml index 3f795d9..a9a3559 100644 --- a/core/config/packages/twig.yaml +++ b/core/config/packages/twig.yaml @@ -1,6 +1,5 @@ twig: file_name_pattern: '*.twig' - when@test: twig: strict_variables: true diff --git a/core/config/services.yaml b/core/config/services.yaml index 6bbad87..2cfa29e 100644 --- a/core/config/services.yaml +++ b/core/config/services.yaml @@ -16,5 +16,22 @@ services: App\: 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 # please note that last definitions always *replace* previous ones diff --git a/core/migrations/Version20241201000001.php b/core/migrations/Version20241201000001.php new file mode 100644 index 0000000..7e35798 --- /dev/null +++ b/core/migrations/Version20241201000001.php @@ -0,0 +1,31 @@ +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'); + } +} diff --git a/core/src/Controller/Api/AuthController.php b/core/src/Controller/Api/AuthController.php new file mode 100644 index 0000000..1ecf51e --- /dev/null +++ b/core/src/Controller/Api/AuthController.php @@ -0,0 +1,282 @@ +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') + ] + ]); + } +} diff --git a/core/src/Controller/Api/LocaleApiController.php b/core/src/Controller/Api/LocaleApiController.php new file mode 100644 index 0000000..f4aae1c --- /dev/null +++ b/core/src/Controller/Api/LocaleApiController.php @@ -0,0 +1,48 @@ +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' => 'زبان با موفقیت تغییر کرد' + ]); + } +} diff --git a/core/src/Controller/Api/TestEventController.php b/core/src/Controller/Api/TestEventController.php new file mode 100644 index 0000000..367da9e --- /dev/null +++ b/core/src/Controller/Api/TestEventController.php @@ -0,0 +1,64 @@ +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); + } + } +} diff --git a/core/src/Controller/Auth/RegistrationController.php b/core/src/Controller/Auth/RegistrationController.php deleted file mode 100644 index f39f09b..0000000 --- a/core/src/Controller/Auth/RegistrationController.php +++ /dev/null @@ -1,78 +0,0 @@ -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(), - ]); - } -} diff --git a/core/src/Controller/Auth/ResetPasswordController.php b/core/src/Controller/Auth/ResetPasswordController.php deleted file mode 100644 index cfa8214..0000000 --- a/core/src/Controller/Auth/ResetPasswordController.php +++ /dev/null @@ -1,128 +0,0 @@ -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'); - } -} diff --git a/core/src/Controller/Auth/SecurityController.php b/core/src/Controller/Auth/SecurityController.php deleted file mode 100644 index a311cb1..0000000 --- a/core/src/Controller/Auth/SecurityController.php +++ /dev/null @@ -1,36 +0,0 @@ -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 مدیریت شود'); - } -} diff --git a/core/src/Controller/LocaleController.php b/core/src/Controller/LocaleController.php new file mode 100644 index 0000000..dcd8620 --- /dev/null +++ b/core/src/Controller/LocaleController.php @@ -0,0 +1,46 @@ + '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' + ]); + } +} diff --git a/core/src/Controller/UIController.php b/core/src/Controller/UIController.php index c99d481..3a9d900 100644 --- a/core/src/Controller/UIController.php +++ b/core/src/Controller/UIController.php @@ -10,39 +10,22 @@ final class UIController extends AbstractController { #[Route('/ui', name: 'app_ui_home')] #[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', [ - 'page' => $page, + 'route' => $route + ]); + } + + #[Route('/auth', name: 'app_auth_home')] + #[Route('/auth/{route}', name: 'app_auth_route', requirements: ['route' => '.+'])] + public function app_auth_home(string $route = ''): Response + { + + return $this->render('ui/auth.html.twig', [ 'route' => $route ]); } - private function extractPageFromRoute(string $route): string - { - // Remove leading slash and get first segment - $route = ltrim($route, '/'); - - if (empty($route)) { - return 'dashboard'; - } - - // 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'; - } } diff --git a/core/src/Entity/User.php b/core/src/Entity/User.php index 4b87c4d..b54b2a0 100644 --- a/core/src/Entity/User.php +++ b/core/src/Entity/User.php @@ -64,6 +64,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(nullable: true)] private ?\DateTimeImmutable $resetTokenExpiresAt = null; + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $lastLoginAt = null; + + #[ORM\Column] + private int $loginCount = 0; + public function __construct() { $this->createdAt = new \DateTimeImmutable(); @@ -205,4 +211,26 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface } 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; + } } diff --git a/core/src/Event/Auth/UserLoginEvent.php b/core/src/Event/Auth/UserLoginEvent.php new file mode 100644 index 0000000..c13e29b --- /dev/null +++ b/core/src/Event/Auth/UserLoginEvent.php @@ -0,0 +1,38 @@ +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; + } +} diff --git a/core/src/Event/BaseEvent.php b/core/src/Event/BaseEvent.php new file mode 100644 index 0000000..e5241e2 --- /dev/null +++ b/core/src/Event/BaseEvent.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/core/src/EventListener/Auth/LoginAuditListener.php b/core/src/EventListener/Auth/LoginAuditListener.php new file mode 100644 index 0000000..01de89c --- /dev/null +++ b/core/src/EventListener/Auth/LoginAuditListener.php @@ -0,0 +1,55 @@ +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; // اولویت بالا + } +} diff --git a/core/src/EventListener/Auth/UserActivityListener.php b/core/src/EventListener/Auth/UserActivityListener.php new file mode 100644 index 0000000..938ad64 --- /dev/null +++ b/core/src/EventListener/Auth/UserActivityListener.php @@ -0,0 +1,71 @@ +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; // اولویت متوسط + } +} diff --git a/core/src/EventListener/LocaleListener.php b/core/src/EventListener/LocaleListener.php new file mode 100644 index 0000000..9b86d14 --- /dev/null +++ b/core/src/EventListener/LocaleListener.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/core/src/EventListener/RollbackableEventListenerInterface.php b/core/src/EventListener/RollbackableEventListenerInterface.php new file mode 100644 index 0000000..68754c7 --- /dev/null +++ b/core/src/EventListener/RollbackableEventListenerInterface.php @@ -0,0 +1,23 @@ +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([]); - } -} diff --git a/core/src/Form/Auth/RegistrationFormType.php b/core/src/Form/Auth/RegistrationFormType.php deleted file mode 100644 index 22bcc42..0000000 --- a/core/src/Form/Auth/RegistrationFormType.php +++ /dev/null @@ -1,106 +0,0 @@ -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, - ]); - } -} diff --git a/core/src/Form/Auth/ResetPasswordRequestFormType.php b/core/src/Form/Auth/ResetPasswordRequestFormType.php deleted file mode 100644 index 97a3049..0000000 --- a/core/src/Form/Auth/ResetPasswordRequestFormType.php +++ /dev/null @@ -1,35 +0,0 @@ -add('email', EmailType::class, [ - 'label' => 'ایمیل', - 'attr' => [ - 'class' => 'form-control', - 'placeholder' => 'ایمیل خود را وارد کنید' - ], - 'constraints' => [ - new NotBlank([ - 'message' => 'لطفاً ایمیل خود را وارد کنید', - ]), - ], - ]) - ; - } - - public function configureOptions(OptionsResolver $resolver): void - { - $resolver->setDefaults([]); - } -} diff --git a/core/src/Service/EventTransactionManager.php b/core/src/Service/EventTransactionManager.php new file mode 100644 index 0000000..35c6235 --- /dev/null +++ b/core/src/Service/EventTransactionManager.php @@ -0,0 +1,123 @@ +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 []; + } +} diff --git a/core/symfony.lock b/core/symfony.lock index 9c6149e..b23c310 100644 --- a/core/symfony.lock +++ b/core/symfony.lock @@ -35,6 +35,18 @@ "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": { "version": "12.3", "recipe": { diff --git a/core/templates/auth/base.html.twig b/core/templates/auth/base.html.twig deleted file mode 100644 index b1515a5..0000000 --- a/core/templates/auth/base.html.twig +++ /dev/null @@ -1,514 +0,0 @@ - - -
- - -{% block auth_subtitle %}به سیستم حسابیکس خوش آمدید{% endblock %}
-- اگر ایمیلی با آدرس وارد شده در سیستم ثبت شده باشد، - لینک بازنشانی رمز عبور برای شما ارسال شده است. -
-- نگران نباشید! ما لینک بازنشانی رمز عبور را برای شما ارسال خواهیم کرد. -
-- رمز عبور جدید خود را انتخاب کنید. این رمز عبور جایگزین رمز عبور قبلی خواهد شد. -
-این صفحه در حال توسعه است.
+Route: {{ $route.path }}
+Page: {{ $route.meta.page }}
+صفحه احراز هویت در حال توسعه است.
+Route: {{ $route.path }}
+Page: {{ $route.meta.page }}
+این صفحه در حال توسعه است.
-Route: {{ $route.path }}
-Page: {{ $route.meta.page }}
-+ ایمیل خود را وارد کنید تا لینک بازنشانی رمز عبور برای شما ارسال شود. +
+ ++ {{ t('auth.subtitle') }} +
+
+ {{ t('auth.noAccount') }}
+