dashboard for org

This commit is contained in:
root 2026-01-12 11:58:12 +03:00
parent d27f66953c
commit 246ca93307
30 changed files with 4362 additions and 125 deletions

View File

@ -35,6 +35,7 @@ class Filters extends BaseFilters
'pagecache' => PageCache::class, 'pagecache' => PageCache::class,
'performance' => PerformanceMetrics::class, 'performance' => PerformanceMetrics::class,
'org' => \App\Filters\OrganizationFilter::class, 'org' => \App\Filters\OrganizationFilter::class,
'role' => \App\Filters\RoleFilter::class,
]; ];
/** /**

View File

@ -17,9 +17,18 @@ $routes->get('auth/verify/(:any)', 'Auth::verify/$1');
$routes->get('auth/resend-verification', 'Auth::resendVerification'); $routes->get('auth/resend-verification', 'Auth::resendVerification');
$routes->post('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) # Защищённые маршруты (с фильтром org)
$routes->group('', ['filter' => 'org'], static function ($routes) { $routes->group('', ['filter' => 'org'], static function ($routes) {
$routes->get('organizations', 'Organizations::index'); $routes->get('organizations', 'Organizations::index');
$routes->get('organizations/(:num)/dashboard', 'Organizations::dashboard/$1');
$routes->get('organizations/create', 'Organizations::create'); $routes->get('organizations/create', 'Organizations::create');
$routes->post('organizations/create', 'Organizations::create'); $routes->post('organizations/create', 'Organizations::create');
$routes->get('organizations/edit/(:num)', 'Organizations::edit/$1'); $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->post('organizations/delete/(:num)', 'Organizations::delete/$1');
$routes->get('organizations/switch/(:num)', 'Organizations::switch/$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';

View File

@ -29,4 +29,39 @@ class Services extends BaseService
* return new \CodeIgniter\Example(); * 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;
}
}
} }

View File

@ -6,22 +6,147 @@ use App\Models\UserModel;
use App\Models\OrganizationModel; use App\Models\OrganizationModel;
use App\Models\OrganizationUserModel; use App\Models\OrganizationUserModel;
use App\Libraries\EmailLibrary; use App\Libraries\EmailLibrary;
use App\Services\RateLimitService;
class Auth extends BaseController class Auth extends BaseController
{ {
protected $emailLibrary; protected $emailLibrary;
protected ?RateLimitService $rateLimitService;
public function __construct() public function __construct()
{ {
$this->emailLibrary = new EmailLibrary(); $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() public function register()
{ {
if ($this->request->getMethod() === 'POST') { 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)); log_message('debug', 'POST запрос получен: ' . print_r($this->request->getPost(), true));
// Валидация (упрощенная для примера)
// Валидация
$rules = [ $rules = [
'name' => 'required|min_length[3]', 'name' => 'required|min_length[3]',
'email' => 'required|valid_email|is_unique[users.email]', 'email' => 'required|valid_email|is_unique[users.email]',
@ -81,6 +206,9 @@ class Auth extends BaseController
$verificationToken $verificationToken
); );
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ РЕГИСТРАЦИИ ===
$this->resetRateLimit('register');
// 5. Показываем сообщение о необходимости подтверждения // 5. Показываем сообщение о необходимости подтверждения
session()->setFlashdata('success', 'Регистрация успешна! Пожалуйста, проверьте вашу почту и подтвердите email.'); session()->setFlashdata('success', 'Регистрация успешна! Пожалуйста, проверьте вашу почту и подтвердите email.');
return redirect()->to('/register/success'); return redirect()->to('/register/success');
@ -162,6 +290,15 @@ class Auth extends BaseController
public function resendVerification() public function resendVerification()
{ {
if ($this->request->getMethod() === 'POST') { 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'); $email = $this->request->getPost('email');
if (empty($email)) { if (empty($email)) {
@ -172,6 +309,8 @@ class Auth extends BaseController
$user = $userModel->where('email', $email)->first(); $user = $userModel->where('email', $email)->first();
if (!$user) { if (!$user) {
// Неудачная попытка - засчитываем для защиты от перебора
$this->recordFailedAttempt('reset');
return redirect()->back()->with('error', 'Пользователь с таким email не найден'); return redirect()->back()->with('error', 'Пользователь с таким email не найден');
} }
@ -192,6 +331,9 @@ class Auth extends BaseController
$newToken $newToken
); );
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ ОТПРАВКИ ===
$this->resetRateLimit('reset');
return redirect()->back()->with('success', 'Письмо для подтверждения отправлено повторно. Проверьте почту.'); return redirect()->back()->with('success', 'Письмо для подтверждения отправлено повторно. Проверьте почту.');
} }
@ -201,6 +343,15 @@ class Auth extends BaseController
public function login() public function login()
{ {
if ($this->request->getMethod() === 'POST') { 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(); $userModel = new \App\Models\UserModel();
$orgUserModel = new \App\Models\OrganizationUserModel(); $orgUserModel = new \App\Models\OrganizationUserModel();
@ -238,6 +389,10 @@ class Auth extends BaseController
// Если одна организация — заходим автоматически для удобства // Если одна организация — заходим автоматически для удобства
$sessionData['active_org_id'] = $userOrgs[0]['organization_id']; $sessionData['active_org_id'] = $userOrgs[0]['organization_id'];
session()->set($sessionData); session()->set($sessionData);
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
$this->resetRateLimit('login');
return redirect()->to('/'); return redirect()->to('/');
} }
@ -251,10 +406,28 @@ class Auth extends BaseController
// чтобы страница /organizations не редиректнула его обратно (см. Organizations::index) // чтобы страница /organizations не редиректнула его обратно (см. Organizations::index)
session()->setFlashdata('info', 'Выберите пространство для работы'); session()->setFlashdata('info', 'Выберите пространство для работы');
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
$this->resetRateLimit('login');
return redirect()->to('/organizations'); return redirect()->to('/organizations');
//}
} else { } 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'); session()->remove('active_org_id');
return redirect()->to('/'); 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'],
],
],
]);
}
} }

View File

@ -7,6 +7,7 @@ use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use App\Models\OrganizationModel; use App\Models\OrganizationModel;
use App\Services\AccessService;
/** /**
* BaseController provides a convenient place for loading components * BaseController provides a convenient place for loading components
@ -27,6 +28,7 @@ abstract class BaseController extends Controller
*/ */
protected $session; protected $session;
protected AccessService $access;
/** /**
* @return void * @return void
@ -42,6 +44,33 @@ abstract class BaseController extends Controller
// Preload any models, libraries, etc, here. // Preload any models, libraries, etc, here.
$this->session = service('session'); $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 = []) public function renderTwig($template, $data = [])
@ -55,6 +84,9 @@ abstract class BaseController extends Controller
$oldInput = $this->session->get('_ci_old_input') ?? []; $oldInput = $this->session->get('_ci_old_input') ?? [];
$data['old'] = $data['old'] ?? $oldInput; $data['old'] = $data['old'] ?? $oldInput;
// Добавляем access в данные шаблона для функций can(), isRole() и т.д.
$data['access'] = $this->access;
ob_start(); ob_start();
$twig->display($template, $data); $twig->display($template, $data);
$content = ob_get_clean(); $content = ob_get_clean();
@ -129,19 +161,39 @@ abstract class BaseController extends Controller
} }
$model = $config['model']; $model = $config['model'];
$builder = $model->builder();
// Сбрасываем все предыдущие условия
$builder->resetQuery();
// Если есть кастомный scope - создаём новый чистый запрос
// scope будет полностью контролировать FROM, JOIN, SELECT
if (isset($config['scope']) && is_callable($config['scope'])) { if (isset($config['scope']) && is_callable($config['scope'])) {
$builder = $model->db()->newQuery();
$config['scope']($builder); $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) { foreach ($filters as $filterKey => $value) {
if ($value !== '' && in_array($field, $config['searchable'])) { if ($value === '') {
$builder->like($field, $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); $builder->orderBy($sort, $order);
} }
// Исправлено: countAllResults(false) вместо countAll()
// Сохраняем текущее состояние builder для подсчета // Сохраняем текущее состояние builder для подсчета
$countBuilder = clone $builder; $countBuilder = clone $builder;
$total = $countBuilder->countAllResults(false); $total = $countBuilder->countAllResults(false);
// Получаем данные с пагинацией // Получаем данные с пагинацией (scope уже установил нужный SELECT)
$builder->select('*');
$items = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray(); $items = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray();
$from = ($page - 1) * $perPage + 1; $from = ($page - 1) * $perPage + 1;
@ -180,6 +230,8 @@ abstract class BaseController extends Controller
'filters' => $filters, 'filters' => $filters,
'columns' => $config['columns'], 'columns' => $config['columns'],
'actionsConfig' => $config['actionsConfig'] ?? [], 'actionsConfig' => $config['actionsConfig'] ?? [],
'can_edit' => $config['can_edit'] ?? true,
'can_delete' => $config['can_delete'] ?? true,
]; ];
return $data; return $data;
@ -221,12 +273,42 @@ abstract class BaseController extends Controller
} }
/** /**
* AJAX endpoint для таблицы - возвращает partial (tbody + tfoot) * AJAX endpoint для таблицы
* Если запрос не AJAX - возвращает полную таблицу *
* Логика:
* - Если 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(); $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);
} }
} }

View File

@ -0,0 +1,267 @@
<?php
namespace App\Controllers;
use App\Models\OrganizationUserModel;
use App\Models\UserModel;
use App\Models\OrganizationModel;
use App\Services\InvitationService;
use App\Services\AccessService;
class InvitationController extends BaseController
{
protected InvitationService $invitationService;
public function __construct()
{
$this->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,
]);
}
}

View File

@ -4,17 +4,25 @@ namespace App\Controllers;
use App\Models\OrganizationModel; use App\Models\OrganizationModel;
use App\Models\OrganizationUserModel; use App\Models\OrganizationUserModel;
use App\Models\UserModel;
use App\Services\AccessService;
class Organizations extends BaseController class Organizations extends BaseController
{ {
protected OrganizationUserModel $orgUserModel;
public function __construct()
{
$this->orgUserModel = new OrganizationUserModel();
}
public function index() public function index()
{ {
$orgModel = new OrganizationModel(); $orgModel = new OrganizationModel();
$orgUserModel = new OrganizationUserModel();
$userId = session()->get('user_id'); $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'); $orgIds = array_column($userOrgLinks, 'organization_id');
@ -24,12 +32,6 @@ class Organizations extends BaseController
$organizations = $orgModel->whereIn('id', $orgIds)->findAll(); $organizations = $orgModel->whereIn('id', $orgIds)->findAll();
} }
// Логика автоперехода (как в Auth)
// if (count($organizations) === 1) {
// session()->set('active_org_id', $organizations[0]['id']);
// return redirect()->to('/');
// }
// Если больше 1 или 0, показываем список // Если больше 1 или 0, показываем список
return $this->renderTwig('organizations/index', [ return $this->renderTwig('organizations/index', [
'organizations' => $organizations, 'organizations' => $organizations,
@ -41,7 +43,6 @@ class Organizations extends BaseController
{ {
if ($this->request->getMethod() === 'POST') { if ($this->request->getMethod() === 'POST') {
$orgModel = new OrganizationModel(); $orgModel = new OrganizationModel();
$orgUserModel = new OrganizationUserModel();
$rules = [ $rules = [
'name' => 'required|min_length[2]', 'name' => 'required|min_length[2]',
@ -77,7 +78,7 @@ class Organizations extends BaseController
]); ]);
// Привязываем владельца // Привязываем владельца
$orgUserModel->insert([ $this->orgUserModel->insert([
'organization_id' => $orgId, 'organization_id' => $orgId,
'user_id' => session()->get('user_id'), 'user_id' => session()->get('user_id'),
'role' => 'owner', 'role' => 'owner',
@ -96,30 +97,66 @@ class Organizations extends BaseController
return $this->renderTwig('organizations/create'); 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) public function edit($orgId)
{ {
$orgModel = new OrganizationModel(); // Проверяем доступ через AccessService
$orgUserModel = new OrganizationUserModel(); $orgId = (int) $orgId;
$userId = session()->get('user_id'); $membership = $this->getMembership($orgId);
// Проверяем: имеет ли пользователь доступ к этой организации?
$membership = $orgUserModel->where('organization_id', $orgId)
->where('user_id', $userId)
->first();
if (!$membership) { if (!$membership) {
session()->setFlashdata('error', 'Доступ запрещен'); return $this->redirectWithError('Доступ запрещен', '/organizations');
return redirect()->to('/organizations');
} }
// Получаем организацию // Проверяем права на редактирование (все роли могут редактировать)
$orgModel = new OrganizationModel();
$organization = $orgModel->find($orgId); $organization = $orgModel->find($orgId);
if (!$organization) { if (!$organization) {
session()->setFlashdata('error', 'Организация не найдена'); return $this->redirectWithError('Организация не найдена', '/organizations');
return redirect()->to('/organizations');
} }
// Декодируем requisites для формы // Декодируем requisites для формы
@ -173,37 +210,29 @@ class Organizations extends BaseController
*/ */
public function delete($orgId) public function delete($orgId)
{ {
$orgModel = new OrganizationModel(); $orgId = (int) $orgId;
$orgUserModel = new OrganizationUserModel(); $membership = $this->getMembership($orgId);
$userId = session()->get('user_id');
// Проверяем: имеет ли пользователь доступ к этой организации?
$membership = $orgUserModel->where('organization_id', $orgId)
->where('user_id', $userId)
->first();
if (!$membership) { if (!$membership) {
session()->setFlashdata('error', 'Доступ запрещен'); return $this->redirectWithError('Доступ запрещен', '/organizations');
return redirect()->to('/organizations');
} }
// Проверяем, что пользователь — владелец // Проверяем права: только владелец может удалить
if ($membership['role'] !== 'owner') { if (!$this->access->canDeleteOrganization()) {
session()->setFlashdata('error', 'Только владелец может удалить организацию'); return $this->redirectWithError('Только владелец может удалить организацию', '/organizations');
return redirect()->to('/organizations');
} }
// Получаем организацию $orgModel = new OrganizationModel();
$organization = $orgModel->find($orgId); $organization = $orgModel->find($orgId);
if (!$organization) { if (!$organization) {
session()->setFlashdata('error', 'Организация не найдена'); return $this->redirectWithError('Организация не найдена', '/organizations');
return redirect()->to('/organizations');
} }
// Если это POST с подтверждением — удаляем // Если это POST с подтверждением — удаляем
if ($this->request->getMethod() === 'POST') { if ($this->request->getMethod() === 'POST') {
// Удаляем связи с пользователями // Удаляем связи с пользователями через forCurrentOrg()
$orgUserModel->where('organization_id', $orgId)->delete(); $this->orgUserModel->forCurrentOrg()->delete();
// Мягкое удаление организации // Мягкое удаление организации
$orgModel->delete($orgId); $orgModel->delete($orgId);
@ -226,14 +255,18 @@ class Organizations extends BaseController
public function switch($orgId) public function switch($orgId)
{ {
$userId = session()->get('user_id'); $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) ->where('user_id', $userId)
->first(); ->first();
if ($membership) { if ($membership) {
// Сбрасываем кэш AccessService при смене организации
$this->access->resetCache();
session()->set('active_org_id', $orgId); session()->set('active_org_id', $orgId);
session()->setFlashdata('success', 'Организация изменена'); session()->setFlashdata('success', 'Организация изменена');
return redirect()->to('/'); return redirect()->to('/');
@ -242,4 +275,525 @@ class Organizations extends BaseController
return redirect()->to('/organizations'); 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);
}
} }

View File

@ -0,0 +1,60 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddInviteFieldsToOrganizationUsers extends Migration
{
public function up()
{
// Добавление полей для системы приглашений
$fields = [
'invite_token' => [
'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");
}
}

100
app/Filters/RoleFilter.php Normal file
View File

@ -0,0 +1,100 @@
<?php
namespace App\Filters;
use App\Services\AccessService;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* RoleFilter - Фильтр проверки ролей и прав доступа
*
* Применяется к маршрутам для проверки прав доступа на уровне роутинга.
*
* Использование в роутах:
* $routes->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('/');
}
}

View File

@ -0,0 +1,244 @@
<?php
namespace App\Helpers;
/**
* Access Helper - Функции-хелперы для проверки прав доступа в представлениях
*
* Предоставляет простые функции для использования в Twig-шаблонах.
*/
/**
* Проверка права на действие
*
* @param string $action
* @param string $resource
* @return bool
*/
if (!function_exists('can')) {
function can(string $action, string $resource): bool
{
$access = service('access');
return $access->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);
}
}

View File

@ -21,9 +21,145 @@ class TwigGlobalsExtension extends AbstractExtension
new TwigFunction('get_current_route', [$this, 'getCurrentRoute'], ['is_safe' => ['html']]), new TwigFunction('get_current_route', [$this, 'getCurrentRoute'], ['is_safe' => ['html']]),
new TwigFunction('render_actions', [$this, 'renderActions'], ['is_safe' => ['html']]), new TwigFunction('render_actions', [$this, 'renderActions'], ['is_safe' => ['html']]),
new TwigFunction('render_cell', [$this, 'renderCell'], ['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 '<span class="badge ' . esc($color) . '">' . esc($label) . '</span>';
}
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 '<span class="badge ' . esc($color) . '">' . esc($label) . '</span>';
}
public function getAllRoles(): array
{
return \App\Services\AccessService::getAllRoles();
}
public function getSession() public function getSession()
{ {
return session(); return session();
@ -137,7 +273,6 @@ class TwigGlobalsExtension extends AbstractExtension
// DEBUG: логируем для отладки // DEBUG: логируем для отладки
log_message('debug', 'renderActions: item type = ' . gettype($item)); 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 keys = ' . (is_array($itemArray) ? implode(', ', array_keys($itemArray)) : 'N/A'));
log_message('debug', 'renderActions: item = ' . print_r($itemArray, true));
$html = '<div class="btn-group btn-group-sm">'; $html = '<div class="btn-group btn-group-sm">';
@ -148,12 +283,27 @@ class TwigGlobalsExtension extends AbstractExtension
$class = $action['class'] ?? 'btn-outline-secondary'; $class = $action['class'] ?? 'btn-outline-secondary';
$title = $action['title'] ?? $label; $title = $action['title'] ?? $label;
$target = $action['target'] ?? ''; $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 // Подставляем значения из item в URL
$url = $this->interpolate($urlPattern, $itemArray); $url = $this->interpolate($urlPattern, $itemArray);
log_message('debug', 'renderActions: urlPattern = ' . $urlPattern . ', url = ' . $url);
// Формируем HTML кнопки/ссылки // Формируем HTML кнопки/ссылки
$iconHtml = $icon ? '<i class="' . esc($icon) . '"></i> ' : ''; $iconHtml = $icon ? '<i class="' . esc($icon) . '"></i> ' : '';
$targetAttr = $target ? ' target="' . esc($target) . '"' : ''; $targetAttr = $target ? ' target="' . esc($target) . '"' : '';
@ -251,6 +401,22 @@ class TwigGlobalsExtension extends AbstractExtension
} }
return '<span class="badge bg-secondary">Нет</span>'; return '<span class="badge bg-secondary">Нет</span>';
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
? '<img src="' . esc($avatar) . '" alt="" style="width: 32px; height: 32px; object-fit: cover; border-radius: 50%;">'
: '<div class="bg-light rounded-circle d-flex align-items-center justify-content-center" style="width: 32px; height: 32px;"><i class="fa-solid fa-user text-muted small"></i></div>';
return '<div class="d-flex align-items-center gap-2">' . $avatarHtml . '<div><div class="fw-medium">' . esc($name ?: $email) . '</div>' . ($name ? '<div class="small text-muted">' . esc($email) . '</div>' : '') . '</div></div>';
case 'uppercase': case 'uppercase':
return $value ? esc(strtoupper($value)) : ($config['default'] ?? ''); return $value ? esc(strtoupper($value)) : ($config['default'] ?? '');

View File

@ -3,14 +3,148 @@
namespace App\Models; namespace App\Models;
use CodeIgniter\Model; use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class OrganizationUserModel extends Model class OrganizationUserModel extends Model
{ {
use TenantScopedModel;
protected $table = 'organization_users'; protected $table = 'organization_users';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = true; protected $useAutoIncrement = true;
protected $returnType = 'array'; protected $returnType = 'array';
protected $allowedFields = ['organization_id', 'user_id', 'role', 'status', 'joined_at']; protected $allowedFields = [
'organization_id',
'user_id',
'role',
'status',
'invite_token',
'invited_by',
'invited_at',
'joined_at',
];
protected $useTimestamps = false; // У нас есть только created_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]);
}
} }

View File

@ -0,0 +1,51 @@
<?php
namespace App\Models\Traits;
use CodeIgniter\Model;
/**
* Trait TenantScopedModel
*
* Обеспечивает автоматическую фильтрацию данных по активной организации.
* Использует organization_id из сессии пользователя.
*/
trait TenantScopedModel
{
/**
* Фильтрует запрос по текущей активной организации из сессии.
*
* Применение:
* $clients = $this->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;
}
}

View File

@ -3,9 +3,12 @@
namespace App\Models; namespace App\Models;
use CodeIgniter\Model; use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class UserModel extends Model class UserModel extends Model
{ {
use TenantScopedModel;
protected $table = 'users'; protected $table = 'users';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = true; protected $useAutoIncrement = true;

View File

@ -4,10 +4,11 @@ namespace App\Modules\Clients\Controllers;
use App\Controllers\BaseController; use App\Controllers\BaseController;
use App\Modules\Clients\Models\ClientModel; use App\Modules\Clients\Models\ClientModel;
use App\Services\AccessService;
class Clients extends BaseController class Clients extends BaseController
{ {
protected $clientModel; protected ClientModel $clientModel;
public function __construct() public function __construct()
{ {
@ -16,11 +17,19 @@ class Clients extends BaseController
public function index() public function index()
{ {
// Проверка права на просмотр
if (!$this->access->canView('clients')) {
return $this->forbiddenResponse('У вас нет прав для просмотра клиентов');
}
$config = $this->getTableConfig(); $config = $this->getTableConfig();
return $this->renderTwig('@Clients/index', [ return $this->renderTwig('@Clients/index', [
'title' => 'Клиенты', 'title' => 'Клиенты',
'tableHtml' => $this->renderTable($config), '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 protected function getTableConfig(): array
{ {
$organizationId = session()->get('active_org_id');
return [ return [
'id' => 'clients-table', 'id' => 'clients-table',
'url' => '/clients/table', 'url' => '/clients/table',
@ -51,14 +58,16 @@ class Clients extends BaseController
'url' => '/clients/edit/{id}', 'url' => '/clients/edit/{id}',
'icon' => 'fa-solid fa-pen', 'icon' => 'fa-solid fa-pen',
'class' => 'btn-outline-primary', 'class' => 'btn-outline-primary',
'title' => 'Редактировать' 'title' => 'Редактировать',
'type' => 'edit',
], ],
[ [
'label' => '', 'label' => '',
'url' => '/clients/delete/{id}', 'url' => '/clients/delete/{id}',
'icon' => 'fa-solid fa-trash', 'icon' => 'fa-solid fa-trash',
'class' => 'btn-outline-danger', 'class' => 'btn-outline-danger',
'title' => 'Удалить' 'title' => 'Удалить',
'type' => 'delete',
] ]
], ],
'emptyMessage' => 'Клиентов пока нет', 'emptyMessage' => 'Клиентов пока нет',
@ -66,33 +75,28 @@ class Clients extends BaseController
'emptyActionUrl' => base_url('/clients/new'), 'emptyActionUrl' => base_url('/clients/new'),
'emptyActionLabel'=> 'Добавить клиента', 'emptyActionLabel'=> 'Добавить клиента',
'emptyActionIcon' => 'fa-solid fa-plus', 'emptyActionIcon' => 'fa-solid fa-plus',
'scope' => function ($builder) use ($organizationId) { 'can_edit' => $this->access->canEdit('clients'),
$builder->where('organization_id', $organizationId); '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 (!$this->access->canView('clients')) {
if ($isPartial) { return $this->forbiddenResponse('У вас нет прав для просмотра клиентов');
// AJAX — только tbody + tfoot
return $this->renderTable(null, true);
} }
// Прямой запрос — полная страница return parent::table($config, '/clients');
$config = $this->getTableConfig();
$tableHtml = $this->renderTable($config, false);
return $this->renderTwig('@Clients/index', [
'title' => $config['pageTitle'] ?? 'Клиенты',
'tableHtml' => $tableHtml,
]);
} }
public function new() public function new()
{ {
// Проверка права на создание
if (!$this->access->canCreate('clients')) {
return $this->forbiddenResponse('У вас нет прав для создания клиентов');
}
$data = [ $data = [
'title' => 'Добавить клиента', 'title' => 'Добавить клиента',
'client' => null, 'client' => null,
@ -103,6 +107,11 @@ class Clients extends BaseController
public function create() public function create()
{ {
// Проверка права на создание
if (!$this->access->canCreate('clients')) {
return $this->forbiddenResponse('У вас нет прав для создания клиентов');
}
$organizationId = session()->get('active_org_id'); $organizationId = session()->get('active_org_id');
$rules = [ $rules = [
@ -133,12 +142,12 @@ class Clients extends BaseController
public function edit($id) public function edit($id)
{ {
$organizationId = session()->get('active_org_id'); // Проверка права на редактирование
if (!$this->access->canEdit('clients')) {
return $this->forbiddenResponse('У вас нет прав для редактирования клиентов');
}
$client = $this->clientModel $client = $this->clientModel->forCurrentOrg()->find($id);
->where('id', $id)
->where('organization_id', $organizationId)
->first();
if (!$client) { if (!$client) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден');
@ -154,13 +163,13 @@ class Clients extends BaseController
public function update($id) public function update($id)
{ {
$organizationId = session()->get('active_org_id'); // Проверка права на редактирование
if (!$this->access->canEdit('clients')) {
return $this->forbiddenResponse('У вас нет прав для редактирования клиентов');
}
// Проверяем что клиент принадлежит организации // Проверяем что клиент принадлежит организации через forCurrentOrg()
$client = $this->clientModel $client = $this->clientModel->forCurrentOrg()->find($id);
->where('id', $id)
->where('organization_id', $organizationId)
->first();
if (!$client) { if (!$client) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден');
@ -193,13 +202,13 @@ class Clients extends BaseController
public function delete($id) public function delete($id)
{ {
$organizationId = session()->get('active_org_id'); // Проверка права на удаление
if (!$this->access->canDelete('clients')) {
return $this->forbiddenResponse('У вас нет прав для удаления клиентов');
}
// Проверяем что клиент принадлежит организации // Проверяем что клиент принадлежит организации через forCurrentOrg()
$client = $this->clientModel $client = $this->clientModel->forCurrentOrg()->find($id);
->where('id', $id)
->where('organization_id', $organizationId)
->first();
if (!$client) { if (!$client) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден'); throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('Клиент не найден');
@ -210,4 +219,22 @@ class Clients extends BaseController
session()->setFlashdata('success', 'Клиент удалён'); session()->setFlashdata('success', 'Клиент удалён');
return redirect()->to('/clients'); 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('/');
}
} }

View File

@ -3,9 +3,12 @@
namespace App\Modules\Clients\Models; namespace App\Modules\Clients\Models;
use CodeIgniter\Model; use CodeIgniter\Model;
use App\Models\Traits\TenantScopedModel;
class ClientModel extends Model class ClientModel extends Model
{ {
use TenantScopedModel;
protected $table = 'organizations_clients'; protected $table = 'organizations_clients';
protected $primaryKey = 'id'; protected $primaryKey = 'id';
protected $useAutoIncrement = true; protected $useAutoIncrement = true;

View File

@ -0,0 +1,462 @@
<?php
namespace App\Services;
use App\Models\OrganizationUserModel;
use CodeIgniter\Database\Exceptions\DataException;
/**
* AccessService - Сервис проверки прав доступа (RBAC)
*
* Управляет правами внутренних пользователей организации.
* Для внешних пользователей (публичные ссылки) используется ExternalAccessService.
*/
class AccessService
{
private ?array $currentMembership = null;
private OrganizationUserModel $orgUserModel;
/**
* Роли и их уровни (для быстрого сравнения)
*/
public const ROLE_OWNER = 'owner';
public const ROLE_ADMIN = 'admin';
public const ROLE_MANAGER = 'manager';
public const ROLE_GUEST = 'guest';
/**
* Иерархия ролей (больше = выше привилегии)
*/
public const ROLE_HIERARCHY = [
self::ROLE_OWNER => 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,
],
];
}
}

