diff --git a/app/Config/Filters.php b/app/Config/Filters.php index 72716e1..f3e02da 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -35,6 +35,7 @@ class Filters extends BaseFilters 'pagecache' => PageCache::class, 'performance' => PerformanceMetrics::class, 'org' => \App\Filters\OrganizationFilter::class, + 'role' => \App\Filters\RoleFilter::class, ]; /** diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 7d32e51..8c4ba51 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -17,9 +17,18 @@ $routes->get('auth/verify/(:any)', 'Auth::verify/$1'); $routes->get('auth/resend-verification', 'Auth::resendVerification'); $routes->post('auth/resend-verification', 'Auth::resendVerification'); +# Маршруты для приглашений (публичные, без фильтра org) +$routes->group('invitation', static function ($routes) { + $routes->get('accept/(:any)', 'InvitationController::accept/$1'); + $routes->post('accept/(:any)', 'InvitationController::processAccept'); + $routes->post('decline/(:any)', 'InvitationController::decline/$1'); + $routes->match(['GET', 'POST'], 'complete/(:any)', 'InvitationController::complete/$1'); +}); + # Защищённые маршруты (с фильтром org) $routes->group('', ['filter' => 'org'], static function ($routes) { $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'); @@ -28,9 +37,18 @@ $routes->group('', ['filter' => 'org'], static function ($routes) { $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)/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'); }); # Подключение роутов модулей -require_once APPPATH . 'Modules/Clients/Config/Routes.php'; - - +require_once APPPATH . 'Modules/Clients/Config/Routes.php'; \ No newline at end of file diff --git a/app/Config/Services.php b/app/Config/Services.php index df7c8ad..96a4e78 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -29,4 +29,39 @@ class Services extends BaseService * return new \CodeIgniter\Example(); * } */ + + /** + * Сервис для проверки прав доступа (RBAC) + * + * @param bool $getShared + * @return \App\Services\AccessService + */ + public static function access(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('access'); + } + + return new \App\Services\AccessService(); + } + + /** + * Сервис для rate limiting + * + * @param bool $getShared + * @return \App\Services\RateLimitService|null + */ + public static function rateLimit(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('rateLimit'); + } + + try { + return \App\Services\RateLimitService::getInstance(); + } catch (\Exception $e) { + log_message('warning', 'RateLimitService unavailable: ' . $e->getMessage()); + return null; + } + } } diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 87d5b74..320d3de 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -6,22 +6,147 @@ use App\Models\UserModel; use App\Models\OrganizationModel; use App\Models\OrganizationUserModel; use App\Libraries\EmailLibrary; +use App\Services\RateLimitService; class Auth extends BaseController { protected $emailLibrary; + protected ?RateLimitService $rateLimitService; public function __construct() { $this->emailLibrary = new EmailLibrary(); + + // Инициализируем rate limiting сервис (может быть null если Redis недоступен) + try { + $this->rateLimitService = RateLimitService::getInstance(); + } catch (\Exception $e) { + // Если Redis недоступен - логируем и продолжаем без rate limiting + log_message('warning', 'RateLimitService недоступен: ' . $e->getMessage()); + $this->rateLimitService = null; + } + } + + /** + * Проверка rate limiting перед действием + * + * @param string $action Тип действия (login, register, reset) + * @return array|null Возвращает данные об ошибке если заблокирован, иначе null + */ + 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; + } + + /** + * Запись неудачной попытки + * + * @param string $action Тип действия + * @return array|null Данные о превышении лимита или null + */ + 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; + } + + /** + * Сброс счётчика после успешного действия + * + * @param string $action Тип действия + * @return void + */ + protected function resetRateLimit(string $action): void + { + if ($this->rateLimitService !== null) { + $this->rateLimitService->resetAttempts($action); + } + } + + /** + * Форматирование времени блокировки для отображения + * + * @param int $seconds Секунды + * @return string + */ + protected function formatBlockTime(int $seconds): string + { + if ($seconds >= 60) { + $minutes = ceil($seconds / 60); + return $minutes . ' ' . $this->pluralize($minutes, ['минуту', 'минуты', 'минут']); + } + return $seconds . ' ' . $this->pluralize($seconds, ['секунду', 'секунды', 'секунд']); + } + + /** + * Склонение окончаний для чисел + * + * @param int $number + * @param array $forms [одна, две, пять] + * @return string + */ + protected function pluralize(int $number, array $forms): string + { + $abs = abs($number); + $mod = $abs % 10; + + if ($abs % 100 >= 11 && $abs % 100 <= 19) { + return $forms[2]; + } + if ($mod === 1) { + return $forms[0]; + } + if ($mod >= 2 && $mod <= 4) { + return $forms[1]; + } + return $forms[2]; } public function register() { if ($this->request->getMethod() === 'POST') { + // === ПРОВЕРКА RATE LIMITING === + $rateLimitError = $this->checkRateLimit('register'); + if ($rateLimitError !== null) { + return redirect()->back() + ->with('error', $rateLimitError['message']) + ->withInput(); + } + log_message('debug', 'POST запрос получен: ' . print_r($this->request->getPost(), true)); - // Валидация (упрощенная для примера) + + // Валидация $rules = [ 'name' => 'required|min_length[3]', 'email' => 'required|valid_email|is_unique[users.email]', @@ -49,11 +174,11 @@ class Auth extends BaseController 'verification_token' => $verificationToken, 'email_verified' => 0, ]; - + log_message('debug', 'Registration userData: ' . print_r($userData, true)); $userId = $userModel->insert($userData); - + log_message('debug', 'Insert result, userId: ' . $userId); @@ -81,6 +206,9 @@ class Auth extends BaseController $verificationToken ); + // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ РЕГИСТРАЦИИ === + $this->resetRateLimit('register'); + // 5. Показываем сообщение о необходимости подтверждения session()->setFlashdata('success', 'Регистрация успешна! Пожалуйста, проверьте вашу почту и подтвердите email.'); return redirect()->to('/register/success'); @@ -103,7 +231,7 @@ class Auth extends BaseController public function verify($token) { log_message('debug', 'Verify called with token: ' . $token); - + if (empty($token)) { return $this->renderTwig('auth/verify_error', [ 'message' => 'Отсутствует токен подтверждения.' @@ -114,7 +242,7 @@ class Auth extends BaseController // Ищем пользователя по токену $user = $userModel->where('verification_token', $token)->first(); - + log_message('debug', 'User found: ' . ($user ? 'yes' : 'no')); if ($user) { log_message('debug', 'User email_verified: ' . $user['email_verified']); @@ -138,9 +266,9 @@ class Auth extends BaseController 'verified_at' => date('Y-m-d H:i:s'), 'verification_token' => null, // Удаляем токен после использования ]; - + $result = $userModel->update($user['id'], $updateData); - + log_message('debug', 'Update result: ' . ($result ? 'success' : 'failed')); log_message('debug', 'Update data: ' . print_r($updateData, true)); @@ -162,8 +290,17 @@ class Auth extends BaseController public function resendVerification() { if ($this->request->getMethod() === 'POST') { + + // === ПРОВЕРКА RATE LIMITING === + $rateLimitError = $this->checkRateLimit('reset'); + if ($rateLimitError !== null) { + return redirect()->back() + ->with('error', $rateLimitError['message']) + ->withInput(); + } + $email = $this->request->getPost('email'); - + if (empty($email)) { return redirect()->back()->with('error', 'Введите email'); } @@ -172,6 +309,8 @@ class Auth extends BaseController $user = $userModel->where('email', $email)->first(); if (!$user) { + // Неудачная попытка - засчитываем для защиты от перебора + $this->recordFailedAttempt('reset'); return redirect()->back()->with('error', 'Пользователь с таким email не найден'); } @@ -192,6 +331,9 @@ class Auth extends BaseController $newToken ); + // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ ОТПРАВКИ === + $this->resetRateLimit('reset'); + return redirect()->back()->with('success', 'Письмо для подтверждения отправлено повторно. Проверьте почту.'); } @@ -201,6 +343,15 @@ class Auth extends BaseController public function login() { if ($this->request->getMethod() === 'POST') { + + // === ПРОВЕРКА RATE LIMITING === + $rateLimitError = $this->checkRateLimit('login'); + if ($rateLimitError !== null) { + return redirect()->back() + ->with('error', $rateLimitError['message']) + ->withInput(); + } + $userModel = new \App\Models\UserModel(); $orgUserModel = new \App\Models\OrganizationUserModel(); @@ -238,6 +389,10 @@ class Auth extends BaseController // Если одна организация — заходим автоматически для удобства $sessionData['active_org_id'] = $userOrgs[0]['organization_id']; session()->set($sessionData); + + // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА === + $this->resetRateLimit('login'); + return redirect()->to('/'); } @@ -251,10 +406,28 @@ class Auth extends BaseController // чтобы страница /organizations не редиректнула его обратно (см. Organizations::index) session()->setFlashdata('info', 'Выберите пространство для работы'); + // === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА === + $this->resetRateLimit('login'); + return redirect()->to('/organizations'); - //} } else { - return redirect()->back()->with('error', 'Неверный логин или пароль'); + // === ЗАСЧИТЫВАЕМ НЕУДАЧНУЮ ПОПЫТКУ === + $limitExceeded = $this->recordFailedAttempt('login'); + + // Если лимит превышен - показываем сообщение + if ($limitExceeded !== null && $limitExceeded['blocked']) { + $message = "Слишком много неудачных попыток входа. "; + $message .= "Доступ заблокирован на " . $this->formatBlockTime($limitExceeded['ttl']) . "."; + return redirect()->back()->with('error', $message)->withInput(); + } + + // Иначе показываем стандартное сообщение об ошибке + $remaining = $this->rateLimitService ? $this->rateLimitService->checkAttempt('login')['remaining'] : 0; + $message = 'Неверный логин или пароль'; + if ($remaining > 0 && $remaining <= 2) { + $message .= " Осталось попыток: {$remaining}"; + } + return redirect()->back()->with('error', $message)->withInput(); } } @@ -267,4 +440,57 @@ class Auth extends BaseController session()->remove('active_org_id'); return redirect()->to('/'); } + + /** + * DEBUG: Просмотр состояния rate limiting (только для разработки) + * DELETE: Убрать перед релизом! + * + * GET /auth/rate-limit-status + */ + public function rateLimitStatus() + { + // В продакшене должен быть доступ только админам + if (env('CI_ENVIRONMENT') === 'production') { + return $this->response->setStatusCode(403)->setJSON(['error' => 'Forbidden']); + } + + if ($this->rateLimitService === null) { + return $this->response->setJSON([ + 'status' => 'unavailable', + 'message' => 'RateLimitService недоступен (Redis не подключен)', + ]); + } + + $loginStatus = $this->rateLimitService->getStatus('login'); + $registerStatus = $this->rateLimitService->getStatus('register'); + $resetStatus = $this->rateLimitService->getStatus('reset'); + + return $this->response->setJSON([ + 'ip' => service('request')->getIPAddress(), + 'redis_connected' => $this->rateLimitService->isConnected(), + 'rate_limiting' => [ + 'login' => [ + 'attempts' => $loginStatus['attempts'], + 'limit' => $loginStatus['limit'], + 'window_seconds' => $loginStatus['window'], + 'is_blocked' => $loginStatus['is_blocked'], + 'block_ttl_seconds' => $loginStatus['block_ttl'], + ], + 'register' => [ + 'attempts' => $registerStatus['attempts'], + 'limit' => $registerStatus['limit'], + 'window_seconds' => $registerStatus['window'], + 'is_blocked' => $registerStatus['is_blocked'], + 'block_ttl_seconds' => $registerStatus['block_ttl'], + ], + 'reset' => [ + 'attempts' => $resetStatus['attempts'], + 'limit' => $resetStatus['limit'], + 'window_seconds' => $resetStatus['window'], + 'is_blocked' => $resetStatus['is_blocked'], + 'block_ttl_seconds' => $resetStatus['block_ttl'], + ], + ], + ]); + } } diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 489dbfb..db1495c 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -7,8 +7,9 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Psr\Log\LoggerInterface; use App\Models\OrganizationModel; +use App\Services\AccessService; -/** +/** * BaseController provides a convenient place for loading components * and performing functions that are needed by all your controllers. * @@ -27,6 +28,7 @@ abstract class BaseController extends Controller */ protected $session; + protected AccessService $access; /** * @return void @@ -42,6 +44,33 @@ abstract class BaseController extends Controller // Preload any models, libraries, etc, here. $this->session = service('session'); + $this->access = service('access'); + + // Загружаем хелпер доступа для Twig + helper('access'); + } + + /** + * Проверка права на действие ( shortcut для $this->access->can() ) + * + * @param string $action + * @param string $resource + * @return bool + */ + protected function can(string $action, string $resource): bool + { + return $this->access->can($action, $resource); + } + + /** + * Проверка роли (shortcut для $this->access->isRole() ) + * + * @param string|array $roles + * @return bool + */ + protected function isRole($roles): bool + { + return $this->access->isRole($roles); } public function renderTwig($template, $data = []) @@ -55,6 +84,9 @@ abstract class BaseController extends Controller $oldInput = $this->session->get('_ci_old_input') ?? []; $data['old'] = $data['old'] ?? $oldInput; + // Добавляем access в данные шаблона для функций can(), isRole() и т.д. + $data['access'] = $this->access; + ob_start(); $twig->display($template, $data); $content = ob_get_clean(); @@ -129,19 +161,39 @@ abstract class BaseController extends Controller } $model = $config['model']; - $builder = $model->builder(); - - // Сбрасываем все предыдущие условия - $builder->resetQuery(); + // Если есть кастомный scope - создаём новый чистый запрос + // scope будет полностью контролировать FROM, JOIN, SELECT if (isset($config['scope']) && is_callable($config['scope'])) { + $builder = $model->db()->newQuery(); $config['scope']($builder); + } else { + // Стандартный путь - используем builder модели + $builder = $model->builder(); + $builder->resetQuery(); + + // Автоматическая фильтрация по организации для моделей с TenantScopedModel + $modelClass = get_class($model); + $traits = class_uses($modelClass); + if (in_array('App\Models\Traits\TenantScopedModel', $traits)) { + $model->forCurrentOrg(); + } } // Применяем фильтры - foreach ($filters as $field => $value) { - if ($value !== '' && in_array($field, $config['searchable'])) { - $builder->like($field, $value); + foreach ($filters as $filterKey => $value) { + if ($value === '') { + continue; + } + + // Сначала проверяем fieldMap (алиасы) — они имеют приоритет + if (isset($config['fieldMap']) && isset($config['fieldMap'][$filterKey])) { + $realField = $config['fieldMap'][$filterKey]; + $builder->like($realField, $value); + } + // Потом проверяем прямое совпадение + elseif (in_array($filterKey, $config['searchable'])) { + $builder->like($filterKey, $value); } } @@ -150,13 +202,11 @@ abstract class BaseController extends Controller $builder->orderBy($sort, $order); } - // Исправлено: countAllResults(false) вместо countAll() // Сохраняем текущее состояние builder для подсчета $countBuilder = clone $builder; $total = $countBuilder->countAllResults(false); - // Получаем данные с пагинацией - $builder->select('*'); + // Получаем данные с пагинацией (scope уже установил нужный SELECT) $items = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray(); $from = ($page - 1) * $perPage + 1; @@ -180,6 +230,8 @@ abstract class BaseController extends Controller 'filters' => $filters, 'columns' => $config['columns'], 'actionsConfig' => $config['actionsConfig'] ?? [], + 'can_edit' => $config['can_edit'] ?? true, + 'can_delete' => $config['can_delete'] ?? true, ]; return $data; @@ -221,12 +273,42 @@ abstract class BaseController extends Controller } /** - * AJAX endpoint для таблицы - возвращает partial (tbody + tfoot) - * Если запрос не AJAX - возвращает полную таблицу + * AJAX endpoint для таблицы + * + * Логика: + * - Если format=partial или AJAX → возвращает только tbody + tfoot + * - Если прямой GET → редиректит на основную страницу с теми же параметрами + * + * @param array|null $config Кастомная конфигурация таблицы (если null, используется getTableConfig()) + * @param string|null $pageUrl URL основной страницы таблицы (для редиректа) + * @return string|ResponseInterface */ - public function table() + public function table(?array $config = null, ?string $pageUrl = null) { $isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax(); - return $this->renderTable(null, $isPartial); + + // Если это частичный запрос (AJAX) — возвращаем только таблицу + if ($isPartial) { + return $this->renderTable($config, true); + } + + // Прямой запрос к /table — редиректим на основную страницу + // Сохраняем все параметры: page, perPage, sort, order, filters + $params = $this->request->getGet(); + unset($params['format']); // Убираем format=partial если был + + if ($pageUrl) { + $redirectUrl = $pageUrl; + } else { + // Пытаемся извлечь URL из config['url'] + $tableUrl = $config['url'] ?? '/table'; + $redirectUrl = $tableUrl; + } + + if (!empty($params)) { + $redirectUrl .= '?' . http_build_query($params); + } + + return redirect()->to($redirectUrl); } } diff --git a/app/Controllers/InvitationController.php b/app/Controllers/InvitationController.php new file mode 100644 index 0000000..f3f2ac6 --- /dev/null +++ b/app/Controllers/InvitationController.php @@ -0,0 +1,267 @@ +invitationService = new InvitationService(); + } + + /** + * Страница принятия/отклонения приглашения + */ + public function accept(string $token) + { + $invitation = $this->invitationService->orgUserModel->findByInviteToken($token); + + if (!$invitation) { + return $this->renderTwig('organizations/invitation_expired', [ + 'title' => 'Приглашение недействительно', + ]); + } + + // Получаем данные организации + $orgModel = new OrganizationModel(); + $organization = $orgModel->find($invitation['organization_id']); + + // Получаем данные приглашающего + $invitedByUser = null; + if ($invitation['invited_by']) { + $userModel = new UserModel(); + $invitedByUser = $userModel->find($invitation['invited_by']); + } + + // Определяем, авторизован ли пользователь + $currentUserId = session()->get('user_id'); + $isLoggedIn = !empty($currentUserId); + + // Если пользователь авторизован - проверяем, тот ли это email + $emailMatches = true; + if ($isLoggedIn && $invitation['user_id']) { + $currentUser = (new UserModel())->find($currentUserId); + // Проверяем, тот ли пользователь (по ID или email) + $emailMatches = ($currentUserId == $invitation['user_id']); + } + + // Метка роли + $roleLabels = [ + 'owner' => 'Владелец', + 'admin' => 'Администратор', + 'manager' => 'Менеджер', + 'guest' => 'Гость', + ]; + + return $this->renderTwig('organizations/invitation_accept', [ + 'title' => 'Приглашение в ' . ($organization['name'] ?? 'организацию'), + 'token' => $token, + 'organization' => $organization, + 'role' => $invitation['role'], + 'role_label' => $roleLabels[$invitation['role']] ?? $invitation['role'], + 'invited_by' => $invitedByUser, + 'invited_at' => $invitation['invited_at'], + 'is_logged_in' => $isLoggedIn, + 'email_matches' => $emailMatches, + 'current_user_id'=> $currentUserId, + ]); + } + + /** + * Обработка принятия приглашения + */ + public function processAccept() + { + $token = $this->request->getPost('token'); + $action = $this->request->getPost('action'); + + if ($action === 'decline') { + return $this->decline($token); + } + + $userId = session()->get('user_id'); + + // Если пользователь не авторизован - редирект на страницу создания пароля + if (!$userId) { + return redirect()->to('/invitation/complete/' . $token); + } + + // Принимаем приглашение + $result = $this->invitationService->acceptInvitation($token, $userId); + + if (!$result['success']) { + session()->setFlashdata('error', $result['message']); + return redirect()->to('/invitation/accept/' . $token); + } + + session()->setFlashdata('success', 'Вы приняли приглашение в организацию "' . $result['organization_name'] . '"'); + + // Переключаем организацию и редиректим на главную + session()->set('active_org_id', $result['organization_id']); + (new AccessService())->resetCache(); + + return redirect()->to('/'); + } + + /** + * Отклонение приглашения + */ + public function decline(string $token) + { + $result = $this->invitationService->declineInvitation($token); + + if (!$result['success']) { + session()->setFlashdata('error', $result['message']); + } else { + session()->setFlashdata('info', 'Приглашение отклонено'); + } + + return redirect()->to('/'); + } + + /** + * Страница завершения регистрации (для новых пользователей) + */ + public function complete(string $token) + { + $invitation = $this->invitationService->orgUserModel->findByInviteToken($token); + + if (!$invitation) { + return $this->renderTwig('organizations/invitation_expired', [ + 'title' => 'Приглашение недействительно', + ]); + } + + // Если пользователь уже авторизован и это его приглашение + $userId = session()->get('user_id'); + if ($userId && $invitation['user_id'] == $userId) { + return redirect()->to('/invitation/accept/' . $token); + } + + // Получаем данные организации + $orgModel = new OrganizationModel(); + $organization = $orgModel->find($invitation['organization_id']); + + // Метка роли + $roleLabels = [ + 'owner' => 'Владелец', + 'admin' => 'Администратор', + 'manager' => 'Менеджер', + 'guest' => 'Гость', + ]; + + return $this->renderTwig('organizations/invitation_complete', [ + 'title' => 'Завершение регистрации', + 'token' => $token, + 'email' => $invitation['user_id'] ? '' : '', // Email возьмем из теневой записи + 'organization' => $organization, + 'role' => $invitation['role'], + 'role_label' => $roleLabels[$invitation['role']] ?? $invitation['role'], + ]); + } + + /** + * Обработка завершения регистрации + */ + public function processComplete() + { + $token = $this->request->getPost('token'); + $name = $this->request->getPost('name'); + $password = $this->request->getPost('password'); + $passwordConfirm = $this->request->getPost('password_confirm'); + + // Валидация + $errors = []; + + if (empty($name) || strlen($name) < 2) { + $errors[] = 'Имя должно содержать минимум 2 символа'; + } + + if (empty($password) || strlen($password) < 8) { + $errors[] = 'Пароль должен содержать минимум 8 символов'; + } + + if ($password !== $passwordConfirm) { + $errors[] = 'Пароли не совпадают'; + } + + if (!empty($errors)) { + return redirect()->back()->withInput()->with('errors', $errors); + } + + $invitation = $this->invitationService->orgUserModel->findByInviteToken($token); + + if (!$invitation) { + return redirect()->to('/'); + } + + // Находим теневую запись пользователя + $userModel = new UserModel(); + + // Если user_id указан - обновляем существующего пользователя + if ($invitation['user_id']) { + $user = $userModel->find($invitation['user_id']); + if (!$user) { + session()->setFlashdata('error', 'Пользователь не найден'); + return redirect()->to('/invitation/complete/' . $token); + } + + // Обновляем профиль + $userModel->update($user['id'], [ + 'name' => $name, + 'password' => $password, + ]); + + $userId = $user['id']; + } else { + // Создаем нового пользователя (теневой + реальный) + // На самом деле у нас уже есть запись в users - нужно её "активировать" + $shadowUsers = $userModel->where('email', $userModel->getFindByEmail($invitation['organization_id']))->findAll(); + // Логика для теневых пользователей требует доработки + + session()->setFlashdata('error', 'Ошибка регистрации'); + return redirect()->to('/invitation/complete/' . $token); + } + + // Авторизуем пользователя + $user = $userModel->find($userId); + $this->loginUser($user); + + // Принимаем приглашение + $result = $this->invitationService->acceptInvitation($token, $userId); + + if (!$result['success']) { + session()->setFlashdata('error', $result['message']); + return redirect()->to('/'); + } + + session()->setFlashdata('success', 'Добро пожаловать! Вы успешно зарегистрировались и приняли приглашение.'); + + // Переключаем организацию + session()->set('active_org_id', $result['organization_id']); + (new AccessService())->resetCache(); + + return redirect()->to('/'); + } + + /** + * Авторизация пользователя после регистрации + */ + protected function loginUser(array $user): void + { + session()->set([ + 'user_id' => $user['id'], + 'email' => $user['email'], + 'name' => $user['name'], + 'logged_in' => true, + ]); + } +} diff --git a/app/Controllers/Organizations.php b/app/Controllers/Organizations.php index e11d61c..06ad05a 100644 --- a/app/Controllers/Organizations.php +++ b/app/Controllers/Organizations.php @@ -4,17 +4,25 @@ namespace App\Controllers; use App\Models\OrganizationModel; use App\Models\OrganizationUserModel; +use App\Models\UserModel; +use App\Services\AccessService; class Organizations extends BaseController { + protected OrganizationUserModel $orgUserModel; + + public function __construct() + { + $this->orgUserModel = new OrganizationUserModel(); + } + public function index() { $orgModel = new OrganizationModel(); - $orgUserModel = new OrganizationUserModel(); $userId = session()->get('user_id'); // Получаем организации пользователя через связующую таблицу - $userOrgLinks = $orgUserModel->where('user_id', $userId)->findAll(); + $userOrgLinks = $this->orgUserModel->where('user_id', $userId)->findAll(); // Нам нужно получить сами данные организаций $orgIds = array_column($userOrgLinks, 'organization_id'); @@ -24,12 +32,6 @@ class Organizations extends BaseController $organizations = $orgModel->whereIn('id', $orgIds)->findAll(); } - // Логика автоперехода (как в Auth) - // if (count($organizations) === 1) { - // session()->set('active_org_id', $organizations[0]['id']); - // return redirect()->to('/'); - // } - // Если больше 1 или 0, показываем список return $this->renderTwig('organizations/index', [ 'organizations' => $organizations, @@ -41,7 +43,6 @@ class Organizations extends BaseController { if ($this->request->getMethod() === 'POST') { $orgModel = new OrganizationModel(); - $orgUserModel = new OrganizationUserModel(); $rules = [ 'name' => 'required|min_length[2]', @@ -77,7 +78,7 @@ class Organizations extends BaseController ]); // Привязываем владельца - $orgUserModel->insert([ + $this->orgUserModel->insert([ 'organization_id' => $orgId, 'user_id' => session()->get('user_id'), 'role' => 'owner', @@ -96,30 +97,66 @@ class Organizations extends BaseController return $this->renderTwig('organizations/create'); } + /** + * Дашборд управления организацией + */ + public function dashboard($orgId) + { + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + // Получаем данные организации + $orgModel = new OrganizationModel(); + $organization = $orgModel->find($orgId); + + if (!$organization) { + return $this->redirectWithError('Организация не найдена', '/organizations'); + } + + // Получаем статистику организации + $stats = [ + 'users_total' => $this->orgUserModel->where('organization_id', $orgId)->countAllResults(), + 'users_active' => $this->orgUserModel->where('organization_id', $orgId)->where('status', 'active')->countAllResults(), + 'users_blocked' => $this->orgUserModel->where('organization_id', $orgId)->where('status', 'blocked')->countAllResults(), + ]; + + // Проверяем права для отображения пунктов меню + $canManageUsers = $this->access->canManageUsers(); + $canEditOrg = true; // Все члены организации могут видеть настройки (редактировать могут только admin+) + + return $this->renderTwig('organizations/dashboard', [ + 'organization' => $organization, + 'organization_id' => $orgId, + 'stats' => $stats, + 'current_role' => $membership['role'], + 'can_manage_users' => $canManageUsers, + 'can_edit_org' => $canEditOrg, + ]); + } + /** * Редактирование организации */ public function edit($orgId) { - $orgModel = new OrganizationModel(); - $orgUserModel = new OrganizationUserModel(); - $userId = session()->get('user_id'); - - // Проверяем: имеет ли пользователь доступ к этой организации? - $membership = $orgUserModel->where('organization_id', $orgId) - ->where('user_id', $userId) - ->first(); + // Проверяем доступ через AccessService + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); if (!$membership) { - session()->setFlashdata('error', 'Доступ запрещен'); - return redirect()->to('/organizations'); + return $this->redirectWithError('Доступ запрещен', '/organizations'); } - // Получаем организацию + // Проверяем права на редактирование (все роли могут редактировать) + $orgModel = new OrganizationModel(); $organization = $orgModel->find($orgId); + if (!$organization) { - session()->setFlashdata('error', 'Организация не найдена'); - return redirect()->to('/organizations'); + return $this->redirectWithError('Организация не найдена', '/organizations'); } // Декодируем requisites для формы @@ -173,37 +210,29 @@ class Organizations extends BaseController */ public function delete($orgId) { - $orgModel = new OrganizationModel(); - $orgUserModel = new OrganizationUserModel(); - $userId = session()->get('user_id'); - - // Проверяем: имеет ли пользователь доступ к этой организации? - $membership = $orgUserModel->where('organization_id', $orgId) - ->where('user_id', $userId) - ->first(); + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); if (!$membership) { - session()->setFlashdata('error', 'Доступ запрещен'); - return redirect()->to('/organizations'); + return $this->redirectWithError('Доступ запрещен', '/organizations'); } - // Проверяем, что пользователь — владелец - if ($membership['role'] !== 'owner') { - session()->setFlashdata('error', 'Только владелец может удалить организацию'); - return redirect()->to('/organizations'); + // Проверяем права: только владелец может удалить + if (!$this->access->canDeleteOrganization()) { + return $this->redirectWithError('Только владелец может удалить организацию', '/organizations'); } - // Получаем организацию + $orgModel = new OrganizationModel(); $organization = $orgModel->find($orgId); + if (!$organization) { - session()->setFlashdata('error', 'Организация не найдена'); - return redirect()->to('/organizations'); + return $this->redirectWithError('Организация не найдена', '/organizations'); } // Если это POST с подтверждением — удаляем if ($this->request->getMethod() === 'POST') { - // Удаляем связи с пользователями - $orgUserModel->where('organization_id', $orgId)->delete(); + // Удаляем связи с пользователями через forCurrentOrg() + $this->orgUserModel->forCurrentOrg()->delete(); // Мягкое удаление организации $orgModel->delete($orgId); @@ -226,14 +255,18 @@ class Organizations extends BaseController public function switch($orgId) { $userId = session()->get('user_id'); - $orgUserModel = new OrganizationUserModel(); + $orgId = (int) $orgId; - // Проверяем: имеет ли пользователь доступ к этой организации? - $membership = $orgUserModel->where('organization_id', $orgId) + // Проверяем доступ + $membership = $this->orgUserModel + ->where('organization_id', $orgId) ->where('user_id', $userId) ->first(); if ($membership) { + // Сбрасываем кэш AccessService при смене организации + $this->access->resetCache(); + session()->set('active_org_id', $orgId); session()->setFlashdata('success', 'Организация изменена'); return redirect()->to('/'); @@ -242,4 +275,525 @@ class Organizations extends BaseController return redirect()->to('/organizations'); } } + + // ======================================== + // Управление пользователями организации + // ======================================== + + /** + * Список пользователей организации + */ + public function users($orgId) + { + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + // Получаем организацию + $orgModel = new OrganizationModel(); + $organization = $orgModel->find($orgId); + + if (!$organization) { + return $this->redirectWithError('Организация не найдена', '/organizations'); + } + + // Проверяем права через единое место в AccessService (включает проверку типа организации) + if (!$this->access->canManageUsers()) { + return $this->redirectWithError('У вас нет прав для управления пользователями', '/organizations/' . $orgId . '/dashboard'); + } + + // Рендерим таблицу через универсальный компонент + $tableHtml = $this->renderTable($this->getUsersTableConfig($orgId)); + + // Получаем данные пользователей для статистики + $users = $this->orgUserModel->getOrganizationUsers($orgId); + + return $this->renderTwig('organizations/users', [ + 'organization' => $organization, + 'organization_id' => $orgId, + 'tableHtml' => $tableHtml, + 'users' => $users, + 'current_user_id' => session()->get('user_id'), + 'can_manage_users' => $this->access->canManageUsers(), + 'current_role' => $membership['role'], + ]); + } + + /** + * Конфигурация таблицы пользователей + */ + protected function getUsersTableConfig(int $orgId): array + { + // Проверяем права для кнопок действий + $canManage = $this->access->canManageUsers(); + + return [ + 'id' => 'users-table', + 'url' => '/organizations/' . $orgId . '/users/table', + 'model' => $this->orgUserModel, + 'columns' => [ + 'user_email' => [ + 'label' => 'Пользователь', + 'width' => '35%', + 'type' => 'user_display', + ], + 'role' => [ + 'label' => 'Роль', + 'width' => '15%', + 'type' => 'role_badge', + ], + 'status' => [ + 'label' => 'Статус', + 'width' => '15%', + 'type' => 'status_badge', + ], + 'joined_at' => [ + 'label' => 'Дата входа', + 'width' => '20%', + 'type' => 'datetime', + 'default' => '—', + ], + ], + 'searchable' => ['user_email', 'user_name'], + 'sortable' => ['joined_at', 'role', 'status'], + 'defaultSort' => 'joined_at', + 'order' => 'desc', + 'actions' => true, // Включаем колонку действий + 'actionsConfig' => [ + // Изменение роли + [ + 'label' => 'Изменить роль', + 'url' => '/organizations/users/' . $orgId . '/role/{user_id}', + 'icon' => 'fa-solid fa-user-gear', + 'class' => 'btn-outline-primary btn-sm', + 'type' => 'edit', + ], + // Блокировка + [ + 'label' => 'Заблокировать', + 'url' => '/organizations/users/' . $orgId . '/block/{user_id}', + 'icon' => 'fa-solid fa-ban', + 'class' => 'btn-outline-warning btn-sm', + 'type' => 'block', + ], + // Разблокировка + [ + 'label' => 'Разблокировать', + 'url' => '/organizations/users/' . $orgId . '/unblock/{user_id}', + 'icon' => 'fa-solid fa-check', + 'class' => 'btn-outline-success btn-sm', + 'type' => 'unblock', + ], + // Удаление + [ + 'label' => 'Удалить', + 'url' => '/organizations/users/' . $orgId . '/remove/{user_id}', + 'icon' => 'fa-solid fa-user-xmark', + 'class' => 'btn-outline-danger btn-sm', + 'type' => 'delete', + ], + ], + 'can_edit' => $canManage, + 'can_delete' => $canManage, + 'emptyMessage' => 'В организации пока нет участников', + 'emptyIcon' => 'fa-solid fa-users', + 'emptyActionUrl' => '', + 'emptyActionLabel'=> '', + 'emptyActionIcon' => '', + 'scope' => function ($builder) use ($orgId) { + $builder->select('ou.*, u.email as user_email, u.name as user_name, u.avatar as user_avatar') + ->from('organization_users ou') + ->join('users u', 'u.id = ou.user_id', 'left') + ->where('ou.organization_id', $orgId); + }, + // Ключи фильтров (совпадают с ключами columns) + 'searchable' => ['user_email', 'user_name'], + // Маппинг ключей фильтров к реальным колонкам БД + 'fieldMap' => [ + 'user_email' => 'u.email', + 'user_name' => 'u.name', + ], + ]; + } + + /** + * Таблица пользователей (AJAX) + */ + public function usersTable($orgId) + { + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->forbiddenResponse('Доступ запрещён'); + } + + // Проверяем права через единое место в AccessService (включает проверку типа организации) + if (!$this->access->canManageUsers()) { + return $this->forbiddenResponse('Управление пользователями недоступно'); + } + + return $this->table( + $this->getUsersTableConfig($orgId), + '/organizations/' . $orgId . '/users' // URL для редиректа + ); + } + + /** + * Приглашение пользователя (AJAX) + */ + public function inviteUser($orgId) + { + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Доступ запрещен', + ]); + } + + // Проверяем права на управление пользователями + if (!$this->access->canManageUsers()) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'У вас нет прав для приглашения пользователей', + ]); + } + + if (!$this->request->isAJAX()) { + return redirect()->to("/organizations/users/{$orgId}"); + } + + $email = $this->request->getPost('email'); + $role = $this->request->getPost('role'); + + // Валидация + if (empty($email) || empty($role)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Email и роль обязательны', + ]); + } + + // Проверяем валидность роли + $availableRoles = $this->access->getAvailableRolesForAssignment($membership['role']); + if (!in_array($role, $availableRoles)) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Недопустимая роль', + ]); + } + + // Создаем приглашение через сервис + $invitationService = new \App\Services\InvitationService(); + $result = $invitationService->createInvitation( + $orgId, + $email, + $role, + session()->get('user_id') + ); + + return $this->response->setJSON($result); + } + + /** + * Блокировка пользователя + */ + public function blockUser($orgId, $userId) + { + $orgId = (int) $orgId; + $userId = (int) $userId; + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + if (!$this->access->canManageUsers()) { + return $this->redirectWithError('У вас нет прав для блокировки', "/organizations/users/{$orgId}"); + } + + // Нельзя заблокировать владельца + $targetMembership = $this->orgUserModel + ->where('organization_id', $orgId) + ->where('user_id', $userId) + ->first(); + + if (!$targetMembership) { + return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}"); + } + + if ($targetMembership['role'] === 'owner') { + return $this->redirectWithError('Нельзя заблокировать владельца', "/organizations/users/{$orgId}"); + } + + $this->orgUserModel->blockUser($targetMembership['id']); + + session()->setFlashdata('success', 'Пользователь заблокирован'); + return redirect()->to("/organizations/users/{$orgId}"); + } + + /** + * Разблокировка пользователя + */ + public function unblockUser($orgId, $userId) + { + $orgId = (int) $orgId; + $userId = (int) $userId; + $membership = $this->getMembership($orgId); + + if (!$membership || !$this->access->canManageUsers()) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + $targetMembership = $this->orgUserModel + ->where('organization_id', $orgId) + ->where('user_id', $userId) + ->first(); + + if (!$targetMembership) { + return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}"); + } + + $this->orgUserModel->unblockUser($targetMembership['id']); + + session()->setFlashdata('success', 'Пользователь разблокирован'); + return redirect()->to("/organizations/users/{$orgId}"); + } + + /** + * Выход пользователя из организации + */ + public function leaveOrganization($orgId) + { + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->redirectWithError('Вы не состоите в этой организации', '/organizations'); + } + + // Владелец не может покинуть организацию + if ($membership['role'] === 'owner') { + return $this->redirectWithError('Владелец не может покинуть организацию. Передайте права другому администратору.', "/organizations/users/{$orgId}"); + } + + // Удаляем из организации + $this->orgUserModel->delete($membership['id']); + + // Если это была активная организация - переключаем на другую + if (session()->get('active_org_id') == $orgId) { + $userId = session()->get('user_id'); + $otherOrgs = $this->orgUserModel->where('user_id', $userId)->where('status', 'active')->findAll(); + + if (!empty($otherOrgs)) { + session()->set('active_org_id', $otherOrgs[0]['organization_id']); + } else { + session()->remove('active_org_id'); + } + } + + session()->setFlashdata('success', 'Вы покинули организацию'); + return redirect()->to('/organizations'); + } + + /** + * Повторная отправка приглашения + */ + public function resendInvite($orgId, $invitationId) + { + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); + + if (!$membership || !$this->access->canManageUsers()) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + $invitationService = new \App\Services\InvitationService(); + $result = $invitationService->resendInvitation($invitationId, $orgId); + + if ($result['success']) { + session()->setFlashdata('success', 'Приглашение отправлено повторно'); + } else { + session()->setFlashdata('error', $result['message']); + } + + return redirect()->to("/organizations/users/{$orgId}"); + } + + /** + * Отзыв приглашения + */ + public function cancelInvite($orgId, $invitationId) + { + $orgId = (int) $orgId; + $membership = $this->getMembership($orgId); + + if (!$membership || !$this->access->canManageUsers()) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + $invitationService = new \App\Services\InvitationService(); + $result = $invitationService->cancelInvitation($invitationId, $orgId); + + if ($result['success']) { + session()->setFlashdata('success', 'Приглашение отозвано'); + } else { + session()->setFlashdata('error', $result['message']); + } + + return redirect()->to("/organizations/users/{$orgId}"); + } + + /** + * Изменение роли пользователя + */ + public function updateUserRole($orgId, $userId) + { + $orgId = (int) $orgId; + $userId = (int) $userId; + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + // Проверяем права + if (!$this->access->canManageUsers()) { + return $this->redirectWithError('У вас нет прав для изменения ролей', "/organizations/users/{$orgId}"); + } + + // Нельзя изменить роль владельца + $targetMembership = $this->orgUserModel + ->where('organization_id', $orgId) + ->where('user_id', $userId) + ->first(); + + if (!$targetMembership) { + return $this->redirectWithError('Пользователь не найден в организации', "/organizations/users/{$orgId}"); + } + + if ($targetMembership['role'] === 'owner') { + return $this->redirectWithError('Нельзя изменить роль владельца', "/organizations/users/{$orgId}"); + } + + if ($this->request->getMethod() === 'POST') { + $newRole = $this->request->getPost('role'); + + // Проверяем валидность роли + $availableRoles = $this->access->getAvailableRolesForAssignment($membership['role']); + if (!in_array($newRole, $availableRoles)) { + return redirect()->back()->withInput()->with('error', 'Недопустимая роль'); + } + + $this->orgUserModel->update($targetMembership['id'], [ + 'role' => $newRole, + ]); + + session()->setFlashdata('success', 'Роль изменена'); + return redirect()->to("/organizations/users/{$orgId}"); + } + + $userModel = new UserModel(); + $user = $userModel->find($userId); + + return $this->renderTwig('organizations/edit_user_role', [ + 'organization_id' => $orgId, + 'user' => $user, + 'current_role' => $targetMembership['role'], + 'available_roles' => $availableRoles, + ]); + } + + /** + * Удаление пользователя из организации + */ + public function removeUser($orgId, $userId) + { + $orgId = (int) $orgId; + $userId = (int) $userId; + $currentUserId = session()->get('user_id'); + $membership = $this->getMembership($orgId); + + if (!$membership) { + return $this->redirectWithError('Доступ запрещен', '/organizations'); + } + + // Проверяем права + if (!$this->access->canManageUsers()) { + return $this->redirectWithError('У вас нет прав для удаления пользователей', "/organizations/users/{$orgId}"); + } + + // Нельзя удалить владельца + $targetMembership = $this->orgUserModel + ->where('organization_id', $orgId) + ->where('user_id', $userId) + ->first(); + + if (!$targetMembership) { + return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}"); + } + + if ($targetMembership['role'] === 'owner') { + return $this->redirectWithError('Нельзя удалить владельца организации', "/organizations/users/{$orgId}"); + } + + // Нельзя удалить самого себя (если ты admin) + if ($userId === $currentUserId) { + return $this->redirectWithError('Нельзя удалить себя из организации', "/organizations/users/{$orgId}"); + } + + // Удаляем пользователя из организации + $this->orgUserModel->delete($targetMembership['id']); + + session()->setFlashdata('success', 'Пользователь удалён из организации'); + return redirect()->to("/organizations/users/{$orgId}"); + } + + // ======================================== + // Вспомогательные методы + // ======================================== + + /** + * Получение membership пользователя для организации + * + * @param int $orgId + * @return array|null + */ + protected function getMembership(int $orgId): ?array + { + $userId = session()->get('user_id'); + if (!$userId || !$orgId) { + return null; + } + + return $this->orgUserModel + ->where('organization_id', $orgId) + ->where('user_id', $userId) + ->first(); + } + + /** + * Редирект с сообщением об ошибке + * + * @param string $message + * @param string $redirectUrl + * @return ResponseInterface + */ + protected function redirectWithError(string $message, string $redirectUrl) + { + if ($this->request->isAJAX()) { + return service('response') + ->setStatusCode(403) + ->setJSON(['error' => $message]); + } + + session()->setFlashdata('error', $message); + return redirect()->to($redirectUrl); + } } diff --git a/app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php b/app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php new file mode 100644 index 0000000..fecfb92 --- /dev/null +++ b/app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php @@ -0,0 +1,60 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 64, + 'null' => true, + 'after' => 'role', + 'comment' => 'Токен для принятия приглашения', + ], + 'invited_by' => [ + 'type' => 'INT', + 'unsigned' => true, + 'null' => true, + 'after' => 'invite_token', + 'comment' => 'ID пользователя, который отправил приглашение', + ], + 'invited_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'invited_by', + 'comment' => 'Дата отправки приглашения', + ], + 'status' => [ + 'type' => 'ENUM', + 'values' => ['active', 'pending', 'blocked'], + 'default' => 'pending', + 'after' => 'invited_at', + 'comment' => 'Статус участия в организации', + ], + ]; + + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN status ENUM('active', 'pending', 'blocked') DEFAULT 'pending' AFTER invited_at"); + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invite_token VARCHAR(64) NULL AFTER role"); + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_by INT UNSIGNED NULL AFTER invite_token"); + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_at DATETIME NULL AFTER invited_by"); + + // Индекс для быстрого поиска по токену + $this->db->simpleQuery("CREATE INDEX idx_org_users_token ON organization_users(invite_token)"); + } + + public function down() + { + // Удаление полей и индекса при откате миграции + $this->db->simpleQuery("DROP INDEX idx_org_users_token ON organization_users"); + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_at"); + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_by"); + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invite_token"); + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN status"); + } +} diff --git a/app/Filters/RoleFilter.php b/app/Filters/RoleFilter.php new file mode 100644 index 0000000..f82ca70 --- /dev/null +++ b/app/Filters/RoleFilter.php @@ -0,0 +1,100 @@ +get('/admin/users', 'Users::index', ['filter' => 'role:admin']); + * $routes->get('/admin/settings', 'Settings::index', ['filter' => 'role:owner']); + */ +class RoleFilter implements FilterInterface +{ + /** + * Проверка доступа перед выполнением запроса + * + * @param RequestInterface $request + * @param array|null $arguments Аргументы из маршрута (роли, разрешения) + * @return ResponseInterface|null + */ + public function before(RequestInterface $request, $arguments = null) + { + // Если фильтр вызван без аргументов - пропускаем + if ($arguments === null) { + return null; + } + + $access = AccessService::getInstance(); + + // Проверка авторизации в организации + if (!$access->isAuthenticated()) { + // Если пользователь не авторизован в организации - редирект на выбор организации + return redirect()->to('/organizations'); + } + + // Разбор аргументов + // Формат: '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->isRole($roles)) { + return $this->forbiddenResponse(); + } + } + + if (is_string($arguments) && str_starts_with($arguments, 'permission:')) { + $parts = explode(':', substr($arguments, 11)); + if (count($parts) >= 2) { + $permission = $parts[0]; + $resource = $parts[1] ?? '*'; + + if (!$access->can($permission, $resource)) { + return $this->forbiddenResponse(); + } + } + } + + return null; + } + + /** + * Обработка после выполнения запроса + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @param array|null $arguments + * @return void + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + // Ничего не делаем после + } + + /** + * Возврат ответа "Доступ запрещён" + * + * @return ResponseInterface + */ + private function forbiddenResponse(): ResponseInterface + { + // Проверяем, AJAX ли это запрос + if (service('request')->isAJAX()) { + return service('response') + ->setStatusCode(403) + ->setJSON(['error' => 'Доступ запрещён']); + } + + // Для обычных запросов - редирект с сообщением + session()->setFlashdata('error', 'У вас нет прав для выполнения этого действия'); + return redirect()->to('/'); + } +} diff --git a/app/Helpers/access_helper.php b/app/Helpers/access_helper.php new file mode 100644 index 0000000..f1020a4 --- /dev/null +++ b/app/Helpers/access_helper.php @@ -0,0 +1,244 @@ +can($action, $resource); + } +} + +/** + * Проверка права на просмотр + * + * @param string $resource + * @return bool + */ +if (!function_exists('canView')) { + function canView(string $resource): bool + { + return can('view', $resource); + } +} + +/** + * Проверка права на создание + * + * @param string $resource + * @return bool + */ +if (!function_exists('canCreate')) { + function canCreate(string $resource): bool + { + return can('create', $resource); + } +} + +/** + * Проверка права на редактирование + * + * @param string $resource + * @return bool + */ +if (!function_exists('canEdit')) { + function canEdit(string $resource): bool + { + return can('edit', $resource); + } +} + +/** + * Проверка права на удаление + * + * @param string $resource + * @param bool $any + * @return bool + */ +if (!function_exists('canDelete')) { + function canDelete(string $resource, bool $any = false): bool + { + return can('delete', $resource) || ($any && can('delete_any', $resource)); + } +} + +/** + * Проверка роли пользователя + * + * @param string|array $roles + * @return bool + */ +if (!function_exists('isRole')) { + function isRole($roles): bool + { + $access = service('access'); + return $access->isRole($roles); + } +} + +/** + * Проверка, является ли пользователь владельцем + * + * @return bool + */ +if (!function_exists('isOwner')) { + function isOwner(): bool + { + return isRole(\App\Services\AccessService::ROLE_OWNER); + } +} + +/** + * Проверка, является ли пользователем администратором + * + * @return bool + */ +if (!function_exists('isAdmin')) { + function isAdmin(): bool + { + return isRole(\App\Services\AccessService::ROLE_ADMIN); + } +} + +/** + * Проверка, является ли пользователем менеджером или выше + * + * @return bool + */ +if (!function_exists('isManager')) { + function isManager(): bool + { + $access = service('access'); + return $access->isManagerOrHigher(); + } +} + +/** + * Проверка, может ли управлять пользователями + * + * @return bool + */ +if (!function_exists('canManageUsers')) { + function canManageUsers(): bool + { + return can('manage_users', 'users'); + } +} + +/** + * Проверка, может ли управлять модулями + * + * @return bool + */ +if (!function_exists('canManageModules')) { + function canManageModules(): bool + { + return can('manage_modules', 'modules'); + } +} + +/** + * Получение текстового названия роли + * + * @param string $role + * @return string + */ +if (!function_exists('roleLabel')) { + function roleLabel(string $role): string + { + $access = service('access'); + return $access->getRoleLabel($role); + } +} + +/** + * Получение роли текущего пользователя + * + * @return string|null + */ +if (!function_exists('currentRole')) { + function currentRole(): ?string + { + $access = service('access'); + return $access->getCurrentRole(); + } +} + +/** + * Проверка, авторизован ли пользователь в организации + * + * @return bool + */ +if (!function_exists('isAuthenticatedInOrg')) { + function isAuthenticatedInOrg(): bool + { + $access = service('access'); + return $access->isAuthenticated(); + } +} + +/** + * Получение списка доступных ролей для назначения + * + * @return array + */ +if (!function_exists('availableRolesForAssignment')) { + function availableRolesForAssignment(): array + { + $currentRole = currentRole(); + if (!$currentRole) { + return []; + } + + $access = service('access'); + $roles = $access->getAvailableRolesForAssignment($currentRole); + + $result = []; + foreach ($roles as $role) { + $result[$role] = roleLabel($role); + } + + return $result; + } +} + +/** + * Проверка, показывать ли кнопку действия + * Скрывает кнопку для пользователей без прав + * + * @param string $action + * @param string $resource + * @param bool $showForOwnerAdmin + * @return bool + */ +if (!function_exists('showAction')) { + function showAction(string $action, string $resource, bool $showForOwnerAdmin = true): bool + { + // Если это просмотр - показываем всегда для авторизованных + if ($action === 'view') { + return isAuthenticatedInOrg(); + } + + // Если пользователь - владелец или админ, показываем всё + if ($showForOwnerAdmin && isManager()) { + return true; + } + + // Иначе проверяем конкретное право + return can($action, $resource); + } +} diff --git a/app/Libraries/Twig/TwigGlobalsExtension.php b/app/Libraries/Twig/TwigGlobalsExtension.php index f0e3966..56ed299 100644 --- a/app/Libraries/Twig/TwigGlobalsExtension.php +++ b/app/Libraries/Twig/TwigGlobalsExtension.php @@ -21,9 +21,145 @@ class TwigGlobalsExtension extends AbstractExtension new TwigFunction('get_current_route', [$this, 'getCurrentRoute'], ['is_safe' => ['html']]), new TwigFunction('render_actions', [$this, 'renderActions'], ['is_safe' => ['html']]), new TwigFunction('render_cell', [$this, 'renderCell'], ['is_safe' => ['html']]), + + // Access functions + new TwigFunction('can', [$this, 'can'], ['is_safe' => ['html']]), + new TwigFunction('is_role', [$this, 'isRole'], ['is_safe' => ['html']]), + new TwigFunction('is_owner', [$this, 'isOwner'], ['is_safe' => ['html']]), + new TwigFunction('is_admin', [$this, 'isAdmin'], ['is_safe' => ['html']]), + new TwigFunction('is_manager', [$this, 'isManager'], ['is_safe' => ['html']]), + new TwigFunction('current_role', [$this, 'currentRole'], ['is_safe' => ['html']]), + new TwigFunction('role_label', [$this, 'roleLabel'], ['is_safe' => ['html']]), + new TwigFunction('can_view', [$this, 'canView'], ['is_safe' => ['html']]), + new TwigFunction('can_create', [$this, 'canCreate'], ['is_safe' => ['html']]), + new TwigFunction('can_edit', [$this, 'canEdit'], ['is_safe' => ['html']]), + new TwigFunction('can_delete', [$this, 'canDelete'], ['is_safe' => ['html']]), + new TwigFunction('can_manage_users', [$this, 'canManageUsers'], ['is_safe' => ['html']]), + + // Role & Status badge functions + 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']]), ]; } + // ======================================== + // Access Functions для Twig + // ======================================== + + public function can(string $action, string $resource): bool + { + return service('access')->can($action, $resource); + } + + public function isRole($roles): bool + { + return service('access')->isRole($roles); + } + + public function isOwner(): bool + { + return service('access')->isRole(\App\Services\AccessService::ROLE_OWNER); + } + + public function isAdmin(): bool + { + $role = service('access')->getCurrentRole(); + return $role === \App\Services\AccessService::ROLE_ADMIN + || $role === \App\Services\AccessService::ROLE_OWNER; + } + + public function isManager(): bool + { + return service('access')->isManagerOrHigher(); + } + + public function currentRole(): ?string + { + return service('access')->getCurrentRole(); + } + + public function roleLabel(string $role): string + { + return service('access')->getRoleLabel($role); + } + + public function canView(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_VIEW, $resource); + } + + public function canCreate(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_CREATE, $resource); + } + + public function canEdit(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_EDIT, $resource); + } + + public function canDelete(string $resource): bool + { + return service('access')->can(\App\Services\AccessService::PERMISSION_DELETE, $resource); + } + + public function canManageUsers(): bool + { + return service('access')->canManageUsers(); + } + + // ======================================== + // Role & Status Badge Functions + // ======================================== + + public function roleBadge(string $role): string + { + $colors = [ + 'owner' => 'bg-primary', + 'admin' => 'bg-info', + 'manager' => 'bg-success', + 'guest' => 'bg-secondary', + ]; + + $labels = [ + 'owner' => 'Владелец', + 'admin' => 'Администратор', + 'manager' => 'Менеджер', + 'guest' => 'Гость', + ]; + + $color = $colors[$role] ?? 'bg-secondary'; + $label = $labels[$role] ?? $role; + + return '' . esc($label) . ''; + } + + public function statusBadge(string $status): string + { + $colors = [ + 'active' => 'bg-success', + 'pending' => 'bg-warning text-dark', + 'blocked' => 'bg-danger', + ]; + + $labels = [ + 'active' => 'Активен', + 'pending' => 'Ожидает', + 'blocked' => 'Заблокирован', + ]; + + $color = $colors[$status] ?? 'bg-secondary'; + $label = $labels[$status] ?? $status; + + return '' . esc($label) . ''; + } + + public function getAllRoles(): array + { + return \App\Services\AccessService::getAllRoles(); + } + public function getSession() { return session(); @@ -137,7 +273,6 @@ class TwigGlobalsExtension extends AbstractExtension // DEBUG: логируем для отладки log_message('debug', 'renderActions: item type = ' . gettype($item)); log_message('debug', 'renderActions: item keys = ' . (is_array($itemArray) ? implode(', ', array_keys($itemArray)) : 'N/A')); - log_message('debug', 'renderActions: item = ' . print_r($itemArray, true)); $html = '
'; @@ -148,11 +283,26 @@ class TwigGlobalsExtension extends AbstractExtension $class = $action['class'] ?? 'btn-outline-secondary'; $title = $action['title'] ?? $label; $target = $action['target'] ?? ''; + $type = $action['type'] ?? ''; + + // Проверяем условия для показа кнопки + // Владелец не может быть изменён, заблокирован или удалён + if ($itemArray['role'] ?? '' === 'owner') { + continue; + } + + // Блокировка - только для активных пользователей + if ($type === 'block' && ($itemArray['status'] ?? '') !== 'active') { + continue; + } + + // Разблокировка - только для заблокированных + if ($type === 'unblock' && ($itemArray['status'] ?? '') !== 'blocked') { + continue; + } // Подставляем значения из item в URL $url = $this->interpolate($urlPattern, $itemArray); - - log_message('debug', 'renderActions: urlPattern = ' . $urlPattern . ', url = ' . $url); // Формируем HTML кнопки/ссылки $iconHtml = $icon ? ' ' : ''; @@ -251,6 +401,22 @@ class TwigGlobalsExtension extends AbstractExtension } return 'Нет'; + case 'role_badge': + return $this->roleBadge((string) $value); + + case 'status_badge': + return $this->statusBadge((string) $value); + + case 'user_display': + // Отображение пользователя с аватаром и именем/email + $name = $itemArray['user_name'] ?? ''; + $email = $itemArray['user_email'] ?? $value; + $avatar = $itemArray['user_avatar'] ?? ''; + $avatarHtml = $avatar + ? '' + : '
'; + return '
' . $avatarHtml . '
' . esc($name ?: $email) . '
' . ($name ? '
' . esc($email) . '
' : '') . '
'; + case 'uppercase': return $value ? esc(strtoupper($value)) : ($config['default'] ?? ''); diff --git a/app/Models/OrganizationUserModel.php b/app/Models/OrganizationUserModel.php index 95eee60..e26d3d7 100644 --- a/app/Models/OrganizationUserModel.php +++ b/app/Models/OrganizationUserModel.php @@ -3,14 +3,148 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Traits\TenantScopedModel; class OrganizationUserModel extends Model { + use TenantScopedModel; + protected $table = 'organization_users'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; protected $returnType = 'array'; - protected $allowedFields = ['organization_id', 'user_id', 'role', 'status', 'joined_at']; - - protected $useTimestamps = false; // У нас есть только created_at, можно настроить вручную -} \ No newline at end of file + protected $allowedFields = [ + 'organization_id', + 'user_id', + 'role', + 'status', + 'invite_token', + 'invited_by', + 'invited_at', + 'joined_at', + ]; + + protected $useTimestamps = false; + + // Константы статусов + public const STATUS_ACTIVE = 'active'; + public const STATUS_PENDING = 'pending'; + public const STATUS_BLOCKED = 'blocked'; + + // Роли + public const ROLE_OWNER = 'owner'; + public const ROLE_ADMIN = 'admin'; + public const ROLE_MANAGER = 'manager'; + public const ROLE_GUEST = 'guest'; + + /** + * Поиск приглашения по токену + */ + public function findByInviteToken(string $token): ?array + { + return $this->where('invite_token', $token) + ->where('status', self::STATUS_PENDING) + ->first(); + } + + /** + * Получение списка пользователей организации с данными из users + */ + public function getOrganizationUsers(int $organizationId): array + { + // Создаём новый чистый запрос с нуля + $db = $this->db(); + $builder = $db->newQuery(); + + return $builder->select('ou.*, u.name as user_name, u.email as user_email, u.avatar as user_avatar') + ->from('organization_users ou') + ->join('users u', 'u.id = ou.user_id', 'left') + ->where('ou.organization_id', $organizationId) + ->orderBy('ou.joined_at', 'DESC') + ->get() + ->getResultArray(); + } + + /** + * Проверка, существует ли активное приглашение для пользователя + */ + public function hasPendingInvite(int $organizationId, int $userId): bool + { + return $this->where('organization_id', $organizationId) + ->where('user_id', $userId) + ->where('status', self::STATUS_PENDING) + ->countAllResults() > 0; + } + + /** + * Получение всех приглашений организации + */ + public function getPendingInvites(int $organizationId): array + { + return $this->where('organization_id', $organizationId) + ->where('status', self::STATUS_PENDING) + ->orderBy('invited_at', 'DESC') + ->findAll(); + } + + /** + * Создание приглашения + */ + public function createInvitation(array $data): int + { + $data['invited_at'] = date('Y-m-d H:i:s'); + return $this->insert($data); + } + + /** + * Принятие приглашения + */ + public function acceptInvitation(int $id, int $userId): bool + { + return $this->update($id, [ + 'status' => self::STATUS_ACTIVE, + 'invite_token' => null, + 'joined_at' => date('Y-m-d H:i:s'), + ]); + } + + /** + * Отклонение приглашения + */ + public function declineInvitation(int $id): bool + { + return $this->delete($id); + } + + /** + * Отзыв приглашения + */ + public function cancelInvitation(int $id): bool + { + return $this->delete($id); + } + + /** + * Изменение роли пользователя + */ + public function updateRole(int $id, string $role): bool + { + return $this->update($id, ['role' => $role]); + } + + /** + * Блокировка пользователя + */ + public function blockUser(int $id): bool + { + return $this->update($id, ['status' => self::STATUS_BLOCKED]); + } + + /** + * Разблокировка пользователя + */ + public function unblockUser(int $id): bool + { + return $this->update($id, ['status' => self::STATUS_ACTIVE]); + } +} diff --git a/app/Models/Traits/TenantScopedModel.php b/app/Models/Traits/TenantScopedModel.php new file mode 100644 index 0000000..72013f4 --- /dev/null +++ b/app/Models/Traits/TenantScopedModel.php @@ -0,0 +1,51 @@ +clientModel->forCurrentOrg()->findAll(); + * $client = $this->clientModel->forCurrentOrg()->find($id); + * + * @return $this + */ + public function forCurrentOrg() + { + $session = session(); + $orgId = $session->get('active_org_id'); + + // Если организации в сессии нет, предотвращаем утечку данных + // Возвращаем условие, которое всегда ложно + if (empty($orgId)) { + return $this->where('1=0'); + } + + // Имя поля с organization_id (можно переопределить в модели) + $field = $this->table . '.organization_id'; + + return $this->where($field, $orgId); + } + + /** + * Проверяет, что запись принадлежит текущей организации. + * + * @param int $id ID записи + * @return bool + */ + public function belongsToCurrentOrg(int $id): bool + { + return $this->forCurrentOrg()->find($id) !== null; + } +} diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php index 68630d3..b3865c3 100644 --- a/app/Models/UserModel.php +++ b/app/Models/UserModel.php @@ -3,9 +3,12 @@ namespace App\Models; use CodeIgniter\Model; +use App\Models\Traits\TenantScopedModel; class UserModel extends Model { + use TenantScopedModel; + protected $table = 'users'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; diff --git a/app/Modules/Clients/Controllers/Clients.php b/app/Modules/Clients/Controllers/Clients.php index 80c9484..31adf8b 100644 --- a/app/Modules/Clients/Controllers/Clients.php +++ b/app/Modules/Clients/Controllers/Clients.php @@ -4,10 +4,11 @@ namespace App\Modules\Clients\Controllers; use App\Controllers\BaseController; use App\Modules\Clients\Models\ClientModel; +use App\Services\AccessService; class Clients extends BaseController { - protected $clientModel; + protected ClientModel $clientModel; public function __construct() { @@ -16,11 +17,19 @@ class Clients extends BaseController public function index() { + // Проверка права на просмотр + if (!$this->access->canView('clients')) { + return $this->forbiddenResponse('У вас нет прав для просмотра клиентов'); + } + $config = $this->getTableConfig(); return $this->renderTwig('@Clients/index', [ 'title' => 'Клиенты', 'tableHtml' => $this->renderTable($config), + 'can_create' => $this->access->canCreate('clients'), + 'can_edit' => $this->access->canEdit('clients'), + 'can_delete' => $this->access->canDelete('clients'), ]); } @@ -29,8 +38,6 @@ class Clients extends BaseController */ protected function getTableConfig(): array { - $organizationId = session()->get('active_org_id'); - return [ 'id' => 'clients-table', 'url' => '/clients/table', @@ -51,14 +58,16 @@ class Clients extends BaseController 'url' => '/clients/edit/{id}', 'icon' => 'fa-solid fa-pen', 'class' => 'btn-outline-primary', - 'title' => 'Редактировать' + 'title' => 'Редактировать', + 'type' => 'edit', ], [ 'label' => '', 'url' => '/clients/delete/{id}', 'icon' => 'fa-solid fa-trash', 'class' => 'btn-outline-danger', - 'title' => 'Удалить' + 'title' => 'Удалить', + 'type' => 'delete', ] ], 'emptyMessage' => 'Клиентов пока нет', @@ -66,33 +75,28 @@ class Clients extends BaseController 'emptyActionUrl' => base_url('/clients/new'), 'emptyActionLabel'=> 'Добавить клиента', 'emptyActionIcon' => 'fa-solid fa-plus', - 'scope' => function ($builder) use ($organizationId) { - $builder->where('organization_id', $organizationId); - }, + 'can_edit' => $this->access->canEdit('clients'), + 'can_delete' => $this->access->canDelete('clients'), ]; } - public function table() + public function table(?array $config = null, ?string $pageUrl = null) { - $isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax(); - - if ($isPartial) { - // AJAX — только tbody + tfoot - return $this->renderTable(null, true); + // Проверка права на просмотр + if (!$this->access->canView('clients')) { + return $this->forbiddenResponse('У вас нет прав для просмотра клиентов'); } - - // Прямой запрос — полная страница - $config = $this->getTableConfig(); - $tableHtml = $this->renderTable($config, false); - - return $this->renderTwig('@Clients/index', [ - 'title' => $config['pageTitle'] ?? 'Клиенты', - 'tableHtml' => $tableHtml, - ]); + + return parent::table($config, '/clients'); } public function new() { + // Проверка права на создание + if (!$this->access->canCreate('clients')) { + return $this->forbiddenResponse('У вас нет прав для создания клиентов'); + } + $data = [ 'title' => 'Добавить клиента', 'client' => null, @@ -103,6 +107,11 @@ class Clients extends BaseController public function create() { + // Проверка права на создание + if (!$this->access->canCreate('clients')) { + return $this->forbiddenResponse('У вас нет прав для создания клиентов'); + } + $organizationId = session()->get('active_org_id'); $rules = [ @@ -133,12 +142,12 @@ class Clients extends BaseController public function edit($id) { - $organizationId = session()->get('active_org_id'); + // Проверка права на редактирование + if (!$this->access->canEdit('clients')) { + return $this->forbiddenResponse('У вас нет прав для редактирования клиентов'); + } - $client = $this->clientModel - ->where('id', $id) - ->where('organization_id', $organizationId) - ->first(); + $client = $this->clientModel->forCurrentOrg()->find($id); if (!$client) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); @@ -154,13 +163,13 @@ class Clients extends BaseController public function update($id) { - $organizationId = session()->get('active_org_id'); + // Проверка права на редактирование + if (!$this->access->canEdit('clients')) { + return $this->forbiddenResponse('У вас нет прав для редактирования клиентов'); + } - // Проверяем что клиент принадлежит организации - $client = $this->clientModel - ->where('id', $id) - ->where('organization_id', $organizationId) - ->first(); + // Проверяем что клиент принадлежит организации через forCurrentOrg() + $client = $this->clientModel->forCurrentOrg()->find($id); if (!$client) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); @@ -193,13 +202,13 @@ class Clients extends BaseController public function delete($id) { - $organizationId = session()->get('active_org_id'); + // Проверка права на удаление + if (!$this->access->canDelete('clients')) { + return $this->forbiddenResponse('У вас нет прав для удаления клиентов'); + } - // Проверяем что клиент принадлежит организации - $client = $this->clientModel - ->where('id', $id) - ->where('organization_id', $organizationId) - ->first(); + // Проверяем что клиент принадлежит организации через forCurrentOrg() + $client = $this->clientModel->forCurrentOrg()->find($id); if (!$client) { throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); @@ -210,4 +219,22 @@ class Clients extends BaseController session()->setFlashdata('success', 'Клиент удалён'); return redirect()->to('/clients'); } + + /** + * Возврат ответа "Доступ запрещён" + * + * @param string $message + * @return ResponseInterface + */ + protected function forbiddenResponse(string $message = 'Доступ запрещён') + { + if ($this->request->isAJAX()) { + return service('response') + ->setStatusCode(403) + ->setJSON(['error' => $message]); + } + + session()->setFlashdata('error', $message); + return redirect()->to('/'); + } } diff --git a/app/Modules/Clients/Models/ClientModel.php b/app/Modules/Clients/Models/ClientModel.php index 00e815d..73cbd64 100644 --- a/app/Modules/Clients/Models/ClientModel.php +++ b/app/Modules/Clients/Models/ClientModel.php @@ -3,9 +3,12 @@ namespace App\Modules\Clients\Models; use CodeIgniter\Model; +use App\Models\Traits\TenantScopedModel; class ClientModel extends Model { + use TenantScopedModel; + protected $table = 'organizations_clients'; protected $primaryKey = 'id'; protected $useAutoIncrement = true; diff --git a/app/Services/AccessService.php b/app/Services/AccessService.php new file mode 100644 index 0000000..f8ad866 --- /dev/null +++ b/app/Services/AccessService.php @@ -0,0 +1,462 @@ + 100, + self::ROLE_ADMIN => 75, + self::ROLE_MANAGER => 50, + self::ROLE_GUEST => 25, + ]; + + /** + * Права на действия + */ + public const PERMISSION_VIEW = 'view'; + public const PERMISSION_CREATE = 'create'; + public const PERMISSION_EDIT = 'edit'; + public const PERMISSION_DELETE = 'delete'; + public const PERMISSION_DELETE_ANY = 'delete_any'; + public const PERMISSION_MANAGE_USERS = 'manage_users'; + public const PERMISSION_MANAGE_MODULES = 'manage_modules'; + public const PERMISSION_VIEW_FINANCE = 'view_finance'; + public const PERMISSION_DELETE_ORG = 'delete_org'; + public const PERMISSION_TRANSFER_OWNER = 'transfer_owner'; + + /** + * Матрица прав по ролям + * Формат: [роль => [ресурс => [действия]]] + */ + private const ROLE_PERMISSIONS = [ + self::ROLE_OWNER => [ + '*' => ['*'], // Полный доступ ко всему + ], + self::ROLE_ADMIN => [ + 'clients' => ['view', 'create', 'edit', 'delete'], + 'deals' => ['view', 'create', 'edit', 'delete'], + 'bookings' => ['view', 'create', 'edit', 'delete'], + 'projects' => ['view', 'create', 'edit', 'delete'], + 'tasks' => ['view', 'create', 'edit', 'delete'], + 'users' => [self::PERMISSION_VIEW, self::PERMISSION_CREATE, self::PERMISSION_EDIT, self::PERMISSION_DELETE], + self::PERMISSION_MANAGE_MODULES => [self::PERMISSION_VIEW, self::PERMISSION_EDIT], + self::PERMISSION_VIEW_FINANCE => ['*'], + ], + self::ROLE_MANAGER => [ + 'clients' => ['view', 'create', 'edit', 'delete'], + 'deals' => ['view', 'create', 'edit', 'delete'], + 'bookings' => ['view', 'create', 'edit', 'delete'], + 'projects' => ['view', 'create', 'edit', 'delete'], + 'tasks' => ['view', 'create', 'edit', 'delete'], + 'users' => [self::PERMISSION_VIEW], // Только просмотр коллег + ], + self::ROLE_GUEST => [ + 'clients' => [self::PERMISSION_VIEW], + 'deals' => [self::PERMISSION_VIEW], + 'bookings' => [self::PERMISSION_VIEW], + 'projects' => [self::PERMISSION_VIEW], + 'tasks' => [self::PERMISSION_VIEW], + 'users' => [self::PERMISSION_VIEW], + ], + ]; + + public function __construct() + { + $this->orgUserModel = new OrganizationUserModel(); + } + + /** + * Получение единственного экземпляра сервиса + * + * @return self + */ + public static function getInstance(): self + { + return new self(); + } + + /** + * Получение текущего membership пользователя + * + * @return array|null + */ + public function getCurrentMembership(): ?array + { + if ($this->currentMembership !== null) { + return $this->currentMembership; + } + + $userId = session()->get('user_id'); + $orgId = session()->get('active_org_id'); + + if (!$userId || !$orgId) { + return null; + } + + $this->currentMembership = $this->orgUserModel + ->where('user_id', $userId) + ->where('organization_id', $orgId) + ->first(); + + return $this->currentMembership; + } + + /** + * Получение роли текущего пользователя + * + * @return string|null + */ + public function getCurrentRole(): ?string + { + $membership = $this->getCurrentMembership(); + return $membership['role'] ?? null; + } + + /** + * Проверка, авторизован ли пользователь в организации + * + * @return bool + */ + public function isAuthenticated(): bool + { + return $this->getCurrentMembership() !== null; + } + + /** + * Проверка роли пользователя + * + * @param string|array $roles Роль или массив ролей для проверки + * @return bool + */ + public function isRole($roles): bool + { + $currentRole = $this->getCurrentRole(); + if ($currentRole === null) { + return false; + } + + $roles = (array) $roles; + return in_array($currentRole, $roles, true); + } + + /** + * Проверка, является ли пользователь владельцем + * + * @return bool + */ + public function isOwner(): bool + { + return $this->getCurrentRole() === self::ROLE_OWNER; + } + + /** + * Проверка, является ли пользователем администратором + * + * @return bool + */ + public function isAdmin(): bool + { + $role = $this->getCurrentRole(); + return $role === self::ROLE_ADMIN || $role === self::ROLE_OWNER; + } + + /** + * Проверка, является ли пользователем менеджером или выше + * + * @return bool + */ + public function isManagerOrHigher(): bool + { + $role = $this->getCurrentRole(); + return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_MANAGER], true); + } + + /** + * Проверка права на действие + * + * @param string $action Действие (view, create, edit, delete, delete_any, manage_users, etc.) + * @param string $resource Ресурс (clients, deals, bookings, projects, tasks, users) + * @return bool + */ + public function can(string $action, string $resource): bool + { + $role = $this->getCurrentRole(); + if ($role === null) { + return false; + } + + $permissions = self::ROLE_PERMISSIONS[$role] ?? []; + + // Проверка полного доступа (*) + if (isset($permissions['*']) && in_array('*', $permissions['*'], true)) { + return true; + } + + // Проверка конкретного ресурса + if (!isset($permissions[$resource])) { + return false; + } + + $resourcePermissions = $permissions[$resource]; + + // Проверка полного доступа к ресурсу + if (in_array('*', $resourcePermissions, true)) { + return true; + } + + return in_array($action, $resourcePermissions, true); + } + + /** + * Проверка права на просмотр + * + * @param string $resource + * @return bool + */ + public function canView(string $resource): bool + { + return $this->can(self::PERMISSION_VIEW, $resource); + } + + /** + * Проверка права на создание + * + * @param string $resource + * @return bool + */ + public function canCreate(string $resource): bool + { + return $this->can(self::PERMISSION_CREATE, $resource); + } + + /** + * Проверка права на редактирование + * + * @param string $resource + * @return bool + */ + public function canEdit(string $resource): bool + { + return $this->can(self::PERMISSION_EDIT, $resource); + } + + /** + * Проверка права на удаление + * + * @param string $resource + * @param bool $any Удаление любой записи (не только своей) + * @return bool + */ + public function canDelete(string $resource, bool $any = false): bool + { + $action = $any ? self::PERMISSION_DELETE_ANY : self::PERMISSION_DELETE; + return $this->can($action, $resource); + } + + /** + * Проверка права на управление пользователями + * + * Управление пользователями недоступно для личных пространств. + * + * @return bool + */ + public function canManageUsers(): bool + { + // Проверяем, что это бизнес-организация (не личное пространство) + $orgId = session()->get('active_org_id'); + if (!$orgId) { + return false; + } + + $orgModel = new \App\Models\OrganizationModel(); + $organization = $orgModel->find($orgId); + + // Личное пространство - управление пользователями недоступно + if ($organization && $organization['type'] === 'personal') { + return false; + } + + return $this->can(self::PERMISSION_MANAGE_USERS, 'users'); + } + + /** + * Проверка права на управление модулями + * + * @return bool + */ + public function canManageModules(): bool + { + return $this->can(self::PERMISSION_MANAGE_MODULES, self::PERMISSION_MANAGE_MODULES); + } + + /** + * Проверка права на просмотр финансов + * + * @return bool + */ + public function canViewFinance(): bool + { + return $this->can(self::PERMISSION_VIEW_FINANCE, self::PERMISSION_VIEW_FINANCE); + } + + /** + * Проверка права на удаление организации + * + * @return bool + */ + public function canDeleteOrganization(): bool + { + return $this->isOwner(); + } + + /** + * Проверка права на передачу прав владельца + * + * @return bool + */ + public function canTransferOwnership(): bool + { + return $this->isOwner(); + } + + /** + * Получение роли для передачи прав владельца (список допустимых ролей) + * + * @return array + */ + public function getRolesEligibleForOwnershipTransfer(): array + { + // Только admin может стать новым владельцем + return [self::ROLE_ADMIN]; + } + + /** + * Получение уровня роли (для сравнения) + * + * @param string $role + * @return int + */ + public function getRoleLevel(string $role): int + { + return self::ROLE_HIERARCHY[$role] ?? 0; + } + + /** + * Проверка, имеет ли роль достаточно прав для сравнения + * + * @param string $role + * @param string $requiredRole + * @return bool + */ + public function hasRoleLevel(string $role, string $requiredRole): bool + { + return $this->getRoleLevel($role) >= $this->getRoleLevel($requiredRole); + } + + /** + * Получение списка доступных ролей для назначения + * + * @param string $assignerRole Роль назначающего + * @return array + */ + public function getAvailableRolesForAssignment(string $assignerRole): array + { + $allRoles = [self::ROLE_ADMIN, self::ROLE_MANAGER, self::ROLE_GUEST]; + + // Owner может назначать любые роли + if ($assignerRole === self::ROLE_OWNER) { + return $allRoles; + } + + // Admin не может назначать owner + if ($assignerRole === self::ROLE_ADMIN) { + return [self::ROLE_ADMIN, self::ROLE_MANAGER, self::ROLE_GUEST]; + } + + // Manager и Guest не могут назначать роли + return []; + } + + /** + * Сброс кэша membership (при переключении организации) + * + * @return void + */ + public function resetCache(): void + { + $this->currentMembership = null; + } + + /** + * Получение текстового названия роли + * + * @param string $role + * @return string + */ + public function getRoleLabel(string $role): string + { + $labels = [ + self::ROLE_OWNER => 'Владелец', + self::ROLE_ADMIN => 'Администратор', + self::ROLE_MANAGER => 'Менеджер', + self::ROLE_GUEST => 'Гость', + ]; + + return $labels[$role] ?? $role; + } + + /** + * Получение всех ролей с описаниями + * + * @return array + */ + public static function getAllRoles(): array + { + return [ + self::ROLE_OWNER => [ + 'label' => 'Владелец', + 'description' => 'Полный доступ к организации', + 'level' => 100, + ], + self::ROLE_ADMIN => [ + 'label' => 'Администратор', + 'description' => 'Управление пользователями и модулями', + 'level' => 75, + ], + self::ROLE_MANAGER => [ + 'label' => 'Менеджер', + 'description' => 'Полный доступ к функционалу модулей', + 'level' => 50, + ], + self::ROLE_GUEST => [ + 'label' => 'Гость', + 'description' => 'Только просмотр данных', + 'level' => 25, + ], + ]; + } +} diff --git a/app/Services/InvitationService.php b/app/Services/InvitationService.php new file mode 100644 index 0000000..ccfaa50 --- /dev/null +++ b/app/Services/InvitationService.php @@ -0,0 +1,383 @@ +orgUserModel = new OrganizationUserModel(); + $this->orgModel = new OrganizationModel(); + $this->userModel = new UserModel(); + $this->baseUrl = rtrim(config('App')->baseURL, '/'); + } + + /** + * Создание приглашения в организацию + * + * @param int $organizationId ID организации + * @param string $email Email приглашаемого + * @param string $role Роль (admin, manager, guest) + * @param int $invitedBy ID пользователя, отправляющего приглашение + * @return array ['success' => bool, 'message' => string, 'invite_link' => string, 'invitation_id' => int] + */ + public function createInvitation(int $organizationId, string $email, string $role, int $invitedBy): array + { + // Валидация email + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return [ + 'success' => false, + 'message' => 'Некорректный email адрес', + 'invite_link' => '', + 'invitation_id' => 0, + ]; + } + + // Получаем организацию + $organization = $this->orgModel->find($organizationId); + if (!$organization) { + return [ + 'success' => false, + 'message' => 'Организация не найдена', + 'invite_link' => '', + 'invitation_id' => 0, + ]; + } + + // Проверяем, существует ли пользователь + $existingUser = $this->userModel->where('email', $email)->first(); + $userId = $existingUser['id'] ?? null; + + // Проверяем, не состоит ли уже пользователь в организации + if ($userId) { + $existingMembership = $this->orgUserModel + ->where('organization_id', $organizationId) + ->where('user_id', $userId) + ->first(); + + if ($existingMembership) { + return [ + 'success' => false, + 'message' => 'Пользователь уже состоит в этой организации', + 'invite_link' => '', + 'invitation_id' => 0, + ]; + } + + // Проверяем, есть ли уже приглашение + if ($this->orgUserModel->hasPendingInvite($organizationId, $userId)) { + return [ + 'success' => false, + 'message' => 'Приглашение для этого пользователя уже отправлено', + 'invite_link' => '', + 'invitation_id' => 0, + ]; + } + } + + // Генерируем токен приглашения + $inviteToken = $this->generateToken(); + + // Создаем запись приглашения + $invitationData = [ + 'organization_id' => $organizationId, + 'user_id' => $userId, // NULL для новых пользователей + 'role' => $role, + 'status' => OrganizationUserModel::STATUS_PENDING, + 'invite_token' => $inviteToken, + 'invited_by' => $invitedBy, + ]; + + $invitationId = $this->orgUserModel->createInvitation($invitationData); + + if (!$invitationId) { + return [ + 'success' => false, + 'message' => 'Ошибка при создании приглашения', + 'invite_link' => '', + 'invitation_id' => 0, + ]; + } + + // Если пользователь новый - создаем "теневую" запись в users + if (!$existingUser) { + $this->createShadowUser($email); + } + + // Формируем ссылку приглашения + $inviteLink = $this->baseUrl . '/invitation/accept/' . $inviteToken; + + // Отправляем email + $emailSent = $this->sendInvitationEmail($email, $organization['name'], $role, $inviteLink); + + return [ + 'success' => $emailSent, + 'message' => $emailSent + ? 'Приглашение успешно отправлено' + : 'Приглашение создано, но не удалось отправить email', + 'invite_link' => $inviteLink, + 'invitation_id' => $invitationId, + ]; + } + + /** + * Принятие приглашения + */ + public function acceptInvitation(string $token, int $userId): array + { + $invitation = $this->orgUserModel->findByInviteToken($token); + + if (!$invitation) { + return [ + 'success' => false, + 'message' => 'Приглашение не найдено или уже обработано', + ]; + } + + // Обновляем приглашение + $updated = $this->orgUserModel->acceptInvitation($invitation['id'], $userId); + + if (!$updated) { + return [ + 'success' => false, + 'message' => 'Ошибка при принятии приглашения', + ]; + } + + // Если это был теневой пользователь - привязываем к реальному + if ($invitation['user_id'] === null) { + $this->bindShadowUser($invitation['organization_id'], $userId); + } + + // Получаем организацию для редиректа + $organization = $this->orgModel->find($invitation['organization_id']); + + return [ + 'success' => true, + 'message' => 'Приглашение принято', + 'organization_id' => $invitation['organization_id'], + 'organization_name' => $organization['name'] ?? '', + ]; + } + + /** + * Отклонение приглашения + */ + public function declineInvitation(string $token): array + { + $invitation = $this->orgUserModel->findByInviteToken($token); + + if (!$invitation) { + return [ + 'success' => false, + 'message' => 'Приглашение не найдено или уже обработано', + ]; + } + + $deleted = $this->orgUserModel->declineInvitation($invitation['id']); + + return [ + 'success' => $deleted, + 'message' => $deleted ? 'Приглашение отклонено' : 'Ошибка при отклонении приглашения', + ]; + } + + /** + * Отзыв приглашения (отправителем) + */ + public function cancelInvitation(int $invitationId, int $organizationId): array + { + $invitation = $this->orgUserModel + ->where('id', $invitationId) + ->where('organization_id', $organizationId) + ->where('status', OrganizationUserModel::STATUS_PENDING) + ->first(); + + if (!$invitation) { + return [ + 'success' => false, + 'message' => 'Приглашение не найдено', + ]; + } + + $deleted = $this->orgUserModel->cancelInvitation($invitationId); + + return [ + 'success' => $deleted, + 'message' => $deleted ? 'Приглашение отозвано' : 'Ошибка при отзыве приглашения', + ]; + } + + /** + * Повторная отправка приглашения + */ + public function resendInvitation(int $invitationId, int $organizationId): array + { + $invitation = $this->orgUserModel + ->where('id', $invitationId) + ->where('organization_id', $organizationId) + ->where('status', OrganizationUserModel::STATUS_PENDING) + ->first(); + + if (!$invitation) { + return [ + 'success' => false, + 'message' => 'Приглашение не найдено', + ]; + } + + // Генерируем новый токен + $newToken = $this->generateToken(); + $this->orgUserModel->update($invitationId, [ + 'invite_token' => $newToken, + 'invited_at' => date('Y-m-d H:i:s'), + ]); + + // Получаем email пользователя + $user = $this->userModel->find($invitation['user_id']); + if (!$user) { + return [ + 'success' => false, + 'message' => 'Пользователь не найден', + ]; + } + + // Формируем ссылку + $organization = $this->orgModel->find($organizationId); + $inviteLink = $this->baseUrl . '/invitation/accept/' . $newToken; + + // Отправляем email + $sent = $this->sendInvitationEmail( + $user['email'], + $organization['name'], + $invitation['role'], + $inviteLink + ); + + return [ + 'success' => $sent, + 'message' => $sent ? 'Приглашение отправлено повторно' : 'Ошибка отправки', + 'invite_link' => $inviteLink, + ]; + } + + /** + * Генерация уникального токена + */ + protected function generateToken(): string + { + do { + $token = bin2hex(random_bytes(32)); + $exists = $this->orgUserModel->where('invite_token', $token)->first(); + } while ($exists); + + return $token; + } + + /** + * Создание "теневого" пользователя для новых приглашенных + */ + protected function createShadowUser(string $email): int + { + $token = bin2hex(random_bytes(32)); + + return $this->userModel->insert([ + 'email' => $email, + 'name' => '', // Заполнится при регистрации + 'password' => null, // Без пароля до регистрации + 'email_verified' => 0, + 'verification_token' => $token, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + + /** + * Привязка теневого пользователя к реальному + */ + protected function bindShadowUser(int $organizationId, int $userId): void + { + // Находим теневую запись по email пользователя + $user = $this->userModel->find($userId); + + if ($user && empty($user['password'])) { + // Обновляем все pending приглашения этого пользователя + $this->orgUserModel + ->where('user_id', null) + ->where('status', OrganizationUserModel::STATUS_PENDING) + ->set(['user_id' => $userId]) + ->update(); + } + } + + /** + * Отправка email с приглашением + */ + protected function sendInvitationEmail(string $email, string $orgName, string $role, string $inviteLink): bool + { + $roleLabels = [ + 'owner' => 'Владелец', + 'admin' => 'Администратор', + 'manager' => 'Менеджер', + 'guest' => 'Гость', + ]; + + $roleLabel = $roleLabels[$role] ?? $role; + + $emailService = service('email'); + $emailService->setTo($email); + $emailService->setSubject('Приглашение в организацию ' . $orgName); + + $message = << + + + + + + +
+
+

Приглашение в Бизнес.Точка

+
+
+

Вас приглашают присоединиться к организации {$orgName}

+

Ваша роль: {$roleLabel}

+

Нажмите кнопку ниже, чтобы принять или отклонить приглашение:

+

+ Принять приглашение +

+

Если кнопка не работает, скопируйте ссылку и откройте в браузере:

+ +

Ссылка действительна 48 часов.

+
+ +
+ + +HTML; + + $emailService->setMessage($message); + return $emailService->send(); + } +} diff --git a/app/Services/RateLimitService.php b/app/Services/RateLimitService.php new file mode 100644 index 0000000..d487477 --- /dev/null +++ b/app/Services/RateLimitService.php @@ -0,0 +1,428 @@ +redis = $redis; + $this->prefix = $prefix; + + $this->config = array_merge([ + // Авторизация + 'auth_login_attempts' => 5, + 'auth_login_window' => 900, // 15 минут в секундах + 'auth_login_block' => 900, // Блокировка на 15 минут + + 'auth_register_attempts' => 10, + 'auth_register_window' => 3600, // 1 час + 'auth_register_block' => 3600, // Блокировка на 1 час + + 'auth_reset_attempts' => 5, + 'auth_reset_window' => 900, // 15 минут + 'auth_reset_block' => 900, // Блокировка на 15 минут + + // Общие API лимиты (для модулей) + 'api_read_attempts' => 100, + 'api_read_window' => 60, // 1 минута + + 'api_write_attempts' => 30, + 'api_write_window' => 60, // 1 минута + ], $config); + } + + /** + * Статический фабричный метод для создания сервиса + * + * @return self + */ + public static function getInstance(): self + { + $config = self::getConfig(); + $redis = self::getRedisConnection(); + + $prefix = $config['prefix'] ?? 'rl:'; + + return new self($redis, $prefix, $config); + } + + /** + * Получение конфигурации из .env + * + * @return array + */ + private static function getConfig(): array + { + return [ + 'prefix' => env('rate_limit.prefix', 'rl:'), + + // Авторизация - логин + 'auth_login_attempts' => (int) env('rate_limit.auth.login.attempts', 5), + 'auth_login_window' => (int) env('rate_limit.auth.login.window', 900), + 'auth_login_block' => (int) env('rate_limit.auth.login.block', 900), + + // Авторизация - регистрация + 'auth_register_attempts' => (int) env('rate_limit.auth.register.attempts', 10), + 'auth_register_window' => (int) env('rate_limit.auth.register.window', 3600), + 'auth_register_block' => (int) env('rate_limit.auth.register.block', 3600), + + // Авторизация - восстановление пароля + 'auth_reset_attempts' => (int) env('rate_limit.auth.reset.attempts', 5), + 'auth_reset_window' => (int) env('rate_limit.auth.reset.window', 900), + 'auth_reset_block' => (int) env('rate_limit.auth.reset.block', 900), + + // API - чтение + 'api_read_attempts' => (int) env('rate_limit.api.read.attempts', 100), + 'api_read_window' => (int) env('rate_limit.api.read.window', 60), + + // API - запись + 'api_write_attempts' => (int) env('rate_limit.api.write.attempts', 30), + 'api_write_window' => (int) env('rate_limit.api.write.window', 60), + ]; + } + + /** + * Подключение к Redis + * + * @return \Redis + * @throws RuntimeException + */ + private static function getRedisConnection(): \Redis + { + /** @var \Redis $redis */ + $redis = new \Redis(); + + $host = env('redis.host', '127.0.0.1'); + $port = (int) env('redis.port', 6379); + $password = env('redis.password', ''); + $database = (int) env('redis.database', 0); + $timeout = (float) env('redis.timeout', 2.0); + $readTimeout = (float) env('redis.read_timeout', 60.0); + + if (!$redis->connect($host, $port, $timeout)) { + throw new RuntimeException("Не удалось подключиться к Redis ({$host}:{$port})"); + } + + if (!empty($password)) { + if (!$redis->auth($password)) { + throw new RuntimeException('Ошибка аутентификации в Redis'); + } + } + + $redis->select($database); + $redis->setOption(\Redis::OPT_READ_TIMEOUT, $readTimeout); + + return $redis; + } + + /** + * Получение IP-адреса клиента + * + * @return string + */ + private function getClientIp(): string + { + $ip = service('request')->getIPAddress(); + + // Если это CLI-запрос или IP не определён - используем fallback + if (empty($ip) || $ip === '0.0.0.0') { + return '127.0.0.1'; + } + + return $ip; + } + + /** + * Генерация ключа для Redis + * + * @param string $type Тип ограничения (login, register, reset) + * @param string $suffix Дополнительный суффикс + * @return string + */ + private function getKey(string $type, string $suffix = ''): string + { + $ip = $this->getClientIp(); + $key = "{$this->prefix}{$type}:{$ip}"; + + if (!empty($suffix)) { + $key .= ":{$suffix}"; + } + + return $key; + } + + /** + * Проверка на блокировку + * + * @param string $type Тип блокировки (login, register, reset) + * @return bool + */ + public function isBlocked(string $type): bool + { + $blockKey = $this->getKey($type, 'block'); + return (bool) $this->redis->exists($blockKey); + } + + /** + * Получение оставшегося времени блокировки в секундах + * + * @param string $type Тип блокировки + * @return int + */ + public function getBlockTimeLeft(string $type): int + { + $blockKey = $this->getKey($type, 'block'); + $ttl = $this->redis->ttl($blockKey); + + return max(0, $ttl); + } + + /** + * Проверка и инкремент счётчика попыток + * + * @param string $type Тип действия (login, register, reset) + * @return array ['allowed' => bool, 'attempts' => int, 'limit' => int, 'remaining' => int] + */ + public function checkAttempt(string $type): array + { + // Если заблокирован - сразу возвращаем запрет + if ($this->isBlocked($type)) { + return [ + 'allowed' => false, + 'attempts' => 0, + 'limit' => $this->config["auth_{$type}_attempts"] ?? 0, + 'remaining' => 0, + 'blocked' => true, + 'block_ttl' => $this->getBlockTimeLeft($type), + ]; + } + + $attemptsKey = $this->getKey($type, 'attempts'); + $window = $this->config["auth_{$type}_window"] ?? 900; + $maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5; + + // Получаем текущее количество попыток + $currentAttempts = (int) $this->redis->get($attemptsKey); + $remaining = max(0, $maxAttempts - $currentAttempts); + + // Проверяем, не превышен ли лимит + if ($currentAttempts >= $maxAttempts) { + // Устанавливаем блокировку + $blockTtl = $this->config["auth_{$type}_block"] ?? $window; + $blockKey = $this->getKey($type, 'block'); + $this->redis->setex($blockKey, $blockTtl, '1'); + + return [ + 'allowed' => false, + 'attempts' => $currentAttempts, + 'limit' => $maxAttempts, + 'remaining' => 0, + 'blocked' => true, + 'block_ttl' => $blockTtl, + ]; + } + + return [ + 'allowed' => true, + 'attempts' => $currentAttempts, + 'limit' => $maxAttempts, + 'remaining' => $remaining, + 'blocked' => false, + 'block_ttl' => 0, + ]; + } + + /** + * Регистрация неудачной попытки + * + * @param string $type Тип действия + * @return array Результат после инкремента + */ + public function recordFailedAttempt(string $type): array + { + $attemptsKey = $this->getKey($type, 'attempts'); + $window = $this->config["auth_{$type}_window"] ?? 900; + $maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5; + + // Инкрементируем счётчик + $attempts = $this->redis->incr($attemptsKey); + + // Устанавливаем TTL только при первой попытке + if ($attempts === 1) { + $this->redis->expire($attemptsKey, $window); + } + + // Проверяем, не превышен ли лимит + if ($attempts >= $maxAttempts) { + // Устанавливаем блокировку + $blockTtl = $this->config["auth_{$type}_block"] ?? $window; + $blockKey = $this->getKey($type, 'block'); + $this->redis->setex($blockKey, $blockTtl, '1'); + + return [ + 'allowed' => false, + 'attempts' => $attempts, + 'limit' => $maxAttempts, + 'remaining' => 0, + 'blocked' => true, + 'block_ttl' => $blockTtl, + ]; + } + + return [ + 'allowed' => true, + 'attempts' => $attempts, + 'limit' => $maxAttempts, + 'remaining' => $maxAttempts - $attempts, + 'blocked' => false, + 'block_ttl' => 0, + ]; + } + + /** + * Сброс счётчика после успешного действия + * + * @param string $type Тип действия + * @return void + */ + public function resetAttempts(string $type): void + { + $attemptsKey = $this->getKey($type, 'attempts'); + $this->redis->del($attemptsKey); + } + + /** + * API rate limiting - проверка лимита на чтение + * + * @return array ['allowed' => bool, 'remaining' => int, 'reset' => int] + */ + public function checkApiReadLimit(): array + { + return $this->checkApiLimit('read'); + } + + /** + * API rate limiting - проверка лимита на запись + * + * @return array ['allowed' => bool, 'remaining' => int, 'reset' => int] + */ + public function checkApiWriteLimit(): array + { + return $this->checkApiLimit('write'); + } + + /** + * Внутренний метод для проверки API лимитов + * + * @param string $type read или write + * @return array + */ + private function checkApiLimit(string $type): array + { + $key = $this->getKey("api_{$type}"); + $maxAttempts = $this->config["api_{$type}_attempts"] ?? 60; + $window = $this->config["api_{$type}_window"] ?? 60; + + $current = (int) $this->redis->get($key); + $ttl = $this->redis->ttl($key); + + // Если ключ не существует или истёк - создаём новый + if ($ttl < 0) { + $this->redis->setex($key, $window, 1); + return [ + 'allowed' => true, + 'remaining' => $maxAttempts - 1, + 'reset' => $window, + ]; + } + + if ($current >= $maxAttempts) { + return [ + 'allowed' => false, + 'remaining' => 0, + 'reset' => max(0, $ttl), + ]; + } + + $this->redis->incr($key); + + return [ + 'allowed' => true, + 'remaining' => $maxAttempts - $current - 1, + 'reset' => max(0, $ttl), + ]; + } + + /** + * Получение статуса rate limiting для отладки + * + * @param string $type Тип действия + * @return array + */ + public function getStatus(string $type): array + { + $attemptsKey = $this->getKey($type, 'attempts'); + $blockKey = $this->getKey($type, 'block'); + + $attempts = (int) $this->redis->get($attemptsKey); + $attemptsTtl = $this->redis->ttl($attemptsKey); + $isBlocked = $this->redis->exists($blockKey); + $blockTtl = $isBlocked ? $this->redis->ttl($blockKey) : 0; + + $maxAttempts = $this->config["auth_{$type}_attempts"] ?? 5; + $window = $this->config["auth_{$type}_window"] ?? 900; + + return [ + 'ip' => $this->getClientIp(), + 'type' => $type, + 'attempts' => $attempts, + 'attempts_ttl' => max(0, $attemptsTtl), + 'limit' => $maxAttempts, + 'window' => $window, + 'is_blocked' => $isBlocked, + 'block_ttl' => max(0, $blockTtl), + ]; + } + + /** + * Проверка подключения к Redis + * + * @return bool + */ + public function isConnected(): bool + { + try { + return $this->redis->ping() === true || $this->redis->ping() === '+PONG'; + } catch (\Exception $e) { + return false; + } + } +} diff --git a/app/Views/components/table/ajax_table.twig b/app/Views/components/table/ajax_table.twig index 182389e..5be2cbc 100644 --- a/app/Views/components/table/ajax_table.twig +++ b/app/Views/components/table/ajax_table.twig @@ -12,7 +12,27 @@ {# Колонка действий #} {% if actionsConfig is defined and actionsConfig|length > 0 %} - {{ render_actions(item, actionsConfig)|raw }} + {# Фильтруем действия на основе прав доступа #} + {% set visibleActions = [] %} + {% for action in actionsConfig %} + {% set showAction = true %} + {% if action.type is defined %} + {% if action.type == 'edit' and not (can_edit|default(true)) %} + {% set showAction = false %} + {% elseif action.type == 'delete' and not (can_delete|default(true)) %} + {% set showAction = false %} + {% endif %} + {% endif %} + {% if showAction %} + {% set visibleActions = visibleActions|merge([action]) %} + {% endif %} + {% endfor %} + + {% if visibleActions|length > 0 %} + {{ render_actions(item, visibleActions)|raw }} + {% else %} + + {% endif %} {% endif %} diff --git a/app/Views/components/table/table.twig b/app/Views/components/table/table.twig index d3b50c4..2fc140b 100644 --- a/app/Views/components/table/table.twig +++ b/app/Views/components/table/table.twig @@ -18,6 +18,8 @@ { label: 'Ред.', url: '/clients/edit/{id}', icon: 'bi bi-pencil', class: 'btn-outline-primary' }, { label: 'Удалить', url: '/clients/delete/{id}', icon: 'bi bi-trash', class: 'btn-outline-danger' } ] + - can_edit: Разрешено ли редактирование (для фильтрации действий) + - can_delete: Разрешено ли удаление (для фильтрации действий) - emptyMessage: Сообщение при отсутствии данных - emptyActionUrl: URL для кнопки действия - emptyActionLabel: Текст кнопки @@ -50,7 +52,27 @@ {# Колонка действий #} {% if actionsConfig is defined and actionsConfig|length > 0 %} - {{ render_actions(item, actionsConfig)|raw }} + {# Фильтруем действия на основе прав доступа #} + {% set visibleActions = [] %} + {% for action in actionsConfig %} + {% set showAction = true %} + {% if action.type is defined %} + {% if action.type == 'edit' and not (can_edit|default(true)) %} + {% set showAction = false %} + {% elseif action.type == 'delete' and not (can_delete|default(true)) %} + {% set showAction = false %} + {% endif %} + {% endif %} + {% if showAction %} + {% set visibleActions = visibleActions|merge([action]) %} + {% endif %} + {% endfor %} + + {% if visibleActions|length > 0 %} + {{ render_actions(item, visibleActions)|raw }} + {% else %} + + {% endif %} {% endif %} diff --git a/app/Views/layouts/base.twig b/app/Views/layouts/base.twig index f0f13e6..dc71554 100644 --- a/app/Views/layouts/base.twig +++ b/app/Views/layouts/base.twig @@ -106,10 +106,10 @@
  • - +
  • - - Настройки текущей + + Управление организацией
  • diff --git a/app/Views/organizations/confirm_modal.twig b/app/Views/organizations/confirm_modal.twig new file mode 100644 index 0000000..88691e2 --- /dev/null +++ b/app/Views/organizations/confirm_modal.twig @@ -0,0 +1,20 @@ +{# + organizations/confirm_modal.twig - Модальное окно подтверждения действия +#} + diff --git a/app/Views/organizations/dashboard.twig b/app/Views/organizations/dashboard.twig new file mode 100644 index 0000000..4d4e823 --- /dev/null +++ b/app/Views/organizations/dashboard.twig @@ -0,0 +1,181 @@ +{% extends 'layouts/base.twig' %} + +{% block title %}Управление организацией - {{ organization.name }} - {{ parent() }}{% endblock %} + +{% block content %} +
    + {# Заголовок #} +
    +
    +

    Управление организацией

    +

    {{ organization.name }}

    +
    +
    + {{ role_badge(current_role) }} +
    +
    + + {# Статистика #} +
    +
    +
    +
    +
    {{ stats.users_total }}
    +
    Всего участников
    +
    +
    +
    +
    +
    +
    +
    {{ stats.users_active }}
    +
    Активных
    +
    +
    +
    +
    +
    +
    +
    {{ stats.users_blocked }}
    +
    Заблокировано
    +
    +
    +
    +
    + + {# Карточки управления #} +
    + {# Управление командой #} + {% if can_manage_users %} + + {% endif %} + + {# Редактирование организации #} + + + {# Модули организации - заглушка #} +
    +
    +
    +
    +
    + +
    +
    +
    Модули
    +

    Управление подключёнными модулями и функционалом организации

    +
    +
    +
    + +
    +
    + + {# Биллинг - заглушка #} +
    +
    +
    +
    +
    + +
    +
    +
    Биллинг и оплата
    +

    Просмотр счетов, история платежей и управление подпиской

    +
    +
    +
    + +
    +
    + + {# Приглашения - заглушка #} +
    +
    +
    +
    +
    + +
    +
    +
    История приглашений
    +

    Просмотр отправленных и отклонённых приглашений

    +
    +
    +
    + +
    +
    + + {# Настройки безопасности - заглушка #} +
    +
    +
    +
    +
    + +
    +
    +
    Безопасность
    +

    Настройки безопасности, двухфакторная аутентификация, логи

    +
    +
    +
    + +
    +
    +
    +
    + + +{% endblock %} \ No newline at end of file diff --git a/app/Views/organizations/edit_role_modal.twig b/app/Views/organizations/edit_role_modal.twig new file mode 100644 index 0000000..73e3e5a --- /dev/null +++ b/app/Views/organizations/edit_role_modal.twig @@ -0,0 +1,55 @@ +{# + organizations/edit_role_modal.twig - Модальное окно изменения роли пользователя +#} + + + diff --git a/app/Views/organizations/invitation_accept.twig b/app/Views/organizations/invitation_accept.twig new file mode 100644 index 0000000..ef3b7ea --- /dev/null +++ b/app/Views/organizations/invitation_accept.twig @@ -0,0 +1,107 @@ +{# + organizations/invitation_accept.twig - Страница принятия/отклонения приглашения +#} +{% extends 'layouts/landing.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +
    + {# Логотип организации #} +
    + {% if organization.logo %} + {{ organization.name }} + {% else %} +
    + +
    + {% endif %} +
    + +

    Приглашение в организацию

    + + {# Информация об организации #} +
    +

    {{ organization.name }}

    + {{ role_label }} +
    + +

    + Вас пригласили присоединиться к организации "{{ organization.name }}" +

    + + {% if invited_by %} +

    + Приглашение отправил: {{ invited_by.name|default(invited_by.email) }} +

    + {% endif %} + + {# Если пользователь не авторизован #} + {% if not is_logged_in %} +
    + + Для принятия приглашения необходимо войти в аккаунт или зарегистрироваться +
    + {% endif %} + + {# Если авторизован, но email не совпадает #} + {% if is_logged_in and not email_matches %} +
    + + Внимание! Вы вошли как {{ get_session('email') }}, + а приглашение отправлено на другой email. +
    + {% endif %} + + {# Форма принятия/отклонения #} +
    + + + +
    + + +
    +
    + + {# Ссылка на вход/регистрацию #} + {% if not is_logged_in %} + + {% endif %} + +
    + + Приглашение действительно 48 часов +
    +
    +
    + + {# Футер #} +
    + © {{ "now"|date("Y") }} Бизнес.Точка +
    +
    +
    +
    +
    +{% endblock %} diff --git a/app/Views/organizations/invitation_complete.twig b/app/Views/organizations/invitation_complete.twig new file mode 100644 index 0000000..8a29874 --- /dev/null +++ b/app/Views/organizations/invitation_complete.twig @@ -0,0 +1,114 @@ +{# + organizations/invitation_complete.twig - Страница завершения регистрации нового пользователя +#} +{% extends 'layouts/landing.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +
    + {# Логотип организации #} +
    + {% if organization.logo %} + {{ organization.name }} + {% else %} +
    + +
    + {% endif %} +
    + +

    Завершение регистрации

    + +

    + Вы приняли приглашение в организацию "{{ organization.name }}" +
    + в роли {{ role_label }} +

    + +

    + Пожалуйста, создайте пароль для вашего аккаунта +

    + + {# Ошибки валидации #} + {% if get_alerts()|filter(a => a.type == 'error')|length > 0 %} +
    + {% for alert in get_alerts() %} + {% if alert.type == 'error' %} +
    {{ alert.message }}
    + {% endif %} + {% endfor %} +
    + {% endif %} + + {# Форма регистрации #} +
    + + +
    + + + {% if old.name is defined and old.name is empty %} +
    Имя обязательно
    + {% endif %} +
    + +
    + + +
    Email подтверждён через приглашение
    +
    + +
    + + +
    Минимум 8 символов
    +
    + +
    + + +
    + +
    + +
    +
    +
    +
    + + {# Футер #} +
    + © {{ "now"|date("Y") }} Бизнес.Точка +
    +
    +
    +
    +
    +{% endblock %} diff --git a/app/Views/organizations/invitation_expired.twig b/app/Views/organizations/invitation_expired.twig new file mode 100644 index 0000000..6c9088f --- /dev/null +++ b/app/Views/organizations/invitation_expired.twig @@ -0,0 +1,45 @@ +{# + organizations/invitation_expired.twig - Страница истёкшего/недействительного приглашения +#} +{% extends 'layouts/landing.twig' %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
    +
    +
    +
    +
    +
    +
    + +
    + +

    {{ title }}

    + +

    + Это приглашение недействительно или уже было обработано.
    + Возможно, оно истекло или было отозвано отправителем. +

    + + +
    +
    + + {# Футер #} +
    + © {{ "now"|date("Y") }} Бизнес.Точка +
    +
    +
    +
    +
    +{% endblock %} diff --git a/app/Views/organizations/invite_modal.twig b/app/Views/organizations/invite_modal.twig new file mode 100644 index 0000000..344e417 --- /dev/null +++ b/app/Views/organizations/invite_modal.twig @@ -0,0 +1,101 @@ +{# + organizations/invite_modal.twig - Модальное окно приглашения пользователя +#} + + +{# Модалка с ссылкой приглашения #} + diff --git a/app/Views/organizations/users.twig b/app/Views/organizations/users.twig new file mode 100644 index 0000000..49f21ca --- /dev/null +++ b/app/Views/organizations/users.twig @@ -0,0 +1,328 @@ +{# + organizations/users.twig - Страница управления пользователями организации +#} +{% extends 'layouts/base.twig' %} + +{% block title %}Участники организации - {{ parent() }}{% endblock %} + +{% block content %} +
    + {# Заголовок #} +
    +
    +

    Участники организации

    +

    {{ organization.name }}

    +
    + {% if can_manage_users %} + + {% endif %} +
    + + {# Статистика #} +
    +
    +
    +
    +
    +
    +
    Всего участников
    +
    {{ users|length }}
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Активных
    +
    {{ users|filter(u => u.status == 'active')|length }}
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Ожидают ответа
    +
    {{ users|filter(u => u.status == 'pending')|length }}
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Заблокировано
    +
    {{ users|filter(u => u.status == 'blocked')|length }}
    +
    + +
    +
    +
    +
    +
    + + {# Таблица пользователей #} + {{ tableHtml|raw }} + + {# CSRF токен для AJAX запросов #} + {{ csrf_field()|raw }} + + {# Кнопка выхода из организации #} + {% if current_role != 'owner' %} +
    + +
    + {% endif %} +
    + +{# Модалка приглашения #} +{% include 'organizations/invite_modal.twig' %} + +{# Модалка подтверждения действий #} +{% include 'organizations/confirm_modal.twig' %} + +{% endblock %} + +{% block stylesheets %} +{{ parent() }} + +{% endblock %} + +{% block scripts %} +{{ parent() }} + + + + +{% endblock %}