start working on auth part

This commit is contained in:
Hesabix 2025-08-31 12:18:07 -04:00
parent 02ea1b0a26
commit 29b49d397d
50 changed files with 12574 additions and 2709 deletions

12
.env.local.php Normal file
View file

@ -0,0 +1,12 @@
<?php
// This file was generated by running "composer dump-env dev"
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',
'MESSENGER_TRANSPORT_DSN' => 'doctrine://default?auto_setup=0',
'MAILER_DSN' => 'null://null',
);

68
.gitignore vendored
View file

@ -1,20 +1,54 @@
# Dependencies
node_modules/
vendor/
###> symfony/framework-bundle ### # Build files
/.env.local public_html/build/
/.env.local.php core/var/cache/
/.env.*.local core/var/log/
/config/secrets/prod/prod.decrypt.private.php frontend/node_modules/
/public_html/bundles/ # Environment files
/var/ .env
/vendor/ .env.local
###< symfony/framework-bundle ### .env.prod
###> phpunit/phpunit ### # IDE files
/phpunit.xml .vscode/
/.phpunit.cache/ .idea/
###< phpunit/phpunit ### *.swp
*.swo
###> symfony/asset-mapper ### # OS files
/public/assets/ .DS_Store
/assets/vendor/ Thumbs.db
###< symfony/asset-mapper ###
# Logs
*.log
npm-debug.log*
var/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env

150
README.md Normal file
View file

@ -0,0 +1,150 @@
# سیستم حسابداری - Vue.js + Vuetify + Symfony
این پروژه یک سیستم حسابداری کامل است که از Vue.js 3، Vuetify 3 و Symfony 7 استفاده می‌کند.
## 🏗️ ساختار پروژه
```
/var/www/v2.hesabix.ir/
├── core/ # Symfony backend
├── frontend/ # Vue.js + Vuetify frontend
│ ├── src/
│ │ ├── components/ # کامپوننت‌های Vue
│ │ ├── views/ # صفحات اصلی
│ │ ├── router/ # Vue Router
│ │ ├── store/ # Vuex store
│ │ └── assets/ # فایل‌های استاتیک
│ ├── public/
│ ├── package.json
│ └── webpack.config.js
├── public_html/ # فایل‌های build شده
└── vendor/ # PHP dependencies
```
## 🚀 نصب و راه‌اندازی
### پیش‌نیازها
- PHP 8.2+
- Node.js 18+
- Composer
- npm
### Backend (Symfony)
```bash
cd core
composer install
```
### Frontend (Vue.js)
```bash
cd frontend
npm install
```
## 🔧 دستورات توسعه
### Development
```bash
cd frontend
npm run dev # Build development
npm run watch # Build + watch changes
```
### Production
```bash
cd frontend
npm run build # Build production
```
## 📱 ویژگی‌ها
### کامپوننت‌های اصلی
- **Dashboard**: نمایش خلاصه مالی
- **Accounts**: مدیریت حساب‌ها
- **Transactions**: مدیریت تراکنش‌ها
- **Reports**: گزارشات مالی
- **Settings**: تنظیمات سیستم
### تکنولوژی‌ها
- **Vue 3**: Composition API
- **Vuetify 3**: Material Design components
- **Vue Router 4**: Client-side routing
- **Vuex 4**: State management
- **Webpack Encore**: Asset compilation
## 🌐 روتینگ
### Vue Router (Frontend)
- `/` - Dashboard
- `/accounts` - مدیریت حساب‌ها
- `/transactions` - مدیریت تراکنش‌ها
- `/reports` - گزارشات
- `/settings` - تنظیمات
### Symfony Routes (Backend)
- `/api/*` - API endpoints
## 🎨 UI/UX Features
- **RTL Support**: پشتیبانی کامل از راست به چپ
- **Persian Font**: فونت Vazirmatn
- **Material Design**: کامپوننت‌های زیبا
- **Responsive**: سازگار با همه دستگاه‌ها
- **Dark/Light Theme**: تم‌های مختلف
## 📊 کامپوننت‌های Vuetify
- **Data Tables**: نمایش داده‌ها
- **Cards**: کارت‌های اطلاعاتی
- **Forms**: فرم‌های ورودی
- **Navigation**: منوهای کاربری
- **Charts**: نمودارها (آینده)
- **Dialogs**: پنجره‌های تعاملی
## 🔄 Build Process
1. **Development**: `npm run dev`
2. **Watch Mode**: `npm run watch`
3. **Production**: `npm run build`
فایل‌های build شده در `public_html/build/` قرار می‌گیرند.
## 🚀 Deployment
### Development Server
```bash
cd frontend
npm run dev-server
```
### Production Build
```bash
cd frontend
npm run build
```
## 📝 نکات مهم
- فایل‌های frontend در پوشه جداگانه قرار دارند
- Build process کاملاً مستقل است
- Symfony به عنوان API backend عمل می‌کند
- Vue Router برای client-side routing استفاده می‌شود
- فونت فارسی Vazirmatn برای RTL استفاده شده
## 🐛 عیب‌یابی
### مشکلات رایج
1. **Node modules**: `rm -rf node_modules && npm install`
2. **Build errors**: بررسی webpack.config.js
3. **Routing issues**: بررسی .htaccess
### Logs
- Symfony: `core/var/log/`
- Webpack: `frontend/` console
## 📞 پشتیبانی
برای سوالات و مشکلات:
- بررسی documentation
- بررسی console errors
- بررسی network tab