View File

@ -0,0 +1,383 @@
<?php
namespace App\Services;
use App\Models\OrganizationUserModel;
use App\Models\OrganizationModel;
use App\Models\UserModel;
use Config\Services;
class InvitationService
{
protected OrganizationUserModel $orgUserModel;
protected OrganizationModel $orgModel;
protected UserModel $userModel;
protected string $baseUrl;
public function __construct()
{
$this->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 = <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f9fafb; padding: 20px; border: 1px solid #e5e7eb; }
.role-badge { display: inline-block; background: #EEF2FF; color: #4F46E5; padding: 4px 12px; border-radius: 20px; font-size: 14px; }
.button { display: inline-block; background: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
.footer { text-align: center; color: #6b7280; font-size: 12px; padding: 20px; }
.invite-link { word-break: break-all; font-size: 12px; color: #6b7280; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Приглашение в Бизнес.Точка</h1>
</div>
<div class="content">
<p>Вас приглашают присоединиться к организации <strong>{$orgName}</strong></p>
<p>Ваша роль: <span class="role-badge">{$roleLabel}</span></p>
<p>Нажмите кнопку ниже, чтобы принять или отклонить приглашение:</p>
<p style="text-align: center;">
<a href="{$inviteLink}" class="button">Принять приглашение</a>
</p>
<p>Если кнопка не работает, скопируйте ссылку и откройте в браузере:</p>
<p class="invite-link">{$inviteLink}</p>
<p>Ссылка действительна 48 часов.</p>
</div>
<div class="footer">
<p>© Бизнес.Точка</p>
</div>
</div>
</body>
</html>
HTML;
$emailService->setMessage($message);
return $emailService->send();
}
}

View File

@ -0,0 +1,428 @@
<?php
namespace App\Services;
use CodeIgniter\Config\Services as BaseServices;
use Redis;
use RuntimeException;
/**
* RateLimitService - Сервис для ограничения частоты запросов и защиты от брутфорса
*
* Использует Redis для хранения счётчиков попыток и блокировок.
* Все ограничения применяются по IP-адресу клиента.
*
* @property \Redis $redis
*/
class RateLimitService
{
/**
* @var \Redis
*/
private $redis;
private string $prefix;
private array $config;
/**
* Конструктор сервиса
*
* @param \Redis $redis Экземпляр Redis-подключения
* @param string $prefix Префикс для всех ключей в Redis
* @param array $config Конфигурация ограничений
*/
public function __construct(\Redis $redis, string $prefix = 'rl:', array $config = [])
{
$this->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;
}
}
}

View File

@ -12,7 +12,27 @@
{# Колонка действий #} {# Колонка действий #}
{% if actionsConfig is defined and actionsConfig|length > 0 %} {% if actionsConfig is defined and actionsConfig|length > 0 %}
<td class="actions-cell text-end"> <td class="actions-cell text-end">
{{ 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 %}
<span class="text-muted small">—</span>
{% endif %}
</td> </td>
{% endif %} {% endif %}
</tr> </tr>

View File

@ -18,6 +18,8 @@
{ label: 'Ред.', url: '/clients/edit/{id}', icon: 'bi bi-pencil', class: 'btn-outline-primary' }, { 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' } { label: 'Удалить', url: '/clients/delete/{id}', icon: 'bi bi-trash', class: 'btn-outline-danger' }
] ]
- can_edit: Разрешено ли редактирование (для фильтрации действий)
- can_delete: Разрешено ли удаление (для фильтрации действий)
- emptyMessage: Сообщение при отсутствии данных - emptyMessage: Сообщение при отсутствии данных
- emptyActionUrl: URL для кнопки действия - emptyActionUrl: URL для кнопки действия
- emptyActionLabel: Текст кнопки - emptyActionLabel: Текст кнопки
@ -50,7 +52,27 @@
{# Колонка действий #} {# Колонка действий #}
{% if actionsConfig is defined and actionsConfig|length > 0 %} {% if actionsConfig is defined and actionsConfig|length > 0 %}
<td class="actions-cell text-end"> <td class="actions-cell text-end">
{{ 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 %}
<span class="text-muted small">—</span>
{% endif %}
</td> </td>
{% endif %} {% endif %}
</tr> </tr>

View File

@ -106,10 +106,10 @@
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<!-- Ссылка: Настройки этой организации (будущий функционал) --> <!-- Ссылка: Дашборд управления организацией -->
<li> <li>
<a class="dropdown-item text-muted" href="#"> <a class="dropdown-item" href="{{ base_url('/organizations/' ~ current_org.id ~ '/dashboard') }}">
<i class="fa-solid fa-gear me-2"></i> Настройки текущей <i class="fa-solid fa-sliders text-primary me-2"></i> Управление организацией
</a> </a>
</li> </li>
</ul> </ul>

View File

@ -0,0 +1,20 @@
{#
organizations/confirm_modal.twig - Модальное окно подтверждения действия
#}
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="confirmModalTitle">Подтверждение действия</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="confirmModalMessage" class="mb-0">Вы уверены?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" id="confirmModalBtn">Подтвердить</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,181 @@
{% extends 'layouts/base.twig' %}
{% block title %}Управление организацией - {{ organization.name }} - {{ parent() }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
{# Заголовок #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">Управление организацией</h1>
<h3 class="text-muted mb-0">{{ organization.name }}</h3>
</div>
<div class="text-end">
{{ role_badge(current_role) }}
</div>
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-primary">{{ stats.users_total }}</div>
<div class="text-muted">Всего участников</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-success">{{ stats.users_active }}</div>
<div class="text-muted">Активных</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<div class="display-6 fw-bold text-warning">{{ stats.users_blocked }}</div>
<div class="text-muted">Заблокировано</div>
</div>
</div>
</div>
</div>
{# Карточки управления #}
<div class="row g-4">
{# Управление командой #}
{% if can_manage_users %}
<div class="col-md-6 col-lg-4">
<a href="{{ base_url('/organizations/'~ organization.id ~ '/users' ) }}" class="card h-100 text-decoration-none border-0 shadow-sm card-hover">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-primary bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-users-gear fs-3 text-primary"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Управление командой</h5>
<p class="card-text text-muted small mb-0">Приглашайте, блокируйте и управляйте ролями участников организации</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-primary fw-medium">Перейти <i class="fa-solid fa-arrow-right ms-1"></i></span>
</div>
</a>
</div>
{% endif %}
{# Редактирование организации #}
<div class="col-md-6 col-lg-4">
<a href="{{ base_url('/organizations/edit/' ~ organization.id) }}" class="card h-100 text-decoration-none border-0 shadow-sm card-hover">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-info bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-building fs-3 text-info"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Реквизиты организации</h5>
<p class="card-text text-muted small mb-0">Измените название, адрес, банковские реквизиты и другую информацию</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-info fw-medium">Редактировать <i class="fa-solid fa-arrow-right ms-1"></i></span>
</div>
</a>
</div>
{# Модули организации - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-secondary bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-puzzle fs-3 text-secondary"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Модули</h5>
<p class="card-text text-muted small mb-0">Управление подключёнными модулями и функционалом организации</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
{# Биллинг - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-success bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-credit-card fs-3 text-success"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Биллинг и оплата</h5>
<p class="card-text text-muted small mb-0">Просмотр счетов, история платежей и управление подпиской</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
{# Приглашения - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-warning bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-envelope-open-text fs-3 text-warning"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">История приглашений</h5>
<p class="card-text text-muted small mb-0">Просмотр отправленных и отклонённых приглашений</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
{# Настройки безопасности - заглушка #}
<div class="col-md-6 col-lg-4">
<div class="card h-100 text-decoration-none border-0 shadow-sm bg-light">
<div class="card-body p-4">
<div class="d-flex align-items-start">
<div class="bg-danger bg-opacity-10 rounded-3 p-3 me-3">
<i class="fa-solid fa-shield-halved fs-3 text-danger"></i>
</div>
<div>
<h5 class="card-title mb-1 text-dark">Безопасность</h5>
<p class="card-text text-muted small mb-0">Настройки безопасности, двухфакторная аутентификация, логи</p>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-muted fw-medium">Скоро <i class="fa-solid fa-clock ms-1"></i></span>
</div>
</div>
</div>
</div>
</div>
<style>
.card-hover {
transition: all 0.2s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
</style>
{% endblock %}

View File

@ -0,0 +1,55 @@
{#
organizations/edit_role_modal.twig - Модальное окно изменения роли пользователя
#}
<div class="modal fade" id="editRoleModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fa-solid fa-user-tag me-2"></i>Изменить роль
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Пользователь: <strong id="editRoleUserEmail"></strong></p>
<div class="mb-3">
<label for="editRoleSelect" class="form-label">Роль</label>
<select class="form-select" id="editRoleSelect">
{% for role_value, role_info in get_all_roles() %}
{% if role_value != 'owner' %}
<option value="{{ role_value }}">{{ role_info.label }}</option>
{% endif %}
{% endfor %}
</select>
<div class="form-text" id="roleDescription"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-primary" onclick="saveRole()">
<i class="fa-solid fa-save me-2"></i>Сохранить
</button>
</div>
</div>
</div>
</div>
<script>
const roleDescriptions = {
'admin': 'Администратор может управлять пользователями и модулями организации',
'manager': 'Менеджер имеет полный доступ к функционалу модулей',
'guest': 'Гость может только просматривать данные'
};
document.getElementById('editRoleSelect').addEventListener('change', function() {
const desc = roleDescriptions[this.value] || '';
document.getElementById('roleDescription').textContent = desc;
});
// Инициализация при открытии
document.getElementById('editRoleModal').addEventListener('shown.bs.modal', function() {
const role = document.getElementById('editRoleSelect').value;
document.getElementById('roleDescription').textContent = roleDescriptions[role] || '';
});
</script>

View File

@ -0,0 +1,107 @@
{#
organizations/invitation_accept.twig - Страница принятия/отклонения приглашения
#}
{% extends 'layouts/landing.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center py-5" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
{# Логотип организации #}
<div class="text-center mb-4">
{% if organization.logo %}
<img src="{{ organization.logo }}" alt="{{ organization.name }}" style="max-height: 60px;">
{% else %}
<div class="bg-primary text-white rounded-3 d-inline-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
<i class="fa-solid fa-building fs-3"></i>
</div>
{% endif %}
</div>
<h2 class="text-center mb-4">Приглашение в организацию</h2>
{# Информация об организации #}
<div class="text-center mb-4">
<h4 class="mb-1">{{ organization.name }}</h4>
<span class="badge bg-primary fs-6">{{ role_label }}</span>
</div>
<p class="text-center text-muted mb-4">
Вас пригласили присоединиться к организации "{{ organization.name }}"
</p>
{% if invited_by %}
<p class="text-center text-muted small mb-4">
Приглашение отправил: {{ invited_by.name|default(invited_by.email) }}
</p>
{% endif %}
{# Если пользователь не авторизован #}
{% if not is_logged_in %}
<div class="alert alert-warning">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
Для принятия приглашения необходимо войти в аккаунт или зарегистрироваться
</div>
{% endif %}
{# Если авторизован, но email не совпадает #}
{% if is_logged_in and not email_matches %}
<div class="alert alert-warning">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
Внимание! Вы вошли как <strong>{{ get_session('email') }}</strong>,
а приглашение отправлено на другой email.
</div>
{% endif %}
{# Форма принятия/отклонения #}
<form action="/invitation/accept/{{ token }}" method="POST">
<input type="hidden" name="<?= csrf_token() ?>" value="<?= csrf_hash() ?>">
<input type="hidden" name="action" value="accept">
<div class="d-flex gap-3">
<button type="submit" class="btn btn-primary flex-grow-1">
<i class="fa-solid fa-check me-2"></i>Принять
</button>
<button type="submit"
name="action"
value="decline"
class="btn btn-outline-danger flex-grow-1">
<i class="fa-solid fa-xmark me-2"></i>Отклонить
</button>
</div>
</form>
{# Ссылка на вход/регистрацию #}
{% if not is_logged_in %}
<div class="text-center mt-4">
<a href="/login?redirect={{ url_encode('/invitation/accept/' ~ token) }}" class="text-muted">
<i class="fa-solid fa-sign-in-alt me-1"></i>Войти в аккаунт
</a>
<span class="text-muted mx-2">|</span>
<a href="/register?redirect={{ url_encode('/invitation/accept/' ~ token) }}" class="text-muted">
<i class="fa-solid fa-user-plus me-1"></i>Регистрация
</a>
</div>
{% endif %}
<div class="text-center mt-4 text-muted small">
<i class="fa-regular fa-clock me-1"></i>
Приглашение действительно 48 часов
</div>
</div>
</div>
{# Футер #}
<div class="text-center mt-4 text-white-50 small">
&copy; {{ "now"|date("Y") }} Бизнес.Точка
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,114 @@
{#
organizations/invitation_complete.twig - Страница завершения регистрации нового пользователя
#}
{% extends 'layouts/landing.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center py-5" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5">
{# Логотип организации #}
<div class="text-center mb-4">
{% if organization.logo %}
<img src="{{ organization.logo }}" alt="{{ organization.name }}" style="max-height: 60px;">
{% else %}
<div class="bg-primary text-white rounded-3 d-inline-flex align-items-center justify-content-center" style="width: 60px; height: 60px;">
<i class="fa-solid fa-building fs-3"></i>
</div>
{% endif %}
</div>
<h2 class="text-center mb-3">Завершение регистрации</h2>
<p class="text-center text-muted mb-4">
Вы приняли приглашение в организацию "{{ organization.name }}"
<br>
в роли <span class="badge bg-primary">{{ role_label }}</span>
</p>
<p class="text-center text-muted mb-4">
Пожалуйста, создайте пароль для вашего аккаунта
</p>
{# Ошибки валидации #}
{% if get_alerts()|filter(a => a.type == 'error')|length > 0 %}
<div class="alert alert-danger">
{% for alert in get_alerts() %}
{% if alert.type == 'error' %}
<div>{{ alert.message }}</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{# Форма регистрации #}
<form action="/invitation/complete/{{ token }}" method="POST">
<input type="hidden" name="<?= csrf_token() ?>" value="<?= csrf_hash() ?>">
<div class="mb-3">
<label for="completeName" class="form-label">Ваше имя</label>
<input type="text"
class="form-control {% if old.name is defined and old.name is empty %}is-invalid{% endif %}"
id="completeName"
name="name"
value="{{ old.name|default('') }}"
placeholder="Иван Иванов"
required>
{% if old.name is defined and old.name is empty %}
<div class="invalid-feedback">Имя обязательно</div>
{% endif %}
</div>
<div class="mb-3">
<label for="completeEmail" class="form-label">Email</label>
<input type="email"
class="form-control"
id="completeEmail"
value="{{ email }}"
disabled>
<div class="form-text">Email подтверждён через приглашение</div>
</div>
<div class="mb-3">
<label for="completePassword" class="form-label">Пароль</label>
<input type="password"
class="form-control"
id="completePassword"
name="password"
minlength="8"
required>
<div class="form-text">Минимум 8 символов</div>
</div>
<div class="mb-4">
<label for="completePasswordConfirm" class="form-label">Подтвердите пароль</label>
<input type="password"
class="form-control"
id="completePasswordConfirm"
name="password_confirm"
required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fa-solid fa-check me-2"></i>Завершить регистрацию
</button>
</div>
</form>
</div>
</div>
{# Футер #}
<div class="text-center mt-4 text-white-50 small">
&copy; {{ "now"|date("Y") }} Бизнес.Точка
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,45 @@
{#
organizations/invitation_expired.twig - Страница истёкшего/недействительного приглашения
#}
{% extends 'layouts/landing.twig' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="min-vh-100 d-flex align-items-center justify-content-center py-5" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-lg border-0">
<div class="card-body p-5 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
</div>
<h3 class="mb-3">{{ title }}</h3>
<p class="text-muted mb-4">
Это приглашение недействительно или уже было обработано.<br>
Возможно, оно истекло или было отозвано отправителем.
</p>
<div class="d-grid gap-2">
<a href="/" class="btn btn-primary">
<i class="fa-solid fa-home me-2"></i>На главную
</a>
<a href="/login" class="btn btn-outline-secondary">
<i class="fa-solid fa-sign-in-alt me-2"></i>Войти в аккаунт
</a>
</div>
</div>
</div>
{# Футер #}
<div class="text-center mt-4 text-white-50 small">
&copy; {{ "now"|date("Y") }} Бизнес.Точка
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,101 @@
{#
organizations/invite_modal.twig - Модальное окно приглашения пользователя
#}
<div class="modal fade" id="inviteUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fa-solid fa-user-plus me-2"></i>Пригласить пользователя
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="inviteUserForm" action="/organizations/users/{{ organization_id }}/invite" method="POST">
<input type="hidden" name="<?= csrf_token() ?>" value="<?= csrf_hash() ?>">
<div class="mb-3">
<label for="inviteEmail" class="form-label">Email адрес</label>
<input type="email"
class="form-control"
id="inviteEmail"
name="email"
placeholder="user@example.com"
required>
<div class="form-text">На этот адрес будет отправлено приглашение</div>
</div>
<div class="mb-3">
<label for="inviteRole" class="form-label">Роль</label>
<select class="form-select" id="inviteRole" name="role" required>
{% for role_value, role_info in get_all_roles() %}
{% if role_value != 'owner' %}
<option value="{{ role_value }}">{{ role_info.label }}</option>
{% endif %}
{% endfor %}
</select>
<div class="form-text">{{ get_all_roles()[current_role].description|default('') }}</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-paper-plane me-2"></i>Отправить приглашение
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{# Модалка с ссылкой приглашения #}
<div class="modal fade" id="inviteLinkModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fa-solid fa-check-circle text-success me-2"></i>Приглашение отправлено
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Приглашение успешно отправлено на <strong id="inviteEmailDisplay"></strong></p>
<div class="mb-3">
<label class="form-label">Ссылка для приглашения</label>
<div class="input-group">
<input type="text"
class="form-control"
id="inviteLinkInput"
readonly>
<button type="button"
class="btn btn-outline-secondary"
id="copyLinkBtn"
onclick="copyInviteLink()"
title="Копировать">
<i class="fa-regular fa-copy"></i>
</button>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary" onclick="shareViaWebShare()">
<i class="fa-solid fa-share-nodes me-1"></i>Поделиться
</button>
<a href="#" onclick="shareToTelegram(); return false;" class="btn btn-outline-primary">
<i class="fa-brands fa-telegram me-1"></i>Telegram
</a>
</div>
<div class="alert alert-info mt-3 mb-0">
<i class="fa-solid fa-circle-info me-2"></i>
Если email не дошёл, можно скопировать ссылку и отправить другим способом
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,328 @@
{#
organizations/users.twig - Страница управления пользователями организации
#}
{% extends 'layouts/base.twig' %}
{% block title %}Участники организации - {{ parent() }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
{# Заголовок #}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">Участники организации</h1>
<h3 class="text-muted mb-0">{{ organization.name }}</h3>
</div>
{% if can_manage_users %}
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#inviteUserModal">
<i class="fa-solid fa-user-plus me-2"></i>Пригласить пользователя
</button>
{% endif %}
</div>
{# Статистика #}
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Всего участников</div>
<div class="fs-4 fw-bold">{{ users|length }}</div>
</div>
<i class="fa-solid fa-users fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Активных</div>
<div class="fs-4 fw-bold">{{ users|filter(u => u.status == 'active')|length }}</div>
</div>
<i class="fa-solid fa-check-circle fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Ожидают ответа</div>
<div class="fs-4 fw-bold">{{ users|filter(u => u.status == 'pending')|length }}</div>
</div>
<i class="fa-solid fa-clock fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-danger text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="small opacity-75">Заблокировано</div>
<div class="fs-4 fw-bold">{{ users|filter(u => u.status == 'blocked')|length }}</div>
</div>
<i class="fa-solid fa-ban fs-1 opacity-25"></i>
</div>
</div>
</div>
</div>
</div>
{# Таблица пользователей #}
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
{# Кнопка выхода из организации #}
{% if current_role != 'owner' %}
<div class="mt-4">
<button type="button" class="btn btn-outline-danger" onclick="leaveOrganization()">
<i class="fa-solid fa-sign-out-alt me-2"></i>Покинуть организацию
</button>
</div>
{% endif %}
</div>
{# Модалка приглашения #}
{% include 'organizations/invite_modal.twig' %}
{# Модалка подтверждения действий #}
{% include 'organizations/confirm_modal.twig' %}
{% endblock %}
{% block stylesheets %}
{{ parent() }}
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
{% endblock %}
{% block scripts %}
{{ parent() }}
<script src="/assets/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
<script>
// Функции для действий с пользователями
function openEditRoleModal(userId, email, currentRole) {
document.getElementById('editRoleUserId').value = userId;
document.getElementById('editRoleUserEmail').textContent = email;
document.getElementById('editRoleSelect').value = currentRole;
const modal = new bootstrap.Modal(document.getElementById('editRoleModal'));
modal.show();
}
function saveRole() {
const userId = document.getElementById('editRoleUserId').value;
const role = document.getElementById('editRoleSelect').value;
const form = document.createElement('form');
form.method = 'POST';
form.action = '/organizations/users/' + {{ organization_id }} + '/role';
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '<?= csrf_token() ?>';
tokenInput.value = '<?= csrf_hash() ?>';
form.appendChild(tokenInput);
const userIdInput = document.createElement('input');
userIdInput.type = 'hidden';
userIdInput.name = 'user_id';
userIdInput.value = userId;
form.appendChild(userIdInput);
const roleInput = document.createElement('input');
roleInput.type = 'hidden';
roleInput.name = 'role';
roleInput.value = role;
form.appendChild(roleInput);
document.body.appendChild(form);
form.submit();
}
function blockUser(userId) {
showConfirmModal(
'Блокировка пользователя',
'Вы уверены, что хотите заблокировать этого пользователя? Он потеряет доступ к организации.',
'/organizations/users/' + {{ organization_id }} + '/block/' + userId
);
}
function unblockUser(userId) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/organizations/users/' + {{ organization_id }} + '/unblock/' + userId;
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '<?= csrf_token() ?>';
tokenInput.value = '<?= csrf_hash() ?>';
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
}
function removeUser(userId) {
showConfirmModal(
'Удаление пользователя',
'Вы уверены, что хотите удалить этого пользователя из организации? Это действие нельзя отменить.',
'/organizations/users/' + {{ organization_id }} + '/remove/' + userId
);
}
function leaveOrganization() {
showConfirmModal(
'Покинуть организацию',
'Вы уверены, что хотите покинуть эту организацию?',
'/organizations/users/' + {{ organization_id }} + '/leave',
true
);
}
// Функция показа модалки подтверждения
function showConfirmModal(title, message, actionUrl, isDanger) {
document.getElementById('confirmModalTitle').textContent = title;
document.getElementById('confirmModalMessage').textContent = message;
const btn = document.getElementById('confirmModalBtn');
btn.onclick = function() {
const form = document.createElement('form');
form.method = 'POST';
form.action = actionUrl;
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = '<?= csrf_token() ?>';
tokenInput.value = '<?= csrf_hash() ?>';
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
};
if (isDanger) {
btn.className = 'btn btn-danger';
} else {
btn.className = 'btn btn-primary';
}
const modal = new bootstrap.Modal(document.getElementById('confirmModal'));
modal.show();
}
// Обработка отправки приглашения
document.getElementById('inviteUserForm').addEventListener('submit', function(e) {
e.preventDefault();
const form = this;
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin me-2"></i>Отправка...';
submitBtn.disabled = true;
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Показываем ссылку приглашения
showInviteLinkModal(data.invite_link, data.email);
form.reset();
// Перезагружаем страницу чтобы увидеть нового пользователя
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
alert(data.message || 'Ошибка при отправке приглашения');
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка');
})
.finally(() => {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
});
});
function showInviteLinkModal(link, email) {
document.getElementById('inviteLinkInput').value = link;
document.getElementById('inviteEmailDisplay').textContent = email;
// Скрываем модалку приглашения
bootstrap.Modal.getInstance(document.getElementById('inviteUserModal')).hide();
// Показываем модалку со ссылкой
const modal = new bootstrap.Modal(document.getElementById('inviteLinkModal'));
modal.show();
}
function copyInviteLink() {
const input = document.getElementById('inviteLinkInput');
input.select();
input.setSelectionRange(0, 99999);
navigator.clipboard.writeText(input.value).then(() => {
const btn = document.getElementById('copyLinkBtn');
const originalIcon = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => {
btn.innerHTML = originalIcon;
}, 2000);
});
}
function shareToTelegram() {
const link = document.getElementById('inviteLinkInput').value;
const text = encodeURIComponent('Присоединяйся к нашей организации!');
window.open('https://t.me/share/url?url=' + encodeURIComponent(link) + '&text=' + text, '_blank');
}
function shareViaWebShare() {
const link = document.getElementById('inviteLinkInput').value;
if (navigator.share) {
navigator.share({
title: 'Приглашение в Бизнес.Точка',
text: 'Присоединяйся к нашей организации!',
url: link
});
}
}
</script>
{% endblock %}