From edb4df7e3789ff53b7fc777b093125058cddb02d Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Jan 2026 20:03:16 +0300 Subject: [PATCH] add superadmin dashboard. subscriptions --- app/Config/Filters.php | 3 +- app/Config/Routes.php | 119 +++- app/Controllers/ForgotPassword.php | 243 ++++++++ app/Controllers/Profile.php | 133 +++++ app/Controllers/Superadmin.php | 558 ++++++++++++++++++ ...026-01-13-200001_CreateCiSessionsTable.php | 45 ++ ...3-200002_AddPasswordResetFieldsToUsers.php | 32 + ...2026-01-14-000001_AddSystemRoleToUsers.php | 27 + .../2026-01-15-000001_AddPlansTable.php | 155 +++++ ...eateOrganizationPlanSubscriptionsTable.php | 70 +++ app/Database/Seeds/SetSystemRoleSeeder.php | 123 ++++ app/Filters/AuthFilter.php | 98 +++ app/Filters/OrganizationFilter.php | 87 +-- app/Filters/RoleFilter.php | 31 +- app/Libraries/EmailLibrary.php | 32 + app/Libraries/Twig/TwigGlobalsExtension.php | 25 + app/Models/OrganizationModel.php | 11 +- app/Models/PlanModel.php | 79 +++ app/Models/UserModel.php | 79 ++- app/Modules/Clients/Config/Routes.php | 1 - app/Services/AccessService.php | 110 +++- app/Views/auth/forgot_password.twig | 71 +++ app/Views/auth/login.twig | 14 + app/Views/auth/reset_password.twig | 81 +++ app/Views/emails/password_reset.twig | 115 ++++ app/Views/layouts/base.twig | 8 + app/Views/profile/security.twig | 109 ++++ app/Views/superadmin/dashboard.twig | 116 ++++ app/Views/superadmin/layout.twig | 91 +++ app/Views/superadmin/organizations/index.twig | 49 ++ app/Views/superadmin/organizations/view.twig | 220 +++++++ app/Views/superadmin/plans/create.twig | 95 +++ app/Views/superadmin/plans/edit.twig | 96 +++ app/Views/superadmin/plans/index.twig | 77 +++ app/Views/superadmin/statistics.twig | 179 ++++++ app/Views/superadmin/users/index.twig | 47 ++ 36 files changed, 3329 insertions(+), 100 deletions(-) create mode 100644 app/Controllers/ForgotPassword.php create mode 100644 app/Controllers/Superadmin.php create mode 100644 app/Database/Migrations/2026-01-13-200001_CreateCiSessionsTable.php create mode 100644 app/Database/Migrations/2026-01-13-200002_AddPasswordResetFieldsToUsers.php create mode 100644 app/Database/Migrations/2026-01-14-000001_AddSystemRoleToUsers.php create mode 100644 app/Database/Migrations/2026-01-15-000001_AddPlansTable.php create mode 100644 app/Database/Migrations/2026-01-15-000002_CreateOrganizationPlanSubscriptionsTable.php create mode 100644 app/Database/Seeds/SetSystemRoleSeeder.php create mode 100644 app/Filters/AuthFilter.php create mode 100644 app/Models/PlanModel.php create mode 100644 app/Views/auth/forgot_password.twig create mode 100644 app/Views/auth/reset_password.twig create mode 100644 app/Views/emails/password_reset.twig create mode 100644 app/Views/superadmin/dashboard.twig create mode 100644 app/Views/superadmin/layout.twig create mode 100644 app/Views/superadmin/organizations/index.twig create mode 100644 app/Views/superadmin/organizations/view.twig create mode 100644 app/Views/superadmin/plans/create.twig create mode 100644 app/Views/superadmin/plans/edit.twig create mode 100644 app/Views/superadmin/plans/index.twig create mode 100644 app/Views/superadmin/statistics.twig create mode 100644 app/Views/superadmin/users/index.twig diff --git a/app/Config/Filters.php b/app/Config/Filters.php index f3e02da..2cbd4ae 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -34,8 +34,9 @@ class Filters extends BaseFilters 'forcehttps' => ForceHTTPS::class, 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, - 'org' => \App\Filters\OrganizationFilter::class, + 'org' => \App\Filters\OrganizationFilter::class, 'role' => \App\Filters\RoleFilter::class, + 'auth' => \App\Filters\AuthFilter::class, ]; /** diff --git a/app/Config/Routes.php b/app/Config/Routes.php index a8a01d7..f456e76 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -5,7 +5,9 @@ use CodeIgniter\Router\RouteCollection; /** * @var RouteCollection $routes */ -# Публичные маршруты (без фильтра org) +# ============================================================================= +# ПУБЛИЧНЫЕ МАРШРУТЫ (без фильтров) +# ============================================================================= $routes->get('/', 'Home::index'); $routes->get('login', 'Auth::login'); $routes->post('login', 'Auth::login'); @@ -17,7 +19,13 @@ $routes->get('auth/verify/(:any)', 'Auth::verify/$1'); $routes->get('auth/resend-verification', 'Auth::resendVerification'); $routes->post('auth/resend-verification', 'Auth::resendVerification'); -# Маршруты для приглашений (публичные, без фильтра org) +# Маршруты для восстановления пароля +$routes->get('forgot-password', 'ForgotPassword::index'); +$routes->post('forgot-password/send', 'ForgotPassword::sendResetLink'); +$routes->get('forgot-password/reset/(:any)', 'ForgotPassword::reset/$1'); +$routes->post('forgot-password/update', 'ForgotPassword::updatePassword'); + +# Маршруты для приглашений (публичные) $routes->group('invitation', static function ($routes) { $routes->get('accept/(:any)', 'InvitationController::accept/$1'); $routes->post('accept/(:any)', 'InvitationController::processAccept'); @@ -25,40 +33,87 @@ $routes->group('invitation', static function ($routes) { $routes->match(['GET', 'POST'], 'complete/(:any)', 'InvitationController::complete/$1'); }); -# Защищённые маршруты (с фильтром org) -$routes->group('', ['filter' => 'org'], static function ($routes) { +# ============================================================================= +# АВТОРИЗОВАННЫЕ МАРШРУТЫ (требуется auth, НЕ требуется org) +# ============================================================================= +$routes->group('', ['filter' => 'auth'], static function ($routes) { + # Профиль (доступен без выбора организации) + $routes->get('profile', 'Profile::index'); + $routes->get('profile/organizations', 'Profile::organizations'); + $routes->get('profile/security', 'Profile::security'); + $routes->post('profile/update-name', 'Profile::updateName'); + $routes->post('profile/upload-avatar', 'Profile::uploadAvatar'); + $routes->post('profile/change-password', 'Profile::changePassword'); + $routes->post('profile/session/revoke', 'Profile::revokeSession'); + $routes->post('profile/sessions/revoke-all', 'Profile::revokeAllSessions'); + $routes->post('profile/leave-org/(:num)', 'Profile::leaveOrganization/$1'); + + # Выбор организации (доступен без выбора организации) $routes->get('organizations', 'Organizations::index'); - $routes->get('organizations/(:num)/dashboard', 'Organizations::dashboard/$1'); $routes->get('organizations/create', 'Organizations::create'); $routes->post('organizations/create', 'Organizations::create'); - $routes->get('organizations/edit/(:num)', 'Organizations::edit/$1'); - $routes->post('organizations/edit/(:num)', 'Organizations::edit/$1'); - $routes->get('organizations/delete/(:num)', 'Organizations::delete/$1'); - $routes->post('organizations/delete/(:num)', 'Organizations::delete/$1'); $routes->get('organizations/switch/(:num)', 'Organizations::switch/$1'); - - # Управление пользователями организации - $routes->get('organizations/(:num)/users', 'Organizations::users/$1'); - $routes->get('organizations/(:num)/users/table', 'Organizations::usersTable/$1'); - $routes->post('organizations/(:num)/users/invite', 'Organizations::inviteUser/$1'); - $routes->post('organizations/(:num)/users/role', 'Organizations::updateUserRole/$1'); - $routes->post('organizations/(:num)/users/(:num)/block', 'Organizations::blockUser/$1/$2'); - $routes->post('organizations/(:num)/users/(:num)/unblock', 'Organizations::unblockUser/$1/$2'); - $routes->post('organizations/(:num)/users/(:num)/remove', 'Organizations::removeUser/$1/$2'); - $routes->post('organizations/(:num)/leave', 'Organizations::leaveOrganization/$1'); - $routes->post('organizations/(:num)/users/leave', 'Organizations::leaveOrganization/$1'); - $routes->post('organizations/(:num)/users/(:num)/resend', 'Organizations::resendInvite/$1/$2'); - $routes->post('organizations/(:num)/users/(:num)/cancel', 'Organizations::cancelInvite/$1/$2'); }); -# Маршруты профиля -$routes->get('profile', 'Profile::index'); -$routes->get('profile/organizations', 'Profile::organizations'); -$routes->get('profile/security', 'Profile::security'); -$routes->post('profile/update-name', 'Profile::updateName'); -$routes->post('profile/upload-avatar', 'Profile::uploadAvatar'); -$routes->post('profile/change-password', 'Profile::changePassword'); -$routes->post('profile/leave-org/(:num)', 'Profile::leaveOrganization/$1'); +# ============================================================================= +# ЗАЩИЩЁННЫЕ МАРШУТЫ (требуется auth И org) +# ============================================================================= +$routes->group('', ['filter' => 'auth'], static function ($routes) { + $routes->group('', ['filter' => 'org'], static function ($routes) { + $routes->get('organizations/(:num)/dashboard', 'Organizations::dashboard/$1'); + $routes->get('organizations/edit/(:num)', 'Organizations::edit/$1'); + $routes->post('organizations/edit/(:num)', 'Organizations::edit/$1'); + $routes->get('organizations/delete/(:num)', 'Organizations::delete/$1'); + $routes->post('organizations/delete/(:num)', 'Organizations::delete/$1'); -# Подключение роутов модулей -require_once APPPATH . 'Modules/Clients/Config/Routes.php'; \ No newline at end of file + # Управление пользователями организации + $routes->get('organizations/(:num)/users', 'Organizations::users/$1'); + $routes->get('organizations/(:num)/users/table', 'Organizations::usersTable/$1'); + $routes->post('organizations/(:num)/users/invite', 'Organizations::inviteUser/$1'); + $routes->post('organizations/(:num)/users/role', 'Organizations::updateUserRole/$1'); + $routes->post('organizations/(:num)/users/(:num)/block', 'Organizations::blockUser/$1/$2'); + $routes->post('organizations/(:num)/users/(:num)/unblock', 'Organizations::unblockUser/$1/$2'); + $routes->post('organizations/(:num)/users/(:num)/remove', 'Organizations::removeUser/$1/$2'); + $routes->post('organizations/(:num)/leave', 'Organizations::leaveOrganization/$1'); + $routes->post('organizations/(:num)/users/leave', 'Organizations::leaveOrganization/$1'); + $routes->post('organizations/(:num)/users/(:num)/resend', 'Organizations::resendInvite/$1/$2'); + $routes->post('organizations/(:num)/users/(:num)/cancel', 'Organizations::cancelInvite/$1/$2'); + }); +}); + +# ============================================================================= +# ПОДКЛЮЧЕНИЕ РОУТОВ МОДУЛЕЙ (требуется auth) +# ============================================================================= +$routes->group('', ['filter' => 'auth'], static function ($routes) { + require_once APPPATH . 'Modules/Clients/Config/Routes.php'; +}); + +# ============================================================================= +# СУПЕРАДМИН ПАНЕЛЬ (требуется system_role: superadmin) +# ============================================================================= +$routes->group('superadmin', ['filter' => 'role:system:superadmin'], static function ($routes) { + $routes->get('/', 'Superadmin::index'); + $routes->get('plans', 'Superadmin::plans'); + $routes->get('plans/create', 'Superadmin::createPlan'); + $routes->post('plans/store', 'Superadmin::storePlan'); + $routes->get('plans/edit/(:num)', 'Superadmin::editPlan/$1'); + $routes->post('plans/update/(:num)', 'Superadmin::updatePlan/$1'); + $routes->get('plans/delete/(:num)', 'Superadmin::deletePlan/$1'); + + $routes->get('organizations', 'Superadmin::organizations'); + $routes->get('organizations/table', 'Superadmin::organizationsTable'); + $routes->get('organizations/view/(:num)', 'Superadmin::viewOrganization/$1'); + $routes->post('organizations/set-plan/(:num)', 'Superadmin::setOrganizationPlan/$1'); + $routes->get('organizations/block/(:num)', 'Superadmin::blockOrganization/$1'); + $routes->get('organizations/unblock/(:num)', 'Superadmin::unblockOrganization/$1'); + $routes->get('organizations/delete/(:num)', 'Superadmin::deleteOrganization/$1'); + + $routes->get('users', 'Superadmin::users'); + $routes->get('users/table', 'Superadmin::usersTable'); + $routes->post('users/update-role/(:num)', 'Superadmin::updateUserRole/$1'); + $routes->get('users/block/(:num)', 'Superadmin::blockUser/$1'); + $routes->get('users/unblock/(:num)', 'Superadmin::unblockUser/$1'); + $routes->get('users/delete/(:num)', 'Superadmin::deleteUser/$1'); + + $routes->get('statistics', 'Superadmin::statistics'); +}); diff --git a/app/Controllers/ForgotPassword.php b/app/Controllers/ForgotPassword.php new file mode 100644 index 0000000..4947c83 --- /dev/null +++ b/app/Controllers/ForgotPassword.php @@ -0,0 +1,243 @@ +userModel = new UserModel(); + $this->emailLibrary = new EmailLibrary(); + + try { + $this->rateLimitService = RateLimitService::getInstance(); + } catch (\Exception $e) { + log_message('warning', 'RateLimitService недоступен: ' . $e->getMessage()); + $this->rateLimitService = null; + } + } + + /** + * Проверка rate limiting + */ + protected function checkRateLimit(string $action): ?array + { + if ($this->rateLimitService === null) { + return null; + } + + if ($this->rateLimitService->isBlocked($action)) { + $ttl = $this->rateLimitService->getBlockTimeLeft($action); + return [ + 'blocked' => true, + 'message' => "Слишком много попыток. Повторите через {$ttl} секунд.", + 'ttl' => $ttl, + ]; + } + + return null; + } + + /** + * Сброс счётчика после успешного действия + */ + protected function resetRateLimit(string $action): void + { + if ($this->rateLimitService !== null) { + $this->rateLimitService->resetAttempts($action); + } + } + + /** + * Запись неудачной попытки + */ + protected function recordFailedAttempt(string $action): ?array + { + if ($this->rateLimitService === null) { + return null; + } + + $result = $this->rateLimitService->recordFailedAttempt($action); + + if ($result['blocked']) { + return [ + 'blocked' => true, + 'message' => "Превышено максимальное количество попыток. Доступ заблокирован на {$result['block_ttl']} секунд.", + 'ttl' => $result['block_ttl'], + ]; + } + + return null; + } + + /** + * Отображение формы запроса сброса пароля + */ + public function index() + { + // Если пользователь уже авторизован - редирект на главную + if (session()->get('isLoggedIn')) { + return redirect()->to('/'); + } + + return $this->renderTwig('auth/forgot_password'); + } + + /** + * Отправка email для сброса пароля + */ + public function sendResetLink() + { + if ($this->request->getMethod() !== 'POST') { + return redirect()->to('/forgot-password'); + } + + // Проверка rate limiting + $rateLimitError = $this->checkRateLimit('reset'); + if ($rateLimitError !== null) { + return redirect()->back() + ->with('error', $rateLimitError['message']) + ->withInput(); + } + + $email = trim($this->request->getPost('email')); + + if (empty($email)) { + return redirect()->back()->with('error', 'Введите email адрес')->withInput(); + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return redirect()->back()->with('error', 'Введите корректный email адрес')->withInput(); + } + + $user = $this->userModel->findByEmail($email); + + // Независимо от результата показываем одно и то же сообщение + // Это предотвращает перебор email адресов + if (!$user) { + // Засчитываем неудачную попытку для защиты от перебора + $this->recordFailedAttempt('reset'); + } + + if ($user) { + // Генерируем токен сброса + $token = $this->userModel->generateResetToken($user['id']); + + // Отправляем email + $this->emailLibrary->sendPasswordResetEmail( + $user['email'], + $user['name'], + $token + ); + + log_message('info', "Password reset link sent to {$email}"); + } + + // Всегда показываем одно и то же сообщение + $this->resetRateLimit('reset'); + + return redirect()->back()->with( + 'success', + 'Если email зарегистрирован в системе, на него будет отправлена ссылка для сброса пароля.' + ); + } + + /** + * Отображение формы сброса пароля (по токену из URL) + */ + public function reset($token = null) + { + // Если пользователь уже авторизован - редирект + if (session()->get('isLoggedIn')) { + return redirect()->to('/'); + } + + if (empty($token)) { + return redirect()->to('/forgot-password')->with('error', 'Недействительная ссылка для сброса пароля.'); + } + + // Проверяем токен + $user = $this->userModel->verifyResetToken($token); + + if (!$user) { + return redirect()->to('/forgot-password')->with('error', 'Ссылка для сброса пароля истекла или недействительна.'); + } + + return $this->renderTwig('auth/reset_password', [ + 'token' => $token, + 'email' => $user['email'], + ]); + } + + /** + * Обновление пароля + */ + public function updatePassword() + { + if ($this->request->getMethod() !== 'POST') { + return redirect()->to('/forgot-password'); + } + + $token = $this->request->getPost('token'); + $password = $this->request->getPost('password'); + $passwordConfirm = $this->request->getPost('password_confirm'); + + // Валидация + if (empty($token)) { + return redirect()->back()->with('error', 'Ошибка валидации токена.'); + } + + if (empty($password)) { + return redirect()->back()->with('error', 'Введите новый пароль')->withInput(); + } + + if (strlen($password) < 6) { + return redirect()->back()->with('error', 'Пароль должен содержать минимум 6 символов')->withInput(); + } + + if ($password !== $passwordConfirm) { + return redirect()->back()->with('error', 'Пароли не совпадают')->withInput(); + } + + // Проверяем токен + $user = $this->userModel->verifyResetToken($token); + + if (!$user) { + return redirect()->to('/forgot-password')->with('error', 'Ссылка для сброса пароля истекла или недействительна.'); + } + + // Обновляем пароль + $this->userModel->update($user['id'], ['password' => $password]); + + // Очищаем токен + $this->userModel->clearResetToken($user['id']); + + // Удаляем все remember-токены пользователя (нужна будет новая авторизация) + $db = \Config\Database::connect(); + $db->table('remember_tokens')->where('user_id', $user['id'])->delete(); + + log_message('info', "Password reset completed for user {$user['email']}"); + + return redirect()->to('/login')->with( + 'success', + 'Пароль успешно изменён. Теперь вы можете войти с новым паролем.' + ); + } +} diff --git a/app/Controllers/Profile.php b/app/Controllers/Profile.php index 8c52010..73a747e 100644 --- a/app/Controllers/Profile.php +++ b/app/Controllers/Profile.php @@ -96,13 +96,146 @@ class Profile extends BaseController $userId = $this->getCurrentUserId(); $user = $this->userModel->find($userId); + // Получаем список активных сессий + $sessions = $this->getUserSessions($userId); + return $this->renderTwig('profile/security', [ 'title' => 'Безопасность', 'user' => $user, 'active_tab' => 'security', + 'sessions' => $sessions, + 'currentSessionId' => session_id(), ]); } + /** + * Получение списка активных сессий пользователя + */ + protected function getUserSessions(int $userId): array + { + $db = \Config\Database::connect(); + + // Получаем сессии из таблицы ci_sessions (требует настройки DatabaseHandler) + // Также получаем remember-токены + $rememberTokens = $db->table('remember_tokens') + ->where('user_id', $userId) + ->where('expires_at >', date('Y-m-d H:i:s')) + ->get() + ->getResultArray(); + + $sessions = []; + + // Добавляем remember-токены как сессии + foreach ($rememberTokens as $token) { + $sessions[] = [ + 'id' => 'remember_' . $token['id'], + 'type' => 'remember', + 'device' => $this->parseUserAgent($token['user_agent'] ?? ''), + 'ip_address' => $token['ip_address'] ?? 'Unknown', + 'created_at' => $token['created_at'], + 'expires_at' => $token['expires_at'], + 'is_current' => false, + ]; + } + + return $sessions; + } + + /** + * Парсинг User Agent для получения информации об устройстве + */ + protected function parseUserAgent(string $userAgent): string + { + if (empty($userAgent)) { + return 'Неизвестное устройство'; + } + + // Определяем браузер + $browser = 'Unknown'; + if (preg_match('/Firefox\/([0-9.]+)/', $userAgent, $matches)) { + $browser = 'Firefox'; + } elseif (preg_match('/Chrome\/([0-9.]+)/', $userAgent, $matches)) { + $browser = 'Chrome'; + } elseif (preg_match('/Safari\/([0-9.]+)/', $userAgent, $matches)) { + $browser = 'Safari'; + } elseif (preg_match('/MSIE\s+([0-9.]+)/', $userAgent, $matches) || preg_match('/Trident\/([0-9.]+)/', $userAgent, $matches)) { + $browser = 'Internet Explorer'; + } elseif (preg_match('/Edg\/([0-9.]+)/', $userAgent, $matches)) { + $browser = 'Edge'; + } + + // Определяем ОС + $os = 'Unknown OS'; + if (preg_match('/Windows/', $userAgent)) { + $os = 'Windows'; + } elseif (preg_match('/Mac OS X/', $userAgent)) { + $os = 'macOS'; + } elseif (preg_match('/Linux/', $userAgent)) { + $os = 'Linux'; + } elseif (preg_match('/Android/', $userAgent)) { + $os = 'Android'; + } elseif (preg_match('/iPhone|iPad|iPod/', $userAgent)) { + $os = 'iOS'; + } + + return "{$browser} на {$os}"; + } + + /** + * Завершение конкретной сессии + */ + public function revokeSession() + { + $userId = $this->getCurrentUserId(); + $sessionId = $this->request->getPost('session_id'); + + if (empty($sessionId)) { + return redirect()->to('/profile/security')->with('error', 'Сессия не найдена'); + } + + $db = \Config\Database::connect(); + + // Проверяем, что сессия принадлежит пользователю + if (strpos($sessionId, 'remember_') === 0) { + // Это remember-токен + $tokenId = (int) str_replace('remember_', '', $sessionId); + $token = $db->table('remember_tokens') + ->where('id', $tokenId) + ->where('user_id', $userId) + ->get() + ->getRowArray(); + + if ($token) { + $db->table('remember_tokens')->where('id', $tokenId)->delete(); + log_message('info', "User {$userId} revoked remember token {$tokenId}"); + } + } + + return redirect()->to('/profile/security')->with('success', 'Сессия завершена'); + } + + /** + * Завершение всех сессий (кроме текущей) + */ + public function revokeAllSessions() + { + $userId = $this->getCurrentUserId(); + + // Удаляем все remember-токены + $db = \Config\Database::connect(); + $db->table('remember_tokens')->where('user_id', $userId)->delete(); + + // Регенерируем текущую сессию + $this->session->regenerate(true); + + log_message('info', "User {$userId} revoked all sessions"); + + return redirect()->to('/profile/security')->with( + 'success', + 'Все сессии на других устройствах завершены. Вы остались авторизованы на текущем устройстве.' + ); + } + /** * Обновление имени пользователя */ diff --git a/app/Controllers/Superadmin.php b/app/Controllers/Superadmin.php new file mode 100644 index 0000000..395caf9 --- /dev/null +++ b/app/Controllers/Superadmin.php @@ -0,0 +1,558 @@ +organizationModel = new \App\Models\OrganizationModel(); + $this->userModel = new \App\Models\UserModel(); + $this->planModel = new \App\Models\PlanModel(); + } + + /** + * Дашборд суперадмина + */ + public function index() + { + // Статистика + $stats = [ + 'total_users' => $this->userModel->countAll(), + 'total_orgs' => $this->organizationModel->countAll(), + 'active_today' => $this->userModel->where('created_at >=', date('Y-m-d'))->countAllResults(), + 'total_plans' => $this->planModel->countAll(), + ]; + + // Последние организации + $recentOrgs = $this->organizationModel + ->orderBy('created_at', 'DESC') + ->findAll(5); + + // Последние пользователи + $recentUsers = $this->userModel + ->orderBy('created_at', 'DESC') + ->findAll(5); + + return $this->renderTwig('superadmin/dashboard', compact('stats', 'recentOrgs', 'recentUsers')); + } + + // ========================================================================= + // УПРАВЛЕНИЕ ТАРИФАМИ + // ========================================================================= + + /** + * Список тарифов + */ + public function plans() + { + $plans = $this->planModel->findAll(); + // Декодируем features для Twig + foreach ($plans as &$plan) { + $plan['features'] = json_decode($plan['features'] ?? '[]', true); + } + + return $this->renderTwig('superadmin/plans/index', compact('plans')); + } + + /** + * Создание тарифа (форма) + */ + public function createPlan() + { + return $this->renderTwig('superadmin/plans/create'); + } + + /** + * Сохранение тарифа + */ + public function storePlan() + { + // Получаем features из текстового поля (каждая строка - отдельная возможность) + $featuresText = $this->request->getPost('features_list'); + $features = []; + if ($featuresText) { + $lines = explode("\n", trim($featuresText)); + foreach ($lines as $line) { + $line = trim($line); + if (!empty($line)) { + $features[] = $line; + } + } + } + + $data = [ + 'name' => $this->request->getPost('name'), + 'description' => $this->request->getPost('description'), + 'price' => (float) $this->request->getPost('price'), + 'currency' => $this->request->getPost('currency') ?? 'RUB', + 'billing_period' => $this->request->getPost('billing_period') ?? 'monthly', + 'max_users' => (int) $this->request->getPost('max_users'), + 'max_clients' => (int) $this->request->getPost('max_clients'), + 'max_storage' => (int) $this->request->getPost('max_storage'), + 'features' => json_encode($features), + 'is_active' => $this->request->getPost('is_active') ?? 1, + 'is_default' => $this->request->getPost('is_default') ?? 0, + ]; + + if (!$this->planModel->insert($data)) { + return redirect()->back()->withInput()->with('error', 'Ошибка создания тарифа: ' . implode(', ', $this->planModel->errors())); + } + + return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно создан'); + } + + /** + * Редактирование тарифа (форма) + */ + public function editPlan($id) + { + $plan = $this->planModel->find($id); + if (!$plan) { + throw new \CodeIgniter\Exceptions\PageNotFoundException('Тариф не найден'); + } + + // Декодируем features для отображения в textarea + $plan['features'] = json_decode($plan['features'] ?? '[]', true); + + return $this->renderTwig('superadmin/plans/edit', compact('plan')); + } + + /** + * Обновление тарифа + */ + public function updatePlan($id) + { + // Получаем features из текстового поля (каждая строка - отдельная возможность) + $featuresText = $this->request->getPost('features_list'); + $features = []; + if ($featuresText) { + $lines = explode("\n", trim($featuresText)); + foreach ($lines as $line) { + $line = trim($line); + if (!empty($line)) { + $features[] = $line; + } + } + } + + $data = [ + 'name' => $this->request->getPost('name'), + 'description' => $this->request->getPost('description'), + 'price' => (float) $this->request->getPost('price'), + 'currency' => $this->request->getPost('currency') ?? 'RUB', + 'billing_period' => $this->request->getPost('billing_period') ?? 'monthly', + 'max_users' => (int) $this->request->getPost('max_users'), + 'max_clients' => (int) $this->request->getPost('max_clients'), + 'max_storage' => (int) $this->request->getPost('max_storage'), + 'features' => json_encode($features), + 'is_active' => $this->request->getPost('is_active') ?? 1, + 'is_default' => $this->request->getPost('is_default') ?? 0, + ]; + + if (!$this->planModel->update($id, $data)) { + return redirect()->back()->withInput()->with('error', 'Ошибка обновления тарифа: ' . implode(', ', $this->planModel->errors())); + } + + return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно обновлён'); + } + + /** + * Удаление тарифа + */ + public function deletePlan($id) + { + if (!$this->planModel->delete($id)) { + return redirect()->to('/superadmin/plans')->with('error', 'Ошибка удаления тарифа'); + } + + return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно удалён'); + } + + // ========================================================================= + // УПРАВЛЕНИЕ ОРГАНИЗАЦИЯМИ + // ========================================================================= + + /** + * Конфигурация таблицы организаций + */ + protected function getOrganizationsTableConfig(): array + { + return [ + 'id' => 'organizations-table', + 'url' => '/superadmin/organizations/table', + 'model' => $this->organizationModel, + 'columns' => [ + 'id' => ['label' => 'ID', 'width' => '60px'], + 'name' => ['label' => 'Название'], + 'type' => ['label' => 'Тип', 'width' => '100px'], + 'user_count' => ['label' => 'Пользователей', 'width' => '100px'], + 'status' => ['label' => 'Статус', 'width' => '120px'], + 'created_at' => ['label' => 'Дата', 'width' => '100px'], + ], + 'searchable' => ['name', 'id'], + 'sortable' => ['id', 'name', 'created_at'], + 'defaultSort' => 'created_at', + 'order' => 'desc', + 'scope' => function ($builder) { + // JOIN с подсчётом пользователей организации + $builder->from('organizations') + ->select('organizations.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.organization_id = organizations.id AND status = "active") as user_count'); + }, + 'actions' => ['label' => 'Действия', 'width' => '140px'], + 'actionsConfig' => [ + [ + 'label' => '', + 'url' => '/superadmin/organizations/view/{id}', + 'icon' => 'fa-solid fa-eye', + 'class' => 'btn-outline-primary', + 'title' => 'Просмотр', + ], + [ + 'label' => '', + 'url' => '/superadmin/organizations/block/{id}', + 'icon' => 'fa-solid fa-ban', + 'class' => 'btn-outline-warning', + 'title' => 'Заблокировать', + 'confirm' => 'Заблокировать организацию?', + ], + [ + 'label' => '', + 'url' => '/superadmin/organizations/delete/{id}', + 'icon' => 'fa-solid fa-trash', + 'class' => 'btn-outline-danger', + 'title' => 'Удалить', + 'confirm' => 'Удалить организацию? Это действие нельзя отменить!', + ], + ], + 'emptyMessage' => 'Организации не найдены', + 'emptyIcon' => 'bi bi-building', + ]; + } + + /** + * Список организаций + */ + public function organizations() + { + $config = $this->getOrganizationsTableConfig(); + $tableHtml = $this->renderTable($config); + + return $this->renderTwig('superadmin/organizations/index', [ + 'tableHtml' => $tableHtml, + 'config' => $config, + ]); + } + + /** + * AJAX таблица организаций + */ + public function organizationsTable() + { + $config = $this->getOrganizationsTableConfig(); + return $this->table($config); + } + + /** + * Просмотр организации + */ + public function viewOrganization($id) + { + $organization = $this->organizationModel->find($id); + if (!$organization) { + throw new \CodeIgniter\Exceptions\PageNotFoundException('Организация не найдена'); + } + + // Пользователи организации + $orgUserModel = new \App\Models\OrganizationUserModel(); + $users = $orgUserModel->getOrganizationUsers($id); + + // Список тарифов для выбора + $plans = $this->planModel->where('is_active', 1)->findAll(); + + // Текущая подписка организации из таблицы связей + $db = \Config\Database::connect(); + $subscriptionTable = $db->table('organization_plan_subscriptions'); + $currentSubscription = $subscriptionTable + ->where('organization_id', $id) + ->orderBy('id', 'DESC') + ->get() + ->getRowArray(); + + return $this->renderTwig('superadmin/organizations/view', compact('organization', 'users', 'plans', 'currentSubscription')); + } + + /** + * Блокировка организации + */ + public function blockOrganization($id) + { + $this->organizationModel->update($id, ['status' => 'blocked']); + + return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Организация заблокирована'); + } + + /** + * Разблокировка организации + */ + public function unblockOrganization($id) + { + $this->organizationModel->update($id, ['status' => 'active']); + + return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Организация разблокирована'); + } + + /** + * Удаление организации (полное удаление) + */ + public function deleteOrganization($id) + { + // Полное удаление без soft delete + $this->organizationModel->delete($id, true); + + return redirect()->to('/superadmin/organizations')->with('success', 'Организация удалена'); + } + + /** + * Назначение тарифа организации + */ + public function setOrganizationPlan($id) + { + $planId = $this->request->getPost('plan_id'); + $durationDays = (int) $this->request->getPost('duration_days'); + + if (!$planId) { + return redirect()->back()->with('error', 'Выберите тариф'); + } + + $plan = $this->planModel->find($planId); + if (!$plan) { + return redirect()->back()->with('error', 'Тариф не найден'); + } + + $db = \Config\Database::connect(); + $subscriptionsTable = $db->table('organization_plan_subscriptions'); + + $startDate = date('Y-m-d H:i:s'); + $endDate = $durationDays > 0 + ? date('Y-m-d H:i:s', strtotime("+{$durationDays} days")) + : null; + + // Проверяем существующую подписку + $existingSub = $subscriptionsTable + ->where('organization_id', $id) + ->where('plan_id', $planId) + ->get() + ->getRowArray(); + + if ($existingSub) { + // Обновляем существующую подписку + $subscriptionsTable->where('id', $existingSub['id'])->update([ + 'status' => $durationDays > 0 ? 'active' : 'trial', + 'trial_ends_at' => $endDate, + 'expires_at' => $endDate, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } else { + // Создаём новую подписку + $subscriptionsTable->insert([ + 'organization_id' => $id, + 'plan_id' => $planId, + 'status' => $durationDays > 0 ? 'active' : 'trial', + 'trial_ends_at' => $endDate, + 'expires_at' => $endDate, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Тариф успешно назначен'); + } + + // ========================================================================= + // УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ + // ========================================================================= + + /** + * Конфигурация таблицы пользователей + */ + protected function getUsersTableConfig(): array + { + return [ + 'id' => 'users-table', + 'url' => '/superadmin/users/table', + 'model' => $this->userModel, + 'columns' => [ + 'id' => ['label' => 'ID', 'width' => '60px'], + 'name' => ['label' => 'Имя'], + 'email' => ['label' => 'Email'], + 'system_role' => ['label' => 'Роль', 'width' => '140px'], + 'org_count' => ['label' => 'Организаций', 'width' => '100px'], + 'status' => ['label' => 'Статус', 'width' => '120px'], + 'created_at' => ['label' => 'Дата', 'width' => '100px'], + ], + 'searchable' => ['name', 'email', 'id'], + 'sortable' => ['id', 'name', 'email', 'created_at'], + 'defaultSort' => 'created_at', + 'order' => 'desc', + 'scope' => function ($builder) { + // JOIN с подсчётом организаций пользователя + $builder->from('users') + ->select('users.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.user_id = users.id) as org_count'); + }, + 'actions' => ['label' => 'Действия', 'width' => '140px'], + 'actionsConfig' => [ + [ + 'label' => '', + 'url' => '/superadmin/users/block/{id}', + 'icon' => 'fa-solid fa-ban', + 'class' => 'btn-outline-warning', + 'title' => 'Заблокировать', + 'confirm' => 'Заблокировать пользователя?', + ], + [ + 'label' => '', + 'url' => '/superadmin/users/delete/{id}', + 'icon' => 'fa-solid fa-trash', + 'class' => 'btn-outline-danger', + 'title' => 'Удалить', + 'confirm' => 'Удалить пользователя? Это действие нельзя отменить!', + ], + ], + 'emptyMessage' => 'Пользователи не найдены', + 'emptyIcon' => 'bi bi-people', + ]; + } + + /** + * Список пользователей + */ + public function users() + { + $config = $this->getUsersTableConfig(); + $tableHtml = $this->renderTable($config); + + return $this->renderTwig('superadmin/users/index', [ + 'tableHtml' => $tableHtml, + 'config' => $config, + ]); + } + + /** + * AJAX таблица пользователей + */ + public function usersTable() + { + $config = $this->getUsersTableConfig(); + return $this->table($config); + } + + /** + * Изменение системной роли пользователя + */ + public function updateUserRole($id) + { + $newRole = $this->request->getPost('system_role'); + + $allowedRoles = ['user', 'admin', 'superadmin']; + if (!in_array($newRole, $allowedRoles)) { + return redirect()->back()->with('error', 'Недопустимая роль'); + } + + $this->userModel->update($id, ['system_role' => $newRole]); + + return redirect()->back()->with('success', 'Роль пользователя обновлена'); + } + + /** + * Блокировка пользователя + */ + public function blockUser($id) + { + $this->userModel->update($id, ['status' => 'blocked']); + + return redirect()->back()->with('success', 'Пользователь заблокирован'); + } + + /** + * Разблокировка пользователя + */ + public function unblockUser($id) + { + $this->userModel->update($id, ['status' => 'active']); + + return redirect()->back()->with('success', 'Пользователь разблокирован'); + } + + /** + * Удаление пользователя (полное удаление) + */ + public function deleteUser($id) + { + // Полное удаление без soft delete + $this->userModel->delete($id, true); + + return redirect()->to('/superadmin/users')->with('success', 'Пользователь удалён'); + } + + // ========================================================================= + // СТАТИСТИКА + // ========================================================================= + + /** + * Статистика использования + */ + public function statistics() + { + // Статистика по дням (последние 30 дней) + $dailyStats = []; + for ($i = 29; $i >= 0; $i--) { + $date = date('Y-m-d', strtotime("-{$i} days")); + $dailyStats[] = [ + 'date' => $date, + 'users' => $this->userModel->where('DATE(created_at)', $date)->countAllResults(), + 'orgs' => $this->organizationModel->where('DATE(created_at)', $date)->countAllResults(), + ]; + } + + // Статистика по тарифам (через таблицу подписок) + $planStats = []; + $plans = $this->planModel->where('is_active', 1)->findAll(); + + // Проверяем существование таблицы подписок + $db = \Config\Database::connect(); + $tableExists = $db->tableExists('organization_plan_subscriptions'); + + if ($tableExists) { + $subscriptionsTable = $db->table('organization_plan_subscriptions'); + foreach ($plans as $plan) { + $count = $subscriptionsTable->where('plan_id', $plan['id']) + ->where('status', 'active') + ->countAllResults(); + $planStats[$plan['id']] = [ + 'name' => $plan['name'], + 'orgs_count' => $count, + ]; + } + } else { + // Таблица подписок ещё не создана - показываем 0 для всех тарифов + foreach ($plans as $plan) { + $planStats[$plan['id']] = [ + 'name' => $plan['name'], + 'orgs_count' => 0, + ]; + } + } + + return $this->renderTwig('superadmin/statistics', compact('dailyStats', 'planStats')); + } +} diff --git a/app/Database/Migrations/2026-01-13-200001_CreateCiSessionsTable.php b/app/Database/Migrations/2026-01-13-200001_CreateCiSessionsTable.php new file mode 100644 index 0000000..f7a6f24 --- /dev/null +++ b/app/Database/Migrations/2026-01-13-200001_CreateCiSessionsTable.php @@ -0,0 +1,45 @@ +forge->addField([ + 'id' => [ + 'type' => 'VARCHAR', + 'constraint' => 128, + ], + 'ip_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + ], + 'timestamp' => [ + 'type' => 'INT', + 'unsigned' => true, + 'default' => 0, + ], + 'data' => [ + 'type' => 'BLOB', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('timestamp'); + $this->forge->createTable('ci_sessions'); + } + + public function down() + { + $this->forge->dropTable('ci_sessions'); + } +} diff --git a/app/Database/Migrations/2026-01-13-200002_AddPasswordResetFieldsToUsers.php b/app/Database/Migrations/2026-01-13-200002_AddPasswordResetFieldsToUsers.php new file mode 100644 index 0000000..f61096b --- /dev/null +++ b/app/Database/Migrations/2026-01-13-200002_AddPasswordResetFieldsToUsers.php @@ -0,0 +1,32 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'after' => 'verified_at', + ], + 'reset_expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'reset_token', + ], + ]; + + $this->forge->addColumn('users', $fields); + } + + public function down() + { + $this->forge->dropColumn('users', ['reset_token', 'reset_expires_at']); + } +} diff --git a/app/Database/Migrations/2026-01-14-000001_AddSystemRoleToUsers.php b/app/Database/Migrations/2026-01-14-000001_AddSystemRoleToUsers.php new file mode 100644 index 0000000..e48d7d5 --- /dev/null +++ b/app/Database/Migrations/2026-01-14-000001_AddSystemRoleToUsers.php @@ -0,0 +1,27 @@ + [ + 'type' => 'ENUM', + 'constraint' => ['user', 'admin', 'superadmin'], + 'default' => 'user', + 'after' => 'password', + ], + ]; + + $this->forge->addColumn('users', $fields); + } + + public function down() + { + $this->forge->dropColumn('users', 'system_role'); + } +} diff --git a/app/Database/Migrations/2026-01-15-000001_AddPlansTable.php b/app/Database/Migrations/2026-01-15-000001_AddPlansTable.php new file mode 100644 index 0000000..c412d99 --- /dev/null +++ b/app/Database/Migrations/2026-01-15-000001_AddPlansTable.php @@ -0,0 +1,155 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => false, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'price' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'currency' => [ + 'type' => 'VARCHAR', + 'constraint' => 3, + 'default' => 'RUB', + ], + 'billing_period' => [ + 'type' => 'ENUM', + 'constraint' => ['monthly', 'yearly', 'quarterly'], + 'default' => 'monthly', + ], + 'max_users' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 5, + ], + 'max_clients' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 100, + ], + 'max_storage' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 10, + ], + 'features' => [ + 'type' => 'JSON', + 'null' => true, + ], + 'is_active' => [ + 'type' => 'TINYINT', + 'default' => 1, + ], + 'is_default' => [ + 'type' => 'TINYINT', + 'default' => 0, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => false, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['name']); + $this->forge->createTable('plans'); + + // Добавление базовых тарифов по умолчанию + $seedData = [ + [ + 'name' => 'Бесплатный', + 'description' => 'Базовый тариф для небольших команд', + 'price' => 0, + 'currency' => 'RUB', + 'billing_period' => 'monthly', + 'max_users' => 3, + 'max_clients' => 50, + 'max_storage' => 5, + 'features' => json_encode([ + 'Базовые модули', + 'Email поддержка', + 'Экспорт в CSV', + ]), + 'is_active' => 1, + 'is_default' => 1, + 'created_at' => date('Y-m-d H:i:s'), + ], + [ + 'name' => 'Старт', + 'description' => 'Тариф для растущих компаний', + 'price' => 990, + 'currency' => 'RUB', + 'billing_period' => 'monthly', + 'max_users' => 10, + 'max_clients' => 500, + 'max_storage' => 50, + 'features' => json_encode([ + 'Все модули', + 'Приоритетная поддержка', + 'Экспорт в PDF и Excel', + 'API доступ', + ]), + 'is_active' => 1, + 'is_default' => 0, + 'created_at' => date('Y-m-d H:i:s'), + ], + [ + 'name' => 'Бизнес', + 'description' => 'Полный функционал для крупных компаний', + 'price' => 4990, + 'currency' => 'RUB', + 'billing_period' => 'monthly', + 'max_users' => 50, + 'max_clients' => 5000, + 'max_storage' => 500, + 'features' => json_encode([ + 'Все модули', + 'Персональный менеджер', + 'Экспорт в PDF и Excel', + 'Полный API доступ', + 'Интеграции', + 'Брендинг', + ]), + 'is_active' => 1, + 'is_default' => 0, + 'created_at' => date('Y-m-d H:i:s'), + ], + ]; + + $this->db->table('plans')->insertBatch($seedData); + } + + public function down() + { + $this->forge->dropTable('plans'); + } +} diff --git a/app/Database/Migrations/2026-01-15-000002_CreateOrganizationPlanSubscriptionsTable.php b/app/Database/Migrations/2026-01-15-000002_CreateOrganizationPlanSubscriptionsTable.php new file mode 100644 index 0000000..d7720ac --- /dev/null +++ b/app/Database/Migrations/2026-01-15-000002_CreateOrganizationPlanSubscriptionsTable.php @@ -0,0 +1,70 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'organization_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'plan_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'status' => [ + 'type' => 'ENUM', + 'constraint' => ['trial', 'active', 'expired', 'cancelled'], + 'default' => 'trial', + ], + 'trial_ends_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'expires_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + + $this->forge->addKey('id', true); + + // Организация не может иметь две активные подписки на один тариф одновременно + $this->forge->addUniqueKey(['organization_id', 'plan_id']); + + $this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('plan_id', 'plans', 'id', 'CASCADE', 'CASCADE'); + + $this->forge->createTable('organization_plan_subscriptions'); + } + + public function down() + { + $this->forge->dropTable('organization_plan_subscriptions'); + } +} diff --git a/app/Database/Seeds/SetSystemRoleSeeder.php b/app/Database/Seeds/SetSystemRoleSeeder.php new file mode 100644 index 0000000..f2ab3f6 --- /dev/null +++ b/app/Database/Seeds/SetSystemRoleSeeder.php @@ -0,0 +1,123 @@ +parseArg('email'); + $role = $this->parseArg('role') ?? self::ROLE_SUPERADMIN; + + if (empty($email)) { + echo "Ошибка: Не указан email пользователя\n"; + echo "\n"; + echo "Использование:\n"; + echo " php spark db:seed \"App\Database\Seeds\SetSystemRoleSeeder\" -email=admin@example.com -role=superadmin\n"; + echo "\n"; + echo "Доступные роли: user, admin, superadmin\n"; + echo "По умолчанию: superadmin\n"; + return; + } + + $this->assignRole($email, $role); + } + + /** + * Парсинг аргумента из командной строки + */ + protected function parseArg(string $name): ?string + { + global $argv; + + foreach ($argv as $arg) { + // Формат: -email=value + if (preg_match("/^-{$name}=(.+)$/", $arg, $matches)) { + return $matches[1]; + } + } + + return null; + } + + /** + * Назначение роли пользователю по email + */ + public function assignRole(string $email, string $role): bool + { + $userModel = new UserModel(); + + // Ищем пользователя по email + $user = $userModel->where('email', $email)->first(); + + if (!$user) { + echo "Ошибка: Пользователь с email '{$email}' не найден в базе данных\n"; + return false; + } + + // Отладка + echo "DEBUG: Found user ID = " . $user['id'] . "\n"; + echo "DEBUG: Current system_role = " . ($user['system_role'] ?? 'NULL') . "\n"; + + // Валидируем роль + $validRoles = [self::ROLE_USER, self::ROLE_ADMIN, self::ROLE_SUPERADMIN]; + if (!in_array($role, $validRoles)) { + echo "Ошибка: Неизвестная роль '{$role}'. Доступные роли: " . implode(', ', $validRoles) . "\n"; + return false; + } + + // Используем DB Builder для обновления + $db = \Config\Database::connect(); + $result = $db->table('users') + ->where('id', $user['id']) + ->set('system_role', $role) + ->update(); + + if (!$result) { + echo "Ошибка обновления\n"; + return false; + } + + // Проверяем что обновилось + $updatedUser = $userModel->find($user['id']); + echo "DEBUG: New system_role = " . ($updatedUser['system_role'] ?? 'NULL') . "\n"; + + $roleLabels = [ + self::ROLE_USER => 'Пользователь', + self::ROLE_ADMIN => 'Администратор', + self::ROLE_SUPERADMIN => 'Суперадмин', + ]; + + $roleLabel = $roleLabels[$role] ?? $role; + + echo "Успех!\n"; + echo " Email: {$email}\n"; + echo " User ID: {$user['id']}\n"; + echo " Назначенная роль: {$roleLabel}\n"; + + return true; + } +} \ No newline at end of file diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php new file mode 100644 index 0000000..379ecd4 --- /dev/null +++ b/app/Filters/AuthFilter.php @@ -0,0 +1,98 @@ +get('isLoggedIn')) { + return; + } + + // Проверяем remember-токен + $userId = Auth::checkRememberToken(); + + if ($userId !== null) { + // Токен найден и валиден — восстанавливаем сессию + $userModel = new UserModel(); + $user = $userModel->find($userId); + + if ($user && $user['email_verified']) { + $orgUserModel = new OrganizationUserModel(); + $userOrgs = $orgUserModel->where('user_id', $user['id'])->findAll(); + + if (!empty($userOrgs)) { + // Восстанавливаем данные сессии + $sessionData = [ + 'user_id' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + 'isLoggedIn' => true, + ]; + + // Выбираем организацию если она была + if (count($userOrgs) === 1) { + $sessionData['active_org_id'] = $userOrgs[0]['organization_id']; + } + + $session->set($sessionData); + + log_message('info', "User {$user['email']} logged in via remember token"); + + return; + } + } + + // Токен недействителен — удаляем его + $response = service('response'); + $response->deleteCookie('remember_selector'); + $response->deleteCookie('remember_token'); + } + + // Пользователь не авторизован — редирект на логин + return redirect()->to('/login'); + } + + /** + * Обработка после выполнения запроса + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @param array|null $arguments + * @return void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void + { + // Ничего не делаем после запроса + } +} diff --git a/app/Filters/OrganizationFilter.php b/app/Filters/OrganizationFilter.php index c278cb1..95400a1 100644 --- a/app/Filters/OrganizationFilter.php +++ b/app/Filters/OrganizationFilter.php @@ -6,74 +6,47 @@ use CodeIgniter\Filters\FilterInterface; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; +/** + * OrganizationFilter - Фильтр выбора организации + * + * Проверяет, что у авторизованного пользователя выбрана организация. + * Если организация не выбрана — редирект на /organizations. + * + * Применяется к маршрутам, которые требуют выбранной организации + * (например, дашборд, управление клиентами, задачи и т.д.). + */ class OrganizationFilter implements FilterInterface { - + /** + * Проверка выбора организации перед обработкой запроса + * + * @param RequestInterface $request + * @param array|null $arguments + * @return ResponseInterface|void + */ public function before(RequestInterface $request, $arguments = null) { - $session = session(); - $uri = $request->getUri(); - $currentPath = $uri->getPath(); - - // === БЛОК ДЛЯ ГОСТЕЙ (НЕЗАЛОГИНЕННЫХ) === - if (!$session->get('isLoggedIn')) { - // Список публичных маршрутов для гостей - $publicRoutes = [ - '/', - '/login', - '/register', - '/register/success', - '/auth/verify', - '/auth/resend-verification', - ]; - - if (!in_array($currentPath, $publicRoutes)) { - // Если гость лезет не туда (например /organizations) — на главную - return redirect()->to('/'); - } - // Иначе пропускаем - return; - } - // ======================================= - - // === БЛОК ДЛЯ ЗАЛОГИНЕННЫХ === - - // Список маршрутов, доступных БЕЗ выбранной организации - $publicAuthRoutes = [ - '/login', - '/register', - '/logout', - '/register/success', - '/auth/verify', - '/auth/resend-verification', - '/organizations', - '/organizations/create', - '/organizations/switch' // Начало пути для переключения - ]; - - // Если мы на этих маршрутах — проверка orgId не нужна (мы просто выбираем её) - if (in_array($currentPath, $publicAuthRoutes) || strpos($currentPath, '/organizations/switch') === 0) { + // Если пользователь не авторизован — пропускаем (AuthFilter разберётся) + if (!session()->get('isLoggedIn')) { return; } - // Если мы попадаем сюда — значит пользователь идет на закрытую страницу (например /crm) - // или на главную (/). - - // Главную страницу (/) мы проверяем на уровне контроллера Home::index, - // поэтому фильтр для '/' можно пропускать, ИЛИ проверить здесь. - // Пропустим '/', чтобы контроллер сам решил: редиректить на /organizations или показывать дашборд. - if ($currentPath === '/') { - return; - } - - // Для всех остальных закрытых страниц проверяем active_org_id + // Проверяем, выбрана ли организация if (empty(session()->get('active_org_id'))) { return redirect()->to('/organizations'); } } - public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + /** + * Обработка после выполнения запроса + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @param array|null $arguments + * @return void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void { - // Do nothing + // Ничего не делаем после запроса } -} \ No newline at end of file +} diff --git a/app/Filters/RoleFilter.php b/app/Filters/RoleFilter.php index f82ca70..cf307d3 100644 --- a/app/Filters/RoleFilter.php +++ b/app/Filters/RoleFilter.php @@ -34,24 +34,43 @@ class RoleFilter implements FilterInterface $access = AccessService::getInstance(); - // Проверка авторизации в организации - if (!$access->isAuthenticated()) { - // Если пользователь не авторизован в организации - редирект на выбор организации - return redirect()->to('/organizations'); + // Разбор аргументов + // Формат: 'role:admin,manager' или 'role:system:superadmin' или 'permission:manage_users:users' + + // Проверка системных ролей (system: prefix) + if (is_string($arguments) && str_starts_with($arguments, 'role:system:')) { + $roles = explode(',', substr($arguments, 13)); // 13 = длина 'role:system:' + $roles = array_map('trim', $roles); + + // Для системных ролей НЕ требуется авторизация в организации + if (!$access->isSystemRole($roles)) { + return $this->forbiddenResponse(); + } + return null; } - // Разбор аргументов - // Формат: 'role:admin,manager' или 'permission:manage_users:users' + // Проверка организационных ролей if (is_string($arguments) && str_starts_with($arguments, 'role:')) { $roles = explode(',', substr($arguments, 5)); $roles = array_map('trim', $roles); + // Проверка авторизации в организации + if (!$access->isAuthenticated()) { + // Если пользователь не авторизован в организации - редирект на выбор организации + return redirect()->to('/organizations'); + } + if (!$access->isRole($roles)) { return $this->forbiddenResponse(); } } if (is_string($arguments) && str_starts_with($arguments, 'permission:')) { + // Проверка авторизации в организации для разрешений + if (!$access->isAuthenticated()) { + return redirect()->to('/organizations'); + } + $parts = explode(':', substr($arguments, 11)); if (count($parts) >= 2) { $permission = $parts[0]; diff --git a/app/Libraries/EmailLibrary.php b/app/Libraries/EmailLibrary.php index 4044c9c..7f27207 100644 --- a/app/Libraries/EmailLibrary.php +++ b/app/Libraries/EmailLibrary.php @@ -65,4 +65,36 @@ class EmailLibrary return false; } } + + /** + * Отправить письмо для сброса пароля + */ + public function sendPasswordResetEmail(string $email, string $name, string $token): bool + { + $emailConfig = config('Email'); + + // Генерируем URL для сброса пароля + $resetUrl = base_url('/forgot-password/reset/' . $token); + + // Рендерим HTML письма через Twig + $twig = Services::twig(); + $htmlBody = $twig->render('emails/password_reset', [ + 'name' => $name, + 'reset_url' => $resetUrl, + 'app_name' => $emailConfig->fromName ?? 'Бизнес.Точка', + ]); + + $emailer = Services::email($emailConfig); + $emailer->setTo($email); + $emailer->setFrom($emailConfig->fromEmail, $emailConfig->fromName); + $emailer->setSubject('Сброс пароля'); + $emailer->setMessage($htmlBody); + + try { + return $emailer->send(); + } catch (\Exception $e) { + log_message('error', 'Ошибка отправки email: ' . $e->getMessage()); + return false; + } + } } diff --git a/app/Libraries/Twig/TwigGlobalsExtension.php b/app/Libraries/Twig/TwigGlobalsExtension.php index cdc96cf..fd78c3e 100644 --- a/app/Libraries/Twig/TwigGlobalsExtension.php +++ b/app/Libraries/Twig/TwigGlobalsExtension.php @@ -42,6 +42,11 @@ class TwigGlobalsExtension extends AbstractExtension new TwigFunction('role_badge', [$this, 'roleBadge'], ['is_safe' => ['html']]), new TwigFunction('status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]), new TwigFunction('get_all_roles', [$this, 'getAllRoles'], ['is_safe' => ['html']]), + + // System role functions (superadmin) + new TwigFunction('is_superadmin', [$this, 'isSuperadmin'], ['is_safe' => ['html']]), + new TwigFunction('is_system_admin', [$this, 'isSystemAdmin'], ['is_safe' => ['html']]), + new TwigFunction('get_system_role', [$this, 'getSystemRole'], ['is_safe' => ['html']]), ]; } @@ -137,6 +142,26 @@ class TwigGlobalsExtension extends AbstractExtension return '' . esc($label) . ''; } + // ======================================== + // System Role Functions (superadmin) + // ======================================== + + public function isSuperadmin(): bool + { + return service('access')->isSuperadmin(); + } + + public function isSystemAdmin(): bool + { + return service('access')->isSystemAdmin(); + } + + public function getSystemRole(): ?string + { + return service('access')->getSystemRole(); + } + + public function statusBadge(string $status): string { $colors = [ diff --git a/app/Models/OrganizationModel.php b/app/Models/OrganizationModel.php index 00b20c5..f0bdca5 100644 --- a/app/Models/OrganizationModel.php +++ b/app/Models/OrganizationModel.php @@ -11,7 +11,16 @@ class OrganizationModel extends Model protected $useAutoIncrement = true; protected $returnType = 'array'; protected $useSoftDeletes = true; // Включаем мягкое удаление (deleted_at) - protected $allowedFields = ['owner_id', 'name', 'type', 'logo', 'requisites', 'trial_ends_at', 'settings']; + protected $allowedFields = [ + 'owner_id', + 'name', + 'type', + 'logo', + 'requisites', + 'trial_ends_at', + 'settings', + 'status', + ]; protected $useTimestamps = true; protected $dateFormat = 'datetime'; diff --git a/app/Models/PlanModel.php b/app/Models/PlanModel.php new file mode 100644 index 0000000..bb2bff7 --- /dev/null +++ b/app/Models/PlanModel.php @@ -0,0 +1,79 @@ + 'required|min_length[2]|max_length[100]', + 'price' => 'required|numeric|greater_than_equal[0]', + 'max_users' => 'required|integer|greater_than[0]', + 'max_clients' => 'required|integer|greater_than[0]', + 'max_storage' => 'required|integer|greater_than[0]', + ]; + + protected $validationMessages = [ + 'name' => [ + 'required' => 'Название тарифа обязательно', + 'min_length' => 'Название должно быть минимум 2 символа', + ], + 'price' => [ + 'required' => 'Цена обязательна', + 'numeric' => 'Цена должна быть числом', + ], + ]; + + /** + * Получение активных тарифов + */ + public function getActivePlans() + { + return $this->where('is_active', 1)->findAll(); + } + + /** + * Получение тарифа по умолчанию + */ + public function getDefaultPlan() + { + return $this->where('is_default', 1)->where('is_active', 1)->first(); + } + + /** + * Проверка, является ли тариф системным + */ + public function isSystemPlan($planId) + { + $plan = $this->find($planId); + return $plan !== null; + } +} diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index b3865c3..082a691 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -24,7 +24,13 @@ class UserModel extends Model // Поля для верификации email 'verification_token', 'email_verified', - 'verified_at' + 'verified_at', + // Поля для восстановления пароля + 'reset_token', + 'reset_expires_at', + //системная роль + 'system_role', + 'status', ]; // Dates @@ -44,4 +50,75 @@ class UserModel extends Model } return $data; } + + /** + * Генерация токена для сброса пароля + * + * @param int $userId ID пользователя + * @param int $expiresInHours Срок действия токена в часах (по умолчанию 24 часа) + * @return string Сгенерированный токен + */ + public function generateResetToken(int $userId, int $expiresInHours = 24): string + { + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:s', strtotime("+{$expiresInHours} hours")); + + $this->update($userId, [ + 'reset_token' => $token, + 'reset_expires_at' => $expiresAt, + ]); + + return $token; + } + + /** + * Проверка токена сброса пароля + * + * @param string $token Токен сброса + * @return array|null Данные пользователя или null если токен недействителен + */ + public function verifyResetToken(string $token): ?array + { + $user = $this->where('reset_token', $token)->first(); + + if (!$user) { + return null; + } + + // Проверяем срок действия токена + if (empty($user['reset_expires_at'])) { + return null; + } + + if (strtotime($user['reset_expires_at']) < time()) { + return null; + } + + return $user; + } + + /** + * Очистка токена сброса пароля + * + * @param int $userId ID пользователя + * @return bool + */ + public function clearResetToken(int $userId): bool + { + return $this->update($userId, [ + 'reset_token' => null, + 'reset_expires_at' => null, + ]); + } + + /** + * Поиск пользователя по email + * + * @param string $email Email адрес + * @return array|null + */ + public function findByEmail(string $email): ?array + { + return $this->where('email', $email)->first(); + } } \ No newline at end of file diff --git a/app/Modules/Clients/Config/Routes.php b/app/Modules/Clients/Config/Routes.php index 1f1ed52..3112beb 100644 --- a/app/Modules/Clients/Config/Routes.php +++ b/app/Modules/Clients/Config/Routes.php @@ -1,6 +1,5 @@ group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) { $routes->get('/', 'Clients::index'); $routes->get('table', 'Clients::table'); // AJAX endpoint для таблицы diff --git a/app/Services/AccessService.php b/app/Services/AccessService.php index f8ad866..c4a417d 100644 --- a/app/Services/AccessService.php +++ b/app/Services/AccessService.php @@ -17,7 +17,14 @@ class AccessService private OrganizationUserModel $orgUserModel; /** - * Роли и их уровни (для быстрого сравнения) + * Системные роли (уровень всей системы, не организации) + */ + public const SYSTEM_ROLE_USER = 'user'; + public const SYSTEM_ROLE_ADMIN = 'admin'; + public const SYSTEM_ROLE_SUPERADMIN = 'superadmin'; + + /** + * Роли организации (для быстрого сравнения) */ public const ROLE_OWNER = 'owner'; public const ROLE_ADMIN = 'admin'; @@ -84,6 +91,8 @@ class AccessService ], ]; + private ?string $cachedSystemRole = null; + public function __construct() { $this->orgUserModel = new OrganizationUserModel(); @@ -195,6 +204,81 @@ class AccessService return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_MANAGER], true); } + // ========================================================================= + // СИСТЕМНЫЕ РОЛИ (суперадмин) + // ========================================================================= + + /** + * Получение системной роли пользователя + * + * @return string|null + */ + public function getSystemRole(): ?string + { + if ($this->cachedSystemRole !== null) { + return $this->cachedSystemRole; + } + + $userId = session()->get('user_id'); + if (!$userId) { + return null; + } + + $userModel = new \App\Models\UserModel(); + $user = $userModel->find($userId); + + $this->cachedSystemRole = $user['system_role'] ?? null; + return $this->cachedSystemRole; + } + + /** + * Проверка системной роли пользователя + * + * @param string|array $roles Роль или массив ролей для проверки + * @return bool + */ + public function isSystemRole($roles): bool + { + $currentRole = $this->getSystemRole(); + if ($currentRole === null) { + return false; + } + + $roles = (array) $roles; + return in_array($currentRole, $roles, true); + } + + /** + * Проверка, является ли пользователь суперадмином + * + * @return bool + */ + public function isSuperadmin(): bool + { + return $this->getSystemRole() === self::SYSTEM_ROLE_SUPERADMIN; + } + + /** + * Проверка, является ли пользователь системным администратором + * + * @return bool + */ + public function isSystemAdmin(): bool + { + $role = $this->getSystemRole(); + return in_array($role, [self::SYSTEM_ROLE_ADMIN, self::SYSTEM_ROLE_SUPERADMIN], true); + } + + /** + * Сброс кэша системной роли + * + * @return void + */ + public function resetSystemRoleCache(): void + { + $this->cachedSystemRole = null; + } + /** * Проверка права на действие * @@ -409,6 +493,7 @@ class AccessService public function resetCache(): void { $this->currentMembership = null; + $this->cachedSystemRole = null; } /** @@ -459,4 +544,27 @@ class AccessService ], ]; } + + /** + * Получение всех системных ролей с описаниями + * + * @return array + */ + public static function getAllSystemRoles(): array + { + return [ + self::SYSTEM_ROLE_USER => [ + 'label' => 'Пользователь', + 'description' => 'Обычный пользователь системы', + ], + self::SYSTEM_ROLE_ADMIN => [ + 'label' => 'Администратор', + 'description' => 'Администратор системы', + ], + self::SYSTEM_ROLE_SUPERADMIN => [ + 'label' => 'Суперадмин', + 'description' => 'Полный доступ ко всем функциям системы', + ], + ]; + } } diff --git a/app/Views/auth/forgot_password.twig b/app/Views/auth/forgot_password.twig new file mode 100644 index 0000000..6b7f9f8 --- /dev/null +++ b/app/Views/auth/forgot_password.twig @@ -0,0 +1,71 @@ +{% extends 'layouts/public.twig' %} + +{% block title %}Восстановление пароля - {{ parent() }}{% endblock %} + +{% block content %} +
+
+
+
+
+