11
core/.gitignore vendored
View file

@ -1,4 +1,3 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
/.env.local /.env.local
/.env.local.php /.env.local.php
@ -14,7 +13,9 @@
/.phpunit.cache/ /.phpunit.cache/
###< phpunit/phpunit ### ###< phpunit/phpunit ###
###> symfony/asset-mapper ### ###> symfony/webpack-encore-bundle ###
/public/assets/ /node_modules/
/assets/vendor/ /../public_html/build/
###< symfony/asset-mapper ### npm-debug.log
yarn-error.log
###< symfony/webpack-encore-bundle ###

View file

@ -1,10 +0,0 @@
import './bootstrap.js';
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './styles/app.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

View file

@ -1,5 +0,0 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);

View file

@ -1,15 +0,0 @@
{
"controllers": {
"@symfony/ux-turbo": {
"turbo-core": {
"enabled": true,
"fetch": "eager"
},
"mercure-turbo-stream": {
"enabled": false,
"fetch": "eager"
}
}
},
"entrypoints": []
}

View file

@ -1,79 +0,0 @@
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
document.addEventListener('submit', function (event) {
generateCsrfToken(event.target);
}, true);
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener('turbo:submit-start', function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener('turbo:submit-end', function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});
export function generateCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
let csrfToken = csrfField.value;
if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
}
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
export function generateCsrfHeaders (formElement) {
const headers = {};
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return headers;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}
return headers;
}
export function removeCsrfToken (formElement) {
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
if (!csrfField) {
return;
}
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
}
}
/* stimulusFetch: 'lazy' */
export default 'csrf-protection-controller';

View file

@ -1,16 +0,0 @@
import { Controller } from '@hotwired/stimulus';
/*
* This is an example Stimulus controller!
*
* Any element with a data-controller="hello" attribute will cause
* this controller to be executed. The name "hello" comes from the filename:
* hello_controller.js -> "hello"
*
* Delete this file or adapt it for your use!
*/
export default class extends Controller {
connect() {
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js';
}
}

View file

@ -1,3 +0,0 @@
body {
background-color: skyblue;
}

View file

