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}
+
Нажмите кнопку ниже, чтобы принять или отклонить приглашение:
+
+ Принять приглашение
+
+
Если кнопка не работает, скопируйте ссылку и откройте в браузере:
+
{$inviteLink}
+
Ссылка действительна 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 %}
+

+ {% 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 %}
+

+ {% 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 %}
+
+ {# Форма регистрации #}
+
+
+
+
+ {# Футер #}
+
+ © {{ "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 - Модальное окно приглашения пользователя
+#}
+
+
+{# Модалка с ссылкой приглашения #}
+
+
+
+
+
+
Приглашение успешно отправлено на
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Если email не дошёл, можно скопировать ссылку и отправить другим способом
+
+
+
+
+
+
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 %}