+ Восстановление пароля +

+
+
+ {% if success %} +
+ + {{ success }} +
+ + {% else %} +

+ Введите email, на который зарегистрирована ваша учётная запись. + Мы отправим вам ссылку для сброса пароля. +

+ + {% if error %} +
+ + {{ error }} +
+ {% endif %} + +
+ {{ csrf_field()|raw }} + +
+ + +
+ +
+ +
+
+ {% endif %} +
+ +
+
+
+
+{% endblock %} diff --git a/app/Views/auth/login.twig b/app/Views/auth/login.twig index 2caf5b2..549936f 100644 --- a/app/Views/auth/login.twig +++ b/app/Views/auth/login.twig @@ -10,6 +10,11 @@ {{ form_open(base_url('/login'), 'class="needs-validation"') }} + {{ csrf_field()|raw }} + + {% if error %} +
{{ error }}
+ {% endif %}
@@ -19,11 +24,20 @@
+ +
+ + +
+ {{ form_close() }}
+ Забыли пароль? +
+
Нет аккаунта? Зарегистрироваться
diff --git a/app/Views/auth/reset_password.twig b/app/Views/auth/reset_password.twig new file mode 100644 index 0000000..58065ea --- /dev/null +++ b/app/Views/auth/reset_password.twig @@ -0,0 +1,81 @@ +{% extends 'layouts/public.twig' %} + +{% block title %}Сброс пароля - {{ parent() }}{% endblock %} + +{% block content %} +
+
+
+
+
+

+ Сброс пароля +

+
+
+ {% if error %} +
+ + {{ error }} +
+ + {% else %} +

+ Введите новый пароль для учётной записи {{ email }} +

+ +
+ {{ csrf_field()|raw }} + + + +
+ + +
Минимум 6 символов
+
+ +
+ + +
+ +
+ + После смены пароля вы будете автоматически разлогинены на всех устройствах. +
+ +
+ +
+
+ {% endif %} +
+ +
+
+
+
+{% endblock %} diff --git a/app/Views/emails/password_reset.twig b/app/Views/emails/password_reset.twig new file mode 100644 index 0000000..d64ccb5 --- /dev/null +++ b/app/Views/emails/password_reset.twig @@ -0,0 +1,115 @@ + + + + + + Сброс пароля + + + +
+ +
+ + diff --git a/app/Views/layouts/base.twig b/app/Views/layouts/base.twig index 6ab2d2f..82fcd14 100644 --- a/app/Views/layouts/base.twig +++ b/app/Views/layouts/base.twig @@ -125,6 +125,14 @@