@ -13,8 +13,8 @@
"doctrine/orm": "^3.5", "doctrine/orm": "^3.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/asset": "7.3.*", "symfony/asset": "7.3.*",
"symfony/asset-mapper": "7.3.*",
"symfony/console": "7.3.*", "symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*", "symfony/doctrine-messenger": "7.3.*",
"symfony/dotenv": "7.3.*", "symfony/dotenv": "7.3.*",
@ -34,13 +34,12 @@
"symfony/runtime": "7.3.*", "symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*", "symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*", "symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.30",
"symfony/string": "7.3.*", "symfony/string": "7.3.*",
"symfony/translation": "7.3.*", "symfony/translation": "7.3.*",
"symfony/twig-bundle": "7.3.*", "symfony/twig-bundle": "7.3.*",
"symfony/ux-turbo": "^2.30",
"symfony/validator": "7.3.*", "symfony/validator": "7.3.*",
"symfony/web-link": "7.3.*", "symfony/web-link": "7.3.*",
"symfony/webpack-encore-bundle": "^2.3",
"symfony/yaml": "7.3.*", "symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0" "twig/twig": "^2.12|^3.0"
@ -79,8 +78,7 @@
"scripts": { "scripts": {
"auto-scripts": { "auto-scripts": {
"cache:clear": "symfony-cmd", "cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd"
"importmap:install": "symfony-cmd"
}, },
"post-install-cmd": [ "post-install-cmd": [
"@auto-scripts" "@auto-scripts"
@ -94,7 +92,7 @@
}, },
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": false, "allow-contrib": true,
"require": "7.3.*", "require": "7.3.*",
"docker": true "docker": true
}, },

4680
core/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,10 +7,9 @@ return [
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
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],
]; ];

View file

@ -1,11 +0,0 @@
framework:
asset_mapper:
# The paths to make available to the asset mapper.
paths:
- assets/
missing_import_mode: strict
when@prod:
framework:
asset_mapper:
missing_import_mode: warn

View file

@ -2,28 +2,32 @@ security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers: password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers: providers:
users_in_memory: { memory: null } app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls: firewalls:
dev: dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/ pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false security: false
main: main:
lazy: true lazy: true
provider: users_in_memory provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
default_target_path: app_home
logout:
path: app_logout
target: app_home
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control: access_control:
# - { path: ^/admin, roles: ROLE_ADMIN } # - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
when@test: when@test:
security: security:

View file

@ -1,4 +0,0 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
csrf_protection:
check_header: true

View file

@ -0,0 +1,45 @@
webpack_encore:
# The path where Encore is building the assets - i.e. Encore.setOutputPath()
output_path: '%kernel.project_dir%/../public_html/build'
# If multiple builds are defined (as shown below), you can disable the default build:
# output_path: false
# Set attributes that will be rendered on all script and link tags
script_attributes:
defer: true
# Uncomment (also under link_attributes) if using Turbo Drive
# https://turbo.hotwired.dev/handbook/drive#reloading-when-assets-change
# 'data-turbo-track': reload
# link_attributes:
# Uncomment if using Turbo Drive
# 'data-turbo-track': reload
# If using Encore.enableIntegrityHashes() and need the crossorigin attribute (default: false, or use 'anonymous' or 'use-credentials')
# crossorigin: 'anonymous'
# Preload all rendered script and link tags automatically via the HTTP/2 Link header
# preload: true
# Throw an exception if the entrypoints.json file is missing or an entry is missing from the data
# strict_mode: false
# If you have multiple builds:
# builds:
# frontend: '%kernel.project_dir%/public/frontend/build'
# pass the build name as the 3rd argument to the Twig functions
# {{ encore_entry_script_tags('entry1', null, 'frontend') }}
framework:
assets:
json_manifest_path: '%kernel.project_dir%/../public_html/build/manifest.json'
#when@prod:
# webpack_encore:
# # Cache the entrypoints.json (rebuild Symfony's cache when entrypoints.json changes)
# # Available in version 1.2
# cache: true
#when@test:
# webpack_encore:
# strict_mode: false

View file

@ -1,28 +0,0 @@
<?php
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@symfony/stimulus-bundle' => [
'path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js',
],
'@hotwired/turbo' => [
'version' => '7.3.0',
],
];

View file

