dashboard for org
This commit is contained in:
+236
-10
@@ -6,22 +6,147 @@ use App\Models\UserModel;
|
||||
use App\Models\OrganizationModel;
|
||||
use App\Models\OrganizationUserModel;
|
||||
use App\Libraries\EmailLibrary;
|
||||
use App\Services\RateLimitService;
|
||||
|
||||
class Auth extends BaseController
|
||||
{
|
||||
protected $emailLibrary;
|
||||
protected ?RateLimitService $rateLimitService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->emailLibrary = new EmailLibrary();
|
||||
|
||||
// Инициализируем rate limiting сервис (может быть null если Redis недоступен)
|
||||
try {
|
||||
$this->rateLimitService = RateLimitService::getInstance();
|
||||
} catch (\Exception $e) {
|
||||
// Если Redis недоступен - логируем и продолжаем без rate limiting
|
||||
log_message('warning', 'RateLimitService недоступен: ' . $e->getMessage());
|
||||
$this->rateLimitService = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка rate limiting перед действием
|
||||
*
|
||||
* @param string $action Тип действия (login, register, reset)
|
||||
* @return array|null Возвращает данные об ошибке если заблокирован, иначе null
|
||||
*/
|
||||
protected function checkRateLimit(string $action): ?array
|
||||
{
|
||||
// Если сервис недоступен - пропускаем проверку
|
||||
if ($this->rateLimitService === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Проверяем блокировку
|
||||
if ($this->rateLimitService->isBlocked($action)) {
|
||||
$ttl = $this->rateLimitService->getBlockTimeLeft($action);
|
||||
return [
|
||||
'blocked' => true,
|
||||
'message' => "Слишком много попыток. Повторите через {$ttl} секунд.",
|
||||
'ttl' => $ttl,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись неудачной попытки
|
||||
*
|
||||
* @param string $action Тип действия
|
||||
* @return array|null Данные о превышении лимита или null
|
||||
*/
|
||||
protected function recordFailedAttempt(string $action): ?array
|
||||
{
|
||||
// Если сервис недоступен - пропускаем
|
||||
if ($this->rateLimitService === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$result = $this->rateLimitService->recordFailedAttempt($action);
|
||||
|
||||
// Если после записи попыток превышен лимит - возвращаем данные для отображения
|
||||
if ($result['blocked']) {
|
||||
return [
|
||||
'blocked' => true,
|
||||
'message' => "Превышено максимальное количество попыток. Доступ заблокирован на {$result['block_ttl']} секунд.",
|
||||
'ttl' => $result['block_ttl'],
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс счётчика после успешного действия
|
||||
*
|
||||
* @param string $action Тип действия
|
||||
* @return void
|
||||
*/
|
||||
protected function resetRateLimit(string $action): void
|
||||
{
|
||||
if ($this->rateLimitService !== null) {
|
||||
$this->rateLimitService->resetAttempts($action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирование времени блокировки для отображения
|
||||
*
|
||||
* @param int $seconds Секунды
|
||||
* @return string
|
||||
*/
|
||||
protected function formatBlockTime(int $seconds): string
|
||||
{
|
||||
if ($seconds >= 60) {
|
||||
$minutes = ceil($seconds / 60);
|
||||
return $minutes . ' ' . $this->pluralize($minutes, ['минуту', 'минуты', 'минут']);
|
||||
}
|
||||
return $seconds . ' ' . $this->pluralize($seconds, ['секунду', 'секунды', 'секунд']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Склонение окончаний для чисел
|
||||
*
|
||||
* @param int $number
|
||||
* @param array $forms [одна, две, пять]
|
||||
* @return string
|
||||
*/
|
||||
protected function pluralize(int $number, array $forms): string
|
||||
{
|
||||
$abs = abs($number);
|
||||
$mod = $abs % 10;
|
||||
|
||||
if ($abs % 100 >= 11 && $abs % 100 <= 19) {
|
||||
return $forms[2];
|
||||
}
|
||||
if ($mod === 1) {
|
||||
return $forms[0];
|
||||
}
|
||||
if ($mod >= 2 && $mod <= 4) {
|
||||
return $forms[1];
|
||||
}
|
||||
return $forms[2];
|
||||
}
|
||||
|
||||
public function register()
|
||||
{
|
||||
if ($this->request->getMethod() === 'POST') {
|
||||
|
||||
// === ПРОВЕРКА RATE LIMITING ===
|
||||
$rateLimitError = $this->checkRateLimit('register');
|
||||
if ($rateLimitError !== null) {
|
||||
return redirect()->back()
|
||||
->with('error', $rateLimitError['message'])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
log_message('debug', 'POST запрос получен: ' . print_r($this->request->getPost(), true));
|
||||
// Валидация (упрощенная для примера)
|
||||
|
||||
// Валидация
|
||||
$rules = [
|
||||
'name' => 'required|min_length[3]',
|
||||
'email' => 'required|valid_email|is_unique[users.email]',
|
||||
@@ -49,11 +174,11 @@ class Auth extends BaseController
|
||||
'verification_token' => $verificationToken,
|
||||
'email_verified' => 0,
|
||||
];
|
||||
|
||||
|
||||
log_message('debug', 'Registration userData: ' . print_r($userData, true));
|
||||
|
||||
$userId = $userModel->insert($userData);
|
||||
|
||||
|
||||
log_message('debug', 'Insert result, userId: ' . $userId);
|
||||
|
||||
|
||||
@@ -81,6 +206,9 @@ class Auth extends BaseController
|
||||
$verificationToken
|
||||
);
|
||||
|
||||
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ РЕГИСТРАЦИИ ===
|
||||
$this->resetRateLimit('register');
|
||||
|
||||
// 5. Показываем сообщение о необходимости подтверждения
|
||||
session()->setFlashdata('success', 'Регистрация успешна! Пожалуйста, проверьте вашу почту и подтвердите email.');
|
||||
return redirect()->to('/register/success');
|
||||
@@ -103,7 +231,7 @@ class Auth extends BaseController
|
||||
public function verify($token)
|
||||
{
|
||||
log_message('debug', 'Verify called with token: ' . $token);
|
||||
|
||||
|
||||
if (empty($token)) {
|
||||
return $this->renderTwig('auth/verify_error', [
|
||||
'message' => 'Отсутствует токен подтверждения.'
|
||||
@@ -114,7 +242,7 @@ class Auth extends BaseController
|
||||
|
||||
// Ищем пользователя по токену
|
||||
$user = $userModel->where('verification_token', $token)->first();
|
||||
|
||||
|
||||
log_message('debug', 'User found: ' . ($user ? 'yes' : 'no'));
|
||||
if ($user) {
|
||||
log_message('debug', 'User email_verified: ' . $user['email_verified']);
|
||||
@@ -138,9 +266,9 @@ class Auth extends BaseController
|
||||
'verified_at' => date('Y-m-d H:i:s'),
|
||||
'verification_token' => null, // Удаляем токен после использования
|
||||
];
|
||||
|
||||
|
||||
$result = $userModel->update($user['id'], $updateData);
|
||||
|
||||
|
||||
log_message('debug', 'Update result: ' . ($result ? 'success' : 'failed'));
|
||||
log_message('debug', 'Update data: ' . print_r($updateData, true));
|
||||
|
||||
@@ -162,8 +290,17 @@ class Auth extends BaseController
|
||||
public function resendVerification()
|
||||
{
|
||||
if ($this->request->getMethod() === 'POST') {
|
||||
|
||||
// === ПРОВЕРКА RATE LIMITING ===
|
||||
$rateLimitError = $this->checkRateLimit('reset');
|
||||
if ($rateLimitError !== null) {
|
||||
return redirect()->back()
|
||||
->with('error', $rateLimitError['message'])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$email = $this->request->getPost('email');
|
||||
|
||||
|
||||
if (empty($email)) {
|
||||
return redirect()->back()->with('error', 'Введите email');
|
||||
}
|
||||
@@ -172,6 +309,8 @@ class Auth extends BaseController
|
||||
$user = $userModel->where('email', $email)->first();
|
||||
|
||||
if (!$user) {
|
||||
// Неудачная попытка - засчитываем для защиты от перебора
|
||||
$this->recordFailedAttempt('reset');
|
||||
return redirect()->back()->with('error', 'Пользователь с таким email не найден');
|
||||
}
|
||||
|
||||
@@ -192,6 +331,9 @@ class Auth extends BaseController
|
||||
$newToken
|
||||
);
|
||||
|
||||
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОЙ ОТПРАВКИ ===
|
||||
$this->resetRateLimit('reset');
|
||||
|
||||
return redirect()->back()->with('success', 'Письмо для подтверждения отправлено повторно. Проверьте почту.');
|
||||
}
|
||||
|
||||
@@ -201,6 +343,15 @@ class Auth extends BaseController
|
||||
public function login()
|
||||
{
|
||||
if ($this->request->getMethod() === 'POST') {
|
||||
|
||||
// === ПРОВЕРКА RATE LIMITING ===
|
||||
$rateLimitError = $this->checkRateLimit('login');
|
||||
if ($rateLimitError !== null) {
|
||||
return redirect()->back()
|
||||
->with('error', $rateLimitError['message'])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$userModel = new \App\Models\UserModel();
|
||||
$orgUserModel = new \App\Models\OrganizationUserModel();
|
||||
|
||||
@@ -238,6 +389,10 @@ class Auth extends BaseController
|
||||
// Если одна организация — заходим автоматически для удобства
|
||||
$sessionData['active_org_id'] = $userOrgs[0]['organization_id'];
|
||||
session()->set($sessionData);
|
||||
|
||||
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
|
||||
$this->resetRateLimit('login');
|
||||
|
||||
return redirect()->to('/');
|
||||
}
|
||||
|
||||
@@ -251,10 +406,28 @@ class Auth extends BaseController
|
||||
// чтобы страница /organizations не редиректнула его обратно (см. Organizations::index)
|
||||
session()->setFlashdata('info', 'Выберите пространство для работы');
|
||||
|
||||
// === СБРОС RATE LIMITING ПОСЛЕ УСПЕШНОГО ВХОДА ===
|
||||
$this->resetRateLimit('login');
|
||||
|
||||
return redirect()->to('/organizations');
|
||||
//}
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Неверный логин или пароль');
|
||||
// === ЗАСЧИТЫВАЕМ НЕУДАЧНУЮ ПОПЫТКУ ===
|
||||
$limitExceeded = $this->recordFailedAttempt('login');
|
||||
|
||||
// Если лимит превышен - показываем сообщение
|
||||
if ($limitExceeded !== null && $limitExceeded['blocked']) {
|
||||
$message = "Слишком много неудачных попыток входа. ";
|
||||
$message .= "Доступ заблокирован на " . $this->formatBlockTime($limitExceeded['ttl']) . ".";
|
||||
return redirect()->back()->with('error', $message)->withInput();
|
||||
}
|
||||
|
||||
// Иначе показываем стандартное сообщение об ошибке
|
||||
$remaining = $this->rateLimitService ? $this->rateLimitService->checkAttempt('login')['remaining'] : 0;
|
||||
$message = 'Неверный логин или пароль';
|
||||
if ($remaining > 0 && $remaining <= 2) {
|
||||
$message .= " Осталось попыток: {$remaining}";
|
||||
}
|
||||
return redirect()->back()->with('error', $message)->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,4 +440,57 @@ class Auth extends BaseController
|
||||
session()->remove('active_org_id');
|
||||
return redirect()->to('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* DEBUG: Просмотр состояния rate limiting (только для разработки)
|
||||
* DELETE: Убрать перед релизом!
|
||||
*
|
||||
* GET /auth/rate-limit-status
|
||||
*/
|
||||
public function rateLimitStatus()
|
||||
{
|
||||
// В продакшене должен быть доступ только админам
|
||||
if (env('CI_ENVIRONMENT') === 'production') {
|
||||
return $this->response->setStatusCode(403)->setJSON(['error' => 'Forbidden']);
|
||||
}
|
||||
|
||||
if ($this->rateLimitService === null) {
|
||||
return $this->response->setJSON([
|
||||
'status' => 'unavailable',
|
||||
'message' => 'RateLimitService недоступен (Redis не подключен)',
|
||||
]);
|
||||
}
|
||||
|
||||
$loginStatus = $this->rateLimitService->getStatus('login');
|
||||
$registerStatus = $this->rateLimitService->getStatus('register');
|
||||
$resetStatus = $this->rateLimitService->getStatus('reset');
|
||||
|
||||
return $this->response->setJSON([
|
||||
'ip' => service('request')->getIPAddress(),
|
||||
'redis_connected' => $this->rateLimitService->isConnected(),
|
||||
'rate_limiting' => [
|
||||
'login' => [
|
||||
'attempts' => $loginStatus['attempts'],
|
||||
'limit' => $loginStatus['limit'],
|
||||
'window_seconds' => $loginStatus['window'],
|
||||
'is_blocked' => $loginStatus['is_blocked'],
|
||||
'block_ttl_seconds' => $loginStatus['block_ttl'],
|
||||
],
|
||||
'register' => [
|
||||
'attempts' => $registerStatus['attempts'],
|
||||
'limit' => $registerStatus['limit'],
|
||||
'window_seconds' => $registerStatus['window'],
|
||||
'is_blocked' => $registerStatus['is_blocked'],
|
||||
'block_ttl_seconds' => $registerStatus['block_ttl'],
|
||||
],
|
||||
'reset' => [
|
||||
'attempts' => $resetStatus['attempts'],
|
||||
'limit' => $resetStatus['limit'],
|
||||
'window_seconds' => $resetStatus['window'],
|
||||
'is_blocked' => $resetStatus['is_blocked'],
|
||||
'block_ttl_seconds' => $resetStatus['block_ttl'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Models\OrganizationModel;
|
||||
use App\Services\AccessService;
|
||||
|
||||
/**
|
||||
/**
|
||||
* BaseController provides a convenient place for loading components
|
||||
* and performing functions that are needed by all your controllers.
|
||||
*
|
||||
@@ -27,6 +28,7 @@ abstract class BaseController extends Controller
|
||||
*/
|
||||
|
||||
protected $session;
|
||||
protected AccessService $access;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
@@ -42,6 +44,33 @@ abstract class BaseController extends Controller
|
||||
|
||||
// Preload any models, libraries, etc, here.
|
||||
$this->session = service('session');
|
||||
$this->access = service('access');
|
||||
|
||||
// Загружаем хелпер доступа для Twig
|
||||
helper('access');
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка права на действие ( shortcut для $this->access->can() )
|
||||
*
|
||||
* @param string $action
|
||||
* @param string $resource
|
||||
* @return bool
|
||||
*/
|
||||
protected function can(string $action, string $resource): bool
|
||||
{
|
||||
return $this->access->can($action, $resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка роли (shortcut для $this->access->isRole() )
|
||||
*
|
||||
* @param string|array $roles
|
||||
* @return bool
|
||||
*/
|
||||
protected function isRole($roles): bool
|
||||
{
|
||||
return $this->access->isRole($roles);
|
||||
}
|
||||
|
||||
public function renderTwig($template, $data = [])
|
||||
@@ -55,6 +84,9 @@ abstract class BaseController extends Controller
|
||||
$oldInput = $this->session->get('_ci_old_input') ?? [];
|
||||
$data['old'] = $data['old'] ?? $oldInput;
|
||||
|
||||
// Добавляем access в данные шаблона для функций can(), isRole() и т.д.
|
||||
$data['access'] = $this->access;
|
||||
|
||||
ob_start();
|
||||
$twig->display($template, $data);
|
||||
$content = ob_get_clean();
|
||||
@@ -129,19 +161,39 @@ abstract class BaseController extends Controller
|
||||
}
|
||||
|
||||
$model = $config['model'];
|
||||
$builder = $model->builder();
|
||||
|
||||
// Сбрасываем все предыдущие условия
|
||||
$builder->resetQuery();
|
||||
|
||||
// Если есть кастомный scope - создаём новый чистый запрос
|
||||
// scope будет полностью контролировать FROM, JOIN, SELECT
|
||||
if (isset($config['scope']) && is_callable($config['scope'])) {
|
||||
$builder = $model->db()->newQuery();
|
||||
$config['scope']($builder);
|
||||
} else {
|
||||
// Стандартный путь - используем builder модели
|
||||
$builder = $model->builder();
|
||||
$builder->resetQuery();
|
||||
|
||||
// Автоматическая фильтрация по организации для моделей с TenantScopedModel
|
||||
$modelClass = get_class($model);
|
||||
$traits = class_uses($modelClass);
|
||||
if (in_array('App\Models\Traits\TenantScopedModel', $traits)) {
|
||||
$model->forCurrentOrg();
|
||||
}
|
||||
}
|
||||
|
||||
// Применяем фильтры
|
||||
foreach ($filters as $field => $value) {
|
||||
if ($value !== '' && in_array($field, $config['searchable'])) {
|
||||
$builder->like($field, $value);
|
||||
foreach ($filters as $filterKey => $value) {
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Сначала проверяем fieldMap (алиасы) — они имеют приоритет
|
||||
if (isset($config['fieldMap']) && isset($config['fieldMap'][$filterKey])) {
|
||||
$realField = $config['fieldMap'][$filterKey];
|
||||
$builder->like($realField, $value);
|
||||
}
|
||||
// Потом проверяем прямое совпадение
|
||||
elseif (in_array($filterKey, $config['searchable'])) {
|
||||
$builder->like($filterKey, $value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,13 +202,11 @@ abstract class BaseController extends Controller
|
||||
$builder->orderBy($sort, $order);
|
||||
}
|
||||
|
||||
// Исправлено: countAllResults(false) вместо countAll()
|
||||
// Сохраняем текущее состояние builder для подсчета
|
||||
$countBuilder = clone $builder;
|
||||
$total = $countBuilder->countAllResults(false);
|
||||
|
||||
// Получаем данные с пагинацией
|
||||
$builder->select('*');
|
||||
// Получаем данные с пагинацией (scope уже установил нужный SELECT)
|
||||
$items = $builder->limit($perPage, ($page - 1) * $perPage)->get()->getResultArray();
|
||||
|
||||
$from = ($page - 1) * $perPage + 1;
|
||||
@@ -180,6 +230,8 @@ abstract class BaseController extends Controller
|
||||
'filters' => $filters,
|
||||
'columns' => $config['columns'],
|
||||
'actionsConfig' => $config['actionsConfig'] ?? [],
|
||||
'can_edit' => $config['can_edit'] ?? true,
|
||||
'can_delete' => $config['can_delete'] ?? true,
|
||||
];
|
||||
|
||||
return $data;
|
||||
@@ -221,12 +273,42 @@ abstract class BaseController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX endpoint для таблицы - возвращает partial (tbody + tfoot)
|
||||
* Если запрос не AJAX - возвращает полную таблицу
|
||||
* AJAX endpoint для таблицы
|
||||
*
|
||||
* Логика:
|
||||
* - Если format=partial или AJAX → возвращает только tbody + tfoot
|
||||
* - Если прямой GET → редиректит на основную страницу с теми же параметрами
|
||||
*
|
||||
* @param array|null $config Кастомная конфигурация таблицы (если null, используется getTableConfig())
|
||||
* @param string|null $pageUrl URL основной страницы таблицы (для редиректа)
|
||||
* @return string|ResponseInterface
|
||||
*/
|
||||
public function table()
|
||||
public function table(?array $config = null, ?string $pageUrl = null)
|
||||
{
|
||||
$isPartial = $this->request->getGet('format') === 'partial' || $this->isAjax();
|
||||
return $this->renderTable(null, $isPartial);
|
||||
|
||||
// Если это частичный запрос (AJAX) — возвращаем только таблицу
|
||||
if ($isPartial) {
|
||||
return $this->renderTable($config, true);
|
||||
}
|
||||
|
||||
// Прямой запрос к /table — редиректим на основную страницу
|
||||
// Сохраняем все параметры: page, perPage, sort, order, filters
|
||||
$params = $this->request->getGet();
|
||||
unset($params['format']); // Убираем format=partial если был
|
||||
|
||||
if ($pageUrl) {
|
||||
$redirectUrl = $pageUrl;
|
||||
} else {
|
||||
// Пытаемся извлечь URL из config['url']
|
||||
$tableUrl = $config['url'] ?? '/table';
|
||||
$redirectUrl = $tableUrl;
|
||||
}
|
||||
|
||||
if (!empty($params)) {
|
||||
$redirectUrl .= '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
return redirect()->to($redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\OrganizationUserModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Services\AccessService;
|
||||
|
||||
class Organizations extends BaseController
|
||||
{
|
||||
protected OrganizationUserModel $orgUserModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->orgUserModel = new OrganizationUserModel();
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$orgModel = new OrganizationModel();
|
||||
$orgUserModel = new OrganizationUserModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Получаем организации пользователя через связующую таблицу
|
||||
$userOrgLinks = $orgUserModel->where('user_id', $userId)->findAll();
|
||||
$userOrgLinks = $this->orgUserModel->where('user_id', $userId)->findAll();
|
||||
|
||||
// Нам нужно получить сами данные организаций
|
||||
$orgIds = array_column($userOrgLinks, 'organization_id');
|
||||
@@ -24,12 +32,6 @@ class Organizations extends BaseController
|
||||
$organizations = $orgModel->whereIn('id', $orgIds)->findAll();
|
||||
}
|
||||
|
||||
// Логика автоперехода (как в Auth)
|
||||
// if (count($organizations) === 1) {
|
||||
// session()->set('active_org_id', $organizations[0]['id']);
|
||||
// return redirect()->to('/');
|
||||
// }
|
||||
|
||||
// Если больше 1 или 0, показываем список
|
||||
return $this->renderTwig('organizations/index', [
|
||||
'organizations' => $organizations,
|
||||
@@ -41,7 +43,6 @@ class Organizations extends BaseController
|
||||
{
|
||||
if ($this->request->getMethod() === 'POST') {
|
||||
$orgModel = new OrganizationModel();
|
||||
$orgUserModel = new OrganizationUserModel();
|
||||
|
||||
$rules = [
|
||||
'name' => 'required|min_length[2]',
|
||||
@@ -77,7 +78,7 @@ class Organizations extends BaseController
|
||||
]);
|
||||
|
||||
// Привязываем владельца
|
||||
$orgUserModel->insert([
|
||||
$this->orgUserModel->insert([
|
||||
'organization_id' => $orgId,
|
||||
'user_id' => session()->get('user_id'),
|
||||
'role' => 'owner',
|
||||
@@ -96,30 +97,66 @@ class Organizations extends BaseController
|
||||
return $this->renderTwig('organizations/create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Дашборд управления организацией
|
||||
*/
|
||||
public function dashboard($orgId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
// Получаем данные организации
|
||||
$orgModel = new OrganizationModel();
|
||||
$organization = $orgModel->find($orgId);
|
||||
|
||||
if (!$organization) {
|
||||
return $this->redirectWithError('Организация не найдена', '/organizations');
|
||||
}
|
||||
|
||||
// Получаем статистику организации
|
||||
$stats = [
|
||||
'users_total' => $this->orgUserModel->where('organization_id', $orgId)->countAllResults(),
|
||||
'users_active' => $this->orgUserModel->where('organization_id', $orgId)->where('status', 'active')->countAllResults(),
|
||||
'users_blocked' => $this->orgUserModel->where('organization_id', $orgId)->where('status', 'blocked')->countAllResults(),
|
||||
];
|
||||
|
||||
// Проверяем права для отображения пунктов меню
|
||||
$canManageUsers = $this->access->canManageUsers();
|
||||
$canEditOrg = true; // Все члены организации могут видеть настройки (редактировать могут только admin+)
|
||||
|
||||
return $this->renderTwig('organizations/dashboard', [
|
||||
'organization' => $organization,
|
||||
'organization_id' => $orgId,
|
||||
'stats' => $stats,
|
||||
'current_role' => $membership['role'],
|
||||
'can_manage_users' => $canManageUsers,
|
||||
'can_edit_org' => $canEditOrg,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактирование организации
|
||||
*/
|
||||
public function edit($orgId)
|
||||
{
|
||||
$orgModel = new OrganizationModel();
|
||||
$orgUserModel = new OrganizationUserModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Проверяем: имеет ли пользователь доступ к этой организации?
|
||||
$membership = $orgUserModel->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
// Проверяем доступ через AccessService
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
session()->setFlashdata('error', 'Доступ запрещен');
|
||||
return redirect()->to('/organizations');
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
// Получаем организацию
|
||||
// Проверяем права на редактирование (все роли могут редактировать)
|
||||
$orgModel = new OrganizationModel();
|
||||
$organization = $orgModel->find($orgId);
|
||||
|
||||
if (!$organization) {
|
||||
session()->setFlashdata('error', 'Организация не найдена');
|
||||
return redirect()->to('/organizations');
|
||||
return $this->redirectWithError('Организация не найдена', '/organizations');
|
||||
}
|
||||
|
||||
// Декодируем requisites для формы
|
||||
@@ -173,37 +210,29 @@ class Organizations extends BaseController
|
||||
*/
|
||||
public function delete($orgId)
|
||||
{
|
||||
$orgModel = new OrganizationModel();
|
||||
$orgUserModel = new OrganizationUserModel();
|
||||
$userId = session()->get('user_id');
|
||||
|
||||
// Проверяем: имеет ли пользователь доступ к этой организации?
|
||||
$membership = $orgUserModel->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
session()->setFlashdata('error', 'Доступ запрещен');
|
||||
return redirect()->to('/organizations');
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
// Проверяем, что пользователь — владелец
|
||||
if ($membership['role'] !== 'owner') {
|
||||
session()->setFlashdata('error', 'Только владелец может удалить организацию');
|
||||
return redirect()->to('/organizations');
|
||||
// Проверяем права: только владелец может удалить
|
||||
if (!$this->access->canDeleteOrganization()) {
|
||||
return $this->redirectWithError('Только владелец может удалить организацию', '/organizations');
|
||||
}
|
||||
|
||||
// Получаем организацию
|
||||
$orgModel = new OrganizationModel();
|
||||
$organization = $orgModel->find($orgId);
|
||||
|
||||
if (!$organization) {
|
||||
session()->setFlashdata('error', 'Организация не найдена');
|
||||
return redirect()->to('/organizations');
|
||||
return $this->redirectWithError('Организация не найдена', '/organizations');
|
||||
}
|
||||
|
||||
// Если это POST с подтверждением — удаляем
|
||||
if ($this->request->getMethod() === 'POST') {
|
||||
// Удаляем связи с пользователями
|
||||
$orgUserModel->where('organization_id', $orgId)->delete();
|
||||
// Удаляем связи с пользователями через forCurrentOrg()
|
||||
$this->orgUserModel->forCurrentOrg()->delete();
|
||||
|
||||
// Мягкое удаление организации
|
||||
$orgModel->delete($orgId);
|
||||
@@ -226,14 +255,18 @@ class Organizations extends BaseController
|
||||
public function switch($orgId)
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
$orgUserModel = new OrganizationUserModel();
|
||||
$orgId = (int) $orgId;
|
||||
|
||||
// Проверяем: имеет ли пользователь доступ к этой организации?
|
||||
$membership = $orgUserModel->where('organization_id', $orgId)
|
||||
// Проверяем доступ
|
||||
$membership = $this->orgUserModel
|
||||
->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if ($membership) {
|
||||
// Сбрасываем кэш AccessService при смене организации
|
||||
$this->access->resetCache();
|
||||
|
||||
session()->set('active_org_id', $orgId);
|
||||
session()->setFlashdata('success', 'Организация изменена');
|
||||
return redirect()->to('/');
|
||||
@@ -242,4 +275,525 @@ class Organizations extends BaseController
|
||||
return redirect()->to('/organizations');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Управление пользователями организации
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Список пользователей организации
|
||||
*/
|
||||
public function users($orgId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
// Получаем организацию
|
||||
$orgModel = new OrganizationModel();
|
||||
$organization = $orgModel->find($orgId);
|
||||
|
||||
if (!$organization) {
|
||||
return $this->redirectWithError('Организация не найдена', '/organizations');
|
||||
}
|
||||
|
||||
// Проверяем права через единое место в AccessService (включает проверку типа организации)
|
||||
if (!$this->access->canManageUsers()) {
|
||||
return $this->redirectWithError('У вас нет прав для управления пользователями', '/organizations/' . $orgId . '/dashboard');
|
||||
}
|
||||
|
||||
// Рендерим таблицу через универсальный компонент
|
||||
$tableHtml = $this->renderTable($this->getUsersTableConfig($orgId));
|
||||
|
||||
// Получаем данные пользователей для статистики
|
||||
$users = $this->orgUserModel->getOrganizationUsers($orgId);
|
||||
|
||||
return $this->renderTwig('organizations/users', [
|
||||
'organization' => $organization,
|
||||
'organization_id' => $orgId,
|
||||
'tableHtml' => $tableHtml,
|
||||
'users' => $users,
|
||||
'current_user_id' => session()->get('user_id'),
|
||||
'can_manage_users' => $this->access->canManageUsers(),
|
||||
'current_role' => $membership['role'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Конфигурация таблицы пользователей
|
||||
*/
|
||||
protected function getUsersTableConfig(int $orgId): array
|
||||
{
|
||||
// Проверяем права для кнопок действий
|
||||
$canManage = $this->access->canManageUsers();
|
||||
|
||||
return [
|
||||
'id' => 'users-table',
|
||||
'url' => '/organizations/' . $orgId . '/users/table',
|
||||
'model' => $this->orgUserModel,
|
||||
'columns' => [
|
||||
'user_email' => [
|
||||
'label' => 'Пользователь',
|
||||
'width' => '35%',
|
||||
'type' => 'user_display',
|
||||
],
|
||||
'role' => [
|
||||
'label' => 'Роль',
|
||||
'width' => '15%',
|
||||
'type' => 'role_badge',
|
||||
],
|
||||
'status' => [
|
||||
'label' => 'Статус',
|
||||
'width' => '15%',
|
||||
'type' => 'status_badge',
|
||||
],
|
||||
'joined_at' => [
|
||||
'label' => 'Дата входа',
|
||||
'width' => '20%',
|
||||
'type' => 'datetime',
|
||||
'default' => '—',
|
||||
],
|
||||
],
|
||||
'searchable' => ['user_email', 'user_name'],
|
||||
'sortable' => ['joined_at', 'role', 'status'],
|
||||
'defaultSort' => 'joined_at',
|
||||
'order' => 'desc',
|
||||
'actions' => true, // Включаем колонку действий
|
||||
'actionsConfig' => [
|
||||
// Изменение роли
|
||||
[
|
||||
'label' => 'Изменить роль',
|
||||
'url' => '/organizations/users/' . $orgId . '/role/{user_id}',
|
||||
'icon' => 'fa-solid fa-user-gear',
|
||||
'class' => 'btn-outline-primary btn-sm',
|
||||
'type' => 'edit',
|
||||
],
|
||||
// Блокировка
|
||||
[
|
||||
'label' => 'Заблокировать',
|
||||
'url' => '/organizations/users/' . $orgId . '/block/{user_id}',
|
||||
'icon' => 'fa-solid fa-ban',
|
||||
'class' => 'btn-outline-warning btn-sm',
|
||||
'type' => 'block',
|
||||
],
|
||||
// Разблокировка
|
||||
[
|
||||
'label' => 'Разблокировать',
|
||||
'url' => '/organizations/users/' . $orgId . '/unblock/{user_id}',
|
||||
'icon' => 'fa-solid fa-check',
|
||||
'class' => 'btn-outline-success btn-sm',
|
||||
'type' => 'unblock',
|
||||
],
|
||||
// Удаление
|
||||
[
|
||||
'label' => 'Удалить',
|
||||
'url' => '/organizations/users/' . $orgId . '/remove/{user_id}',
|
||||
'icon' => 'fa-solid fa-user-xmark',
|
||||
'class' => 'btn-outline-danger btn-sm',
|
||||
'type' => 'delete',
|
||||
],
|
||||
],
|
||||
'can_edit' => $canManage,
|
||||
'can_delete' => $canManage,
|
||||
'emptyMessage' => 'В организации пока нет участников',
|
||||
'emptyIcon' => 'fa-solid fa-users',
|
||||
'emptyActionUrl' => '',
|
||||
'emptyActionLabel'=> '',
|
||||
'emptyActionIcon' => '',
|
||||
'scope' => function ($builder) use ($orgId) {
|
||||
$builder->select('ou.*, u.email as user_email, u.name as user_name, u.avatar as user_avatar')
|
||||
->from('organization_users ou')
|
||||
->join('users u', 'u.id = ou.user_id', 'left')
|
||||
->where('ou.organization_id', $orgId);
|
||||
},
|
||||
// Ключи фильтров (совпадают с ключами columns)
|
||||
'searchable' => ['user_email', 'user_name'],
|
||||
// Маппинг ключей фильтров к реальным колонкам БД
|
||||
'fieldMap' => [
|
||||
'user_email' => 'u.email',
|
||||
'user_name' => 'u.name',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Таблица пользователей (AJAX)
|
||||
*/
|
||||
public function usersTable($orgId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->forbiddenResponse('Доступ запрещён');
|
||||
}
|
||||
|
||||
// Проверяем права через единое место в AccessService (включает проверку типа организации)
|
||||
if (!$this->access->canManageUsers()) {
|
||||
return $this->forbiddenResponse('Управление пользователями недоступно');
|
||||
}
|
||||
|
||||
return $this->table(
|
||||
$this->getUsersTableConfig($orgId),
|
||||
'/organizations/' . $orgId . '/users' // URL для редиректа
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Приглашение пользователя (AJAX)
|
||||
*/
|
||||
public function inviteUser($orgId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Доступ запрещен',
|
||||
]);
|
||||
}
|
||||
|
||||
// Проверяем права на управление пользователями
|
||||
if (!$this->access->canManageUsers()) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'У вас нет прав для приглашения пользователей',
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$this->request->isAJAX()) {
|
||||
return redirect()->to("/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
$email = $this->request->getPost('email');
|
||||
$role = $this->request->getPost('role');
|
||||
|
||||
// Валидация
|
||||
if (empty($email) || empty($role)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Email и роль обязательны',
|
||||
]);
|
||||
}
|
||||
|
||||
// Проверяем валидность роли
|
||||
$availableRoles = $this->access->getAvailableRolesForAssignment($membership['role']);
|
||||
if (!in_array($role, $availableRoles)) {
|
||||
return $this->response->setJSON([
|
||||
'success' => false,
|
||||
'message' => 'Недопустимая роль',
|
||||
]);
|
||||
}
|
||||
|
||||
// Создаем приглашение через сервис
|
||||
$invitationService = new \App\Services\InvitationService();
|
||||
$result = $invitationService->createInvitation(
|
||||
$orgId,
|
||||
$email,
|
||||
$role,
|
||||
session()->get('user_id')
|
||||
);
|
||||
|
||||
return $this->response->setJSON($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка пользователя
|
||||
*/
|
||||
public function blockUser($orgId, $userId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$userId = (int) $userId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
if (!$this->access->canManageUsers()) {
|
||||
return $this->redirectWithError('У вас нет прав для блокировки', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
// Нельзя заблокировать владельца
|
||||
$targetMembership = $this->orgUserModel
|
||||
->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (!$targetMembership) {
|
||||
return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
if ($targetMembership['role'] === 'owner') {
|
||||
return $this->redirectWithError('Нельзя заблокировать владельца', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
$this->orgUserModel->blockUser($targetMembership['id']);
|
||||
|
||||
session()->setFlashdata('success', 'Пользователь заблокирован');
|
||||
return redirect()->to("/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Разблокировка пользователя
|
||||
*/
|
||||
public function unblockUser($orgId, $userId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$userId = (int) $userId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership || !$this->access->canManageUsers()) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
$targetMembership = $this->orgUserModel
|
||||
->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (!$targetMembership) {
|
||||
return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
$this->orgUserModel->unblockUser($targetMembership['id']);
|
||||
|
||||
session()->setFlashdata('success', 'Пользователь разблокирован');
|
||||
return redirect()->to("/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Выход пользователя из организации
|
||||
*/
|
||||
public function leaveOrganization($orgId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->redirectWithError('Вы не состоите в этой организации', '/organizations');
|
||||
}
|
||||
|
||||
// Владелец не может покинуть организацию
|
||||
if ($membership['role'] === 'owner') {
|
||||
return $this->redirectWithError('Владелец не может покинуть организацию. Передайте права другому администратору.', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
// Удаляем из организации
|
||||
$this->orgUserModel->delete($membership['id']);
|
||||
|
||||
// Если это была активная организация - переключаем на другую
|
||||
if (session()->get('active_org_id') == $orgId) {
|
||||
$userId = session()->get('user_id');
|
||||
$otherOrgs = $this->orgUserModel->where('user_id', $userId)->where('status', 'active')->findAll();
|
||||
|
||||
if (!empty($otherOrgs)) {
|
||||
session()->set('active_org_id', $otherOrgs[0]['organization_id']);
|
||||
} else {
|
||||
session()->remove('active_org_id');
|
||||
}
|
||||
}
|
||||
|
||||
session()->setFlashdata('success', 'Вы покинули организацию');
|
||||
return redirect()->to('/organizations');
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторная отправка приглашения
|
||||
*/
|
||||
public function resendInvite($orgId, $invitationId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership || !$this->access->canManageUsers()) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
$invitationService = new \App\Services\InvitationService();
|
||||
$result = $invitationService->resendInvitation($invitationId, $orgId);
|
||||
|
||||
if ($result['success']) {
|
||||
session()->setFlashdata('success', 'Приглашение отправлено повторно');
|
||||
} else {
|
||||
session()->setFlashdata('error', $result['message']);
|
||||
}
|
||||
|
||||
return redirect()->to("/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Отзыв приглашения
|
||||
*/
|
||||
public function cancelInvite($orgId, $invitationId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership || !$this->access->canManageUsers()) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
$invitationService = new \App\Services\InvitationService();
|
||||
$result = $invitationService->cancelInvitation($invitationId, $orgId);
|
||||
|
||||
if ($result['success']) {
|
||||
session()->setFlashdata('success', 'Приглашение отозвано');
|
||||
} else {
|
||||
session()->setFlashdata('error', $result['message']);
|
||||
}
|
||||
|
||||
return redirect()->to("/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменение роли пользователя
|
||||
*/
|
||||
public function updateUserRole($orgId, $userId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$userId = (int) $userId;
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
// Проверяем права
|
||||
if (!$this->access->canManageUsers()) {
|
||||
return $this->redirectWithError('У вас нет прав для изменения ролей', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
// Нельзя изменить роль владельца
|
||||
$targetMembership = $this->orgUserModel
|
||||
->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (!$targetMembership) {
|
||||
return $this->redirectWithError('Пользователь не найден в организации', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
if ($targetMembership['role'] === 'owner') {
|
||||
return $this->redirectWithError('Нельзя изменить роль владельца', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
if ($this->request->getMethod() === 'POST') {
|
||||
$newRole = $this->request->getPost('role');
|
||||
|
||||
// Проверяем валидность роли
|
||||
$availableRoles = $this->access->getAvailableRolesForAssignment($membership['role']);
|
||||
if (!in_array($newRole, $availableRoles)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Недопустимая роль');
|
||||
}
|
||||
|
||||
$this->orgUserModel->update($targetMembership['id'], [
|
||||
'role' => $newRole,
|
||||
]);
|
||||
|
||||
session()->setFlashdata('success', 'Роль изменена');
|
||||
return redirect()->to("/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
$userModel = new UserModel();
|
||||
$user = $userModel->find($userId);
|
||||
|
||||
return $this->renderTwig('organizations/edit_user_role', [
|
||||
'organization_id' => $orgId,
|
||||
'user' => $user,
|
||||
'current_role' => $targetMembership['role'],
|
||||
'available_roles' => $availableRoles,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление пользователя из организации
|
||||
*/
|
||||
public function removeUser($orgId, $userId)
|
||||
{
|
||||
$orgId = (int) $orgId;
|
||||
$userId = (int) $userId;
|
||||
$currentUserId = session()->get('user_id');
|
||||
$membership = $this->getMembership($orgId);
|
||||
|
||||
if (!$membership) {
|
||||
return $this->redirectWithError('Доступ запрещен', '/organizations');
|
||||
}
|
||||
|
||||
// Проверяем права
|
||||
if (!$this->access->canManageUsers()) {
|
||||
return $this->redirectWithError('У вас нет прав для удаления пользователей', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
// Нельзя удалить владельца
|
||||
$targetMembership = $this->orgUserModel
|
||||
->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
|
||||
if (!$targetMembership) {
|
||||
return $this->redirectWithError('Пользователь не найден', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
if ($targetMembership['role'] === 'owner') {
|
||||
return $this->redirectWithError('Нельзя удалить владельца организации', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
// Нельзя удалить самого себя (если ты admin)
|
||||
if ($userId === $currentUserId) {
|
||||
return $this->redirectWithError('Нельзя удалить себя из организации', "/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
// Удаляем пользователя из организации
|
||||
$this->orgUserModel->delete($targetMembership['id']);
|
||||
|
||||
session()->setFlashdata('success', 'Пользователь удалён из организации');
|
||||
return redirect()->to("/organizations/users/{$orgId}");
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Вспомогательные методы
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Получение membership пользователя для организации
|
||||
*
|
||||
* @param int $orgId
|
||||
* @return array|null
|
||||
*/
|
||||
protected function getMembership(int $orgId): ?array
|
||||
{
|
||||
$userId = session()->get('user_id');
|
||||
if (!$userId || !$orgId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->orgUserModel
|
||||
->where('organization_id', $orgId)
|
||||
->where('user_id', $userId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Редирект с сообщением об ошибке
|
||||
*
|
||||
* @param string $message
|
||||
* @param string $redirectUrl
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
protected function redirectWithError(string $message, string $redirectUrl)
|
||||
{
|
||||
if ($this->request->isAJAX()) {
|
||||
return service('response')
|
||||
->setStatusCode(403)
|
||||
->setJSON(['error' => $message]);
|
||||
}
|
||||
|
||||
session()->setFlashdata('error', $message);
|
||||
return redirect()->to($redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user