dashboard for org
This commit is contained in:
parent
d27f66953c
commit
246ca93307
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'] ?? '');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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">
|
||||||
|
© {{ "now"|date("Y") }} Бизнес.Точка
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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">
|
||||||
|
© {{ "now"|date("Y") }} Бизнес.Точка
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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">
|
||||||
|
© {{ "now"|date("Y") }} Бизнес.Точка
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 %}
|
||||||
Loading…
Reference in New Issue