@ -0,0 +1,44 @@
<?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 Version20241201000000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create user table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE `user` (
id INT AUTO_INCREMENT NOT NULL,
email VARCHAR(180) NOT NULL,
mobile VARCHAR(15) NOT NULL,
full_name VARCHAR(100) NOT NULL,
roles JSON NOT NULL,
password VARCHAR(255) NOT NULL,
is_verified TINYINT(1) NOT NULL,
created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\',
updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
reset_token VARCHAR(255) DEFAULT NULL,
reset_token_expires_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\',
UNIQUE INDEX UNIQ_8D93D649E7927C74 (email),
UNIQUE INDEX UNIQ_8D93D6493C7323CB (mobile),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE `user`');
}
}

20
core/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"devDependencies": {
"@babel/core": "^7.17.0",
"@babel/preset-env": "^7.16.0",
"@symfony/webpack-encore": "^5.0.0",
"bootstrap": "^5.3.0",
"core-js": "^3.38.0",
"regenerator-runtime": "^0.13.9",
"webpack": "^5.74.0",
"webpack-cli": "^5.1.0"
},
"license": "UNLICENSED",
"private": true,
"scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class HomeController extends AbstractController
{
#[Route('/', name: 'app_home')]
public function index(): Response
{
return $this->render('home/index.html.twig');
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\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('registration/register.html.twig', [
'registrationForm' => $form->createView(),
]);
}
$existingUser = $userRepository->findByMobile($user->getMobile());
if ($existingUser) {
$this->addFlash('error', 'این شماره موبایل قبلاً ثبت شده است');
return $this->render('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('registration/register.html.twig', [
'registrationForm' => $form->createView(),
]);
}
}

View file

@ -0,0 +1,128 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\ChangePasswordFormType;
use App\Form\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('reset_password/request.html.twig', [
'requestForm' => $form->createView(),
]);
}
#[Route('/check-email', name: 'app_check_email')]
public function checkEmail(): Response
{
// این صفحه فقط برای نمایش پیام استفاده می‌شود
return $this->render('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('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

@ -0,0 +1,36 @@
<?php
namespace App\Controller;
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('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,48 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
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
{
// Extract the main page from route
$page = $this->extractPageFromRoute($route);
return $this->render('ui/app.html.twig', [
'page' => $page,
'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';
}
}

208
core/src/Entity/User.php Normal file
View file

@ -0,0 +1,208 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
#[UniqueEntity(fields: ['email'], message: 'این ایمیل قبلاً ثبت شده است')]
#[UniqueEntity(fields: ['mobile'], message: 'این شماره موبایل قبلاً ثبت شده است')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Assert\NotBlank(message: 'ایمیل الزامی است')]
#[Assert\Email(message: 'فرمت ایمیل صحیح نیست')]
private ?string $email = null;
#[ORM\Column(length: 15, unique: true)]
#[Assert\NotBlank(message: 'شماره موبایل الزامی است')]
#[Assert\Regex(
pattern: '/^09[0-9]{9}$/',
message: 'فرمت شماره موبایل صحیح نیست (مثال: 09123456789)'
)]
private ?string $mobile = null;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'نام الزامی است')]
#[Assert\Length(
min: 2,
max: 100,
minMessage: 'نام باید حداقل {{ limit }} کاراکتر باشد',
maxMessage: 'نام نمی‌تواند بیشتر از {{ limit }} کاراکتر باشد'
)]
private ?string $fullName = null;
#[ORM\Column]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
#[ORM\Column]
private ?bool $isVerified = false;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\Column(nullable: true)]
private ?string $resetToken = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $resetTokenExpiresAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
$this->roles = ['ROLE_USER'];
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getMobile(): ?string
{
return $this->mobile;
}
public function setMobile(string $mobile): static
{
$this->mobile = $mobile;
return $this;
}
public function getFullName(): ?string
{
return $this->fullName;
}
public function setFullName(string $fullName): static
{
$this->fullName = $fullName;
return $this;
}
public function getUserIdentifier(): string
{
return (string) $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
}
public function isVerified(): bool
{
return $this->isVerified;
}
public function setIsVerified(bool $isVerified): static
{
$this->isVerified = $isVerified;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getResetToken(): ?string
{
return $this->resetToken;
}
public function setResetToken(?string $resetToken): static
{
$this->resetToken = $resetToken;
return $this;
}
public function getResetTokenExpiresAt(): ?\DateTimeImmutable
{
return $this->resetTokenExpiresAt;
}
public function setResetTokenExpiresAt(?\DateTimeImmutable $resetTokenExpiresAt): static
{
$this->resetTokenExpiresAt = $resetTokenExpiresAt;
return $this;
}
public function isResetTokenExpired(): bool
{
if (!$this->resetTokenExpiresAt) {
return true;
}
return $this->resetTokenExpiresAt < new \DateTimeImmutable();
}
}

View file

@ -0,0 +1,54 @@
<?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

@ -0,0 +1,106 @@
<?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

@ -0,0 +1,35 @@
<?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,82 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*
* @method User|null find($id, $lockMode = null, $lockVersion = null)
* @method User|null findOneBy(array $criteria, array $orderBy = null)
* @method User[] findAll()
* @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function save(User $entity, bool $flush = false): void
{
$this->getEntityManager()->persist($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
public function remove(User $entity, bool $flush = false): void
{
$this->getEntityManager()->remove($entity);
if ($flush) {
$this->getEntityManager()->flush();
}
}
/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
}
$user->setPassword($newHashedPassword);
$this->save($user, true);
}
public function findByEmail(string $email): ?User
{
return $this->findOneBy(['email' => $email]);
}
public function findByMobile(string $mobile): ?User
{
return $this->findOneBy(['mobile' => $mobile]);
}
public function findByResetToken(string $token): ?User
{
return $this->findOneBy(['resetToken' => $token]);
}
public function findExpiredResetTokens(): array
{
$qb = $this->createQueryBuilder('u');
$qb->where('u.resetToken IS NOT NULL')
->andWhere('u.resetTokenExpiresAt < :now')
->setParameter('now', new \DateTimeImmutable());
return $qb->getQuery()->getResult();
}
}

View file

@ -50,19 +50,16 @@
"bin/phpunit" "bin/phpunit"
] ]
}, },
"symfony/asset-mapper": { "symfony/apache-pack": {
"version": "7.3", "version": "1.0",
"recipe": { "recipe": {
"repo": "github.com/symfony/recipes", "repo": "github.com/symfony/recipes-contrib",
"branch": "main", "branch": "main",
"version": "6.4", "version": "1.0",
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a" "ref": "5d454ec6cc4c700ed3d963f3803e1d427d9669fb"
}, },
"files": [ "files": [
"assets/app.js", ".public_html/.htaccess"
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
] ]
}, },
"symfony/console": { "symfony/console": {
@ -229,21 +226,6 @@
"config/routes/security.yaml" "config/routes/security.yaml"
] ]
}, },
"symfony/stimulus-bundle": {
"version": "2.30",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "c30f782a8910ae9f12e7a1399db607d172d23da6"
},
"files": [
"assets/bootstrap.js",
"assets/controllers.json",
"assets/controllers/csrf_protection_controller.js",
"assets/controllers/hello_controller.js"
]
},
"symfony/translation": { "symfony/translation": {
"version": "7.3", "version": "7.3",
"recipe": { "recipe": {
@ -270,18 +252,6 @@
"templates/base.html.twig" "templates/base.html.twig"
] ]
}, },
"symfony/ux-turbo": {
"version": "2.30",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.20",
"ref": "287f7c6eb6e9b65e422d34c00795b360a787380b"
},
"files": [
"config/packages/ux_turbo.yaml"
]
},
"symfony/validator": { "symfony/validator": {
"version": "7.3", "version": "7.3",
"recipe": { "recipe": {
@ -319,6 +289,22 @@
"config/packages/messenger.yaml" "config/packages/messenger.yaml"
] ]
}, },
"symfony/webpack-encore-bundle": {
"version": "2.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.0",
"ref": "9ef5412a4a2a8415aca3a3f2b4edd3866aab9a19"
},
"files": [
"assets/app.js",
"assets/styles/app.css",
"config/packages/webpack_encore.yaml",
"package.json",
"webpack.config.js"
]
},
"twig/extra-bundle": { "twig/extra-bundle": {
"version": "v3.21.0" "version": "v3.21.0"
} }

View file

@ -0,0 +1,514 @@
<!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,14 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title> <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>"> <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>">
{% block stylesheets %} {% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {{ encore_entry_script_tags('app') }}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>

View file

@ -0,0 +1,23 @@
{% extends 'base.html.twig' %}
{% block title %}صفحه اصلی - سیستم حسابداری{% endblock %}
{% 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 %}

View file

@ -0,0 +1,109 @@
{% 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

@ -0,0 +1,61 @@
{% 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

@ -0,0 +1,100 @@
<!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>
<style>
body {
font-family: 'Tahoma', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
}
.container {
max-width: 600px;
margin: 0 auto;
background-color: #fff;
padding: 30px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
border-bottom: 2px solid #007bff;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #007bff;
margin: 0;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
background-color: #007bff;
color: #fff;
padding: 12px 30px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
margin: 20px 0;
}
.button:hover {
background-color: #0056b3;
}
.footer {
text-align: center;
color: #666;
font-size: 14px;
border-top: 1px solid #eee;
padding-top: 20px;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>بازنشانی رمز عبور</h1>
</div>
<div class="content">
<p>سلام {{ user.fullName }}،</p>
<p>شما درخواست بازنشانی رمز عبور کرده‌اید. برای ادامه، روی دکمه زیر کلیک کنید:</p>
<div style="text-align: center;">
<a href="{{ url('app_reset_password', {token: resetToken}) }}" class="button">
تغییر رمز عبور
</a>
</div>
<div class="warning">
<strong>توجه:</strong> این لینک فقط تا 1 ساعت معتبر است. اگر درخواست شما نبوده، این ایمیل را نادیده بگیرید.
</div>
<p>اگر دکمه بالا کار نمی‌کند، می‌توانید این لینک را در مرورگر خود کپی کنید:</p>
<p style="word-break: break-all; background-color: #f8f9fa; padding: 10px; border-radius: 5px;">
{{ url('app_reset_password', {token: resetToken}) }}
</p>
</div>
<div class="footer">
<p>این ایمیل به صورت خودکار ارسال شده است. لطفاً به آن پاسخ ندهید.</p>
<p>&copy; {{ "now"|date("Y") }} سیستم حسابداری حسابی‌کس</p>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,64 @@
{% 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

@ -0,0 +1,90 @@
{% 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

@ -0,0 +1,103 @@
{% 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

@ -0,0 +1,7 @@
{% extends 'base.html.twig' %}
{% block title %}سیستم حسابداری{% endblock %}
{% block body %}
<div id="app" data-page="{{ page }}" data-route="{{ route }}"></div>
{% endblock %}

76
core/webpack.config.js Normal file
View file

@ -0,0 +1,76 @@
const Encore = require('@symfony/webpack-encore');
// Manually configure the runtime environment if not already configured yet by the "encore" command.
// It's useful when you use tools that rely on webpack.config.js file.
if (!Encore.isRuntimeEnvironmentConfigured()) {
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
// directory where compiled assets will be stored
.setOutputPath('public/build/')
// public path used by the web server to access the output path
.setPublicPath('/build')
// only needed for CDN's or subdirectory deploy
//.setManifestKeyPrefix('build/')
/*
* ENTRY CONFIG
*
* Each entry will result in one JavaScript file (e.g. app.js)
* and one CSS file (e.g. app.css) if your JavaScript imports CSS.
*/
.addEntry('app', './assets/app.js')
// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
.splitEntryChunks()
// will require an extra script tag for runtime.js
// but, you probably want this, unless you're building a single-page app
.enableSingleRuntimeChunk()
/*
* FEATURE CONFIG
*
* Enable & configure other features below. For a full
* list of features, see:
* https://symfony.com/doc/current/frontend.html#adding-more-features
*/
.cleanupOutputBeforeBuild()
// Displays build status system notifications to the user
// .enableBuildNotifications()
.enableSourceMaps(!Encore.isProduction())
// enables hashed filenames (e.g. app.abc123.css)
.enableVersioning(Encore.isProduction())
// configure Babel
// .configureBabel((config) => {
// config.plugins.push('@babel/a-babel-plugin');
// })
// enables and configure @babel/preset-env polyfills
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = '3.38';
})
// enables Sass/SCSS support
//.enableSassLoader()
// uncomment if you use TypeScript
//.enableTypeScriptLoader()
// uncomment if you use React
//.enableReactPreset()
// uncomment to get integrity="..." attributes on your script & link tags
// requires WebpackEncoreBundle 1.4 or higher
//.enableIntegrityHashes(Encore.isProduction())
// uncomment if you're having problems with a jQuery plugin
//.autoProvidejQuery()
;
module.exports = Encore.getWebpackConfig();

7813
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
frontend/package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "Vue.js + Vuetify frontend for accounting system",
"main": "index.js",
"scripts": {
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"vue",
"vuetify",
"accounting",
"symfony"
],
"author": "",
"license": "ISC",
"dependencies": {
"@mdi/font": "^7.4.47",
"@symfony/webpack-encore": "^4.7.0",
"vue": "^3.4.15",
"vue-loader": "^17.4.2",
"vue-router": "^4.5.1",
"vuetify": "^3.5.8",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vue/compiler-sfc": "^3.4.15",
"sass": "^1.70.0",
"sass-loader": "^14.1.0",
"webpack-notifier": "^1.15.0"
}
}

View file

@ -0,0 +1,12 @@
<!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>

48
frontend/src/main.js Normal file
View file

@ -0,0 +1,48 @@
import { createApp } from 'vue'
import router from './router'
// Vuetify
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'
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'light'
}
})
const app = createApp({
template: `
<v-app>
<v-main>
<v-container fluid>
<router-view></router-view>
</v-container>
</v-main>
</v-app>
`
})
// Get initial page and route from data attributes
const appElement = document.getElementById('app')
const initialPage = appElement ? appElement.dataset.page : 'dashboard'
const initialRoute = appElement ? appElement.dataset.route : ''
// Set initial route based on page and route
if (initialPage && initialPage !== 'dashboard') {
// Use router.push for programmatic navigation
router.push(`/${initialPage}`)
} else if (initialRoute && initialRoute !== 'dashboard') {
// Handle nested routes
router.push(`/${initialRoute}`)
}
app.use(router)
app.use(vuetify)
app.mount('#app')

View file

@ -0,0 +1,76 @@
import { createRouter, createWebHistory } from 'vue-router'
// Placeholder component for now
const PlaceholderComponent = {
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 routes = [
{
path: '/',
name: 'Dashboard',
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
router.beforeEach((to, from, next) => {
// Update current page in meta
if (to.meta.page) {
console.log('Navigating to:', to.meta.page)
}
next()
})
export default router

View file

@ -0,0 +1,15 @@
const Encore = require('@symfony/webpack-encore');
Encore
.setOutputPath('../public_html/build/')
.setPublicPath('/build/')
.addEntry('app', './src/main.js')
.enableVueLoader()
.enableSassLoader()
.enableVersioning()
.disableSingleRuntimeChunk()
.splitEntryChunks()
.enableSourceMaps(!Encore.isProduction())
.enableBuildNotifications();
module.exports = Encore.getWebpackConfig();

37
public_html/.htaccess Normal file
View file

@ -0,0 +1,37 @@
# Use the front controller as index file
DirectoryIndex index.php
# Disabling MultiViews prevents unwanted negotiation
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=308,L]
# Handle UI routes - let Symfony handle /ui/* routes
RewriteCond %{REQUEST_URI} ^/ui
RewriteRule ^ %{ENV:BASE}/index.php [L]
# If the requested filename exists, simply serve it
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
RedirectMatch 307 ^/$ /index.php/
</IfModule>
</IfModule>

View file

@ -2,7 +2,7 @@
use App\Kernel; use App\Kernel;
require_once dirname(__DIR__,2).'/vendor/autoload_runtime.php'; require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) { return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']); return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);