dashboard for org

This commit is contained in:
root
2026-01-12 11:58:12 +03:00
parent d27f66953c
commit 246ca93307
30 changed files with 4362 additions and 125 deletions
+236 -10
View File
@@ -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'],
],
],
]);
}
}
+97 -15
View File
@@ -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);
}
}
+267
View File
@@ -0,0 +1,267 @@
<?php
namespace App\Controllers;
use App\Models\OrganizationUserModel;
use App\Models\UserModel;
use App\Models\OrganizationModel;
use App\Services\InvitationService;
use App\Services\AccessService;
class InvitationController extends BaseController
{
protected InvitationService $invitationService;
public function __construct()
{
$this->invitationService = new InvitationService();
}
/**
* Страница принятия/отклонения приглашения
*/
public function accept(string $token)
{
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
if (!$invitation) {
return $this->renderTwig('organizations/invitation_expired', [
'title' => 'Приглашение недействительно',
]);
}
// Получаем данные организации
$orgModel = new OrganizationModel();
$organization = $orgModel->find($invitation['organization_id']);
// Получаем данные приглашающего
$invitedByUser = null;
if ($invitation['invited_by']) {
$userModel = new UserModel();
$invitedByUser = $userModel->find($invitation['invited_by']);
}
// Определяем, авторизован ли пользователь
$currentUserId = session()->get('user_id');
$isLoggedIn = !empty($currentUserId);
// Если пользователь авторизован - проверяем, тот ли это email
$emailMatches = true;
if ($isLoggedIn && $invitation['user_id']) {
$currentUser = (new UserModel())->find($currentUserId);
// Проверяем, тот ли пользователь (по ID или email)
$emailMatches = ($currentUserId == $invitation['user_id']);
}
// Метка роли
$roleLabels = [
'owner' => 'Владелец',
'admin' => 'Администратор',
'manager' => 'Менеджер',
'guest' => 'Гость',
];
return $this->renderTwig('organizations/invitation_accept', [
'title' => 'Приглашение в ' . ($organization['name'] ?? 'организацию'),
'token' => $token,
'organization' => $organization,
'role' => $invitation['role'],
'role_label' => $roleLabels[$invitation['role']] ?? $invitation['role'],
'invited_by' => $invitedByUser,
'invited_at' => $invitation['invited_at'],
'is_logged_in' => $isLoggedIn,
'email_matches' => $emailMatches,
'current_user_id'=> $currentUserId,
]);
}
/**
* Обработка принятия приглашения
*/
public function processAccept()
{
$token = $this->request->getPost('token');
$action = $this->request->getPost('action');
if ($action === 'decline') {
return $this->decline($token);
}
$userId = session()->get('user_id');
// Если пользователь не авторизован - редирект на страницу создания пароля
if (!$userId) {
return redirect()->to('/invitation/complete/' . $token);
}
// Принимаем приглашение
$result = $this->invitationService->acceptInvitation($token, $userId);
if (!$result['success']) {
session()->setFlashdata('error', $result['message']);
return redirect()->to('/invitation/accept/' . $token);
}
session()->setFlashdata('success', 'Вы приняли приглашение в организацию "' . $result['organization_name'] . '"');
// Переключаем организацию и редиректим на главную
session()->set('active_org_id', $result['organization_id']);
(new AccessService())->resetCache();
return redirect()->to('/');
}
/**
* Отклонение приглашения
*/
public function decline(string $token)
{
$result = $this->invitationService->declineInvitation($token);
if (!$result['success']) {
session()->setFlashdata('error', $result['message']);
} else {
session()->setFlashdata('info', 'Приглашение отклонено');
}
return redirect()->to('/');
}
/**
* Страница завершения регистрации (для новых пользователей)
*/
public function complete(string $token)
{
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
if (!$invitation) {
return $this->renderTwig('organizations/invitation_expired', [
'title' => 'Приглашение недействительно',
]);
}
// Если пользователь уже авторизован и это его приглашение
$userId = session()->get('user_id');
if ($userId && $invitation['user_id'] == $userId) {
return redirect()->to('/invitation/accept/' . $token);
}
// Получаем данные организации
$orgModel = new OrganizationModel();
$organization = $orgModel->find($invitation['organization_id']);
// Метка роли
$roleLabels = [
'owner' => 'Владелец',
'admin' => 'Администратор',
'manager' => 'Менеджер',
'guest' => 'Гость',
];
return $this->renderTwig('organizations/invitation_complete', [
'title' => 'Завершение регистрации',
'token' => $token,
'email' => $invitation['user_id'] ? '' : '', // Email возьмем из теневой записи
'organization' => $organization,
'role' => $invitation['role'],
'role_label' => $roleLabels[$invitation['role']] ?? $invitation['role'],
]);
}
/**
* Обработка завершения регистрации
*/
public function processComplete()
{
$token = $this->request->getPost('token');
$name = $this->request->getPost('name');
$password = $this->request->getPost('password');
$passwordConfirm = $this->request->getPost('password_confirm');
// Валидация
$errors = [];
if (empty($name) || strlen($name) < 2) {
$errors[] = 'Имя должно содержать минимум 2 символа';
}
if (empty($password) || strlen($password) < 8) {
$errors[] = 'Пароль должен содержать минимум 8 символов';
}
if ($password !== $passwordConfirm) {
$errors[] = 'Пароли не совпадают';
}
if (!empty($errors)) {
return redirect()->back()->withInput()->with('errors', $errors);
}
$invitation = $this->invitationService->orgUserModel->findByInviteToken($token);
if (!$invitation) {
return redirect()->to('/');
}
// Находим теневую запись пользователя
$userModel = new UserModel();
// Если user_id указан - обновляем существующего пользователя
if ($invitation['user_id']) {
$user = $userModel->find($invitation['user_id']);
if (!$user) {
session()->setFlashdata('error', 'Пользователь не найден');
return redirect()->to('/invitation/complete/' . $token);
}
// Обновляем профиль
$userModel->update($user['id'], [
'name' => $name,
'password' => $password,
]);
$userId = $user['id'];
} else {
// Создаем нового пользователя (теневой + реальный)
// На самом деле у нас уже есть запись в users - нужно её "активировать"
$shadowUsers = $userModel->where('email', $userModel->getFindByEmail($invitation['organization_id']))->findAll();
// Логика для теневых пользователей требует доработки
session()->setFlashdata('error', 'Ошибка регистрации');
return redirect()->to('/invitation/complete/' . $token);
}
// Авторизуем пользователя
$user = $userModel->find($userId);
$this->loginUser($user);
// Принимаем приглашение
$result = $this->invitationService->acceptInvitation($token, $userId);
if (!$result['success']) {
session()->setFlashdata('error', $result['message']);
return redirect()->to('/');
}
session()->setFlashdata('success', 'Добро пожаловать! Вы успешно зарегистрировались и приняли приглашение.');
// Переключаем организацию
session()->set('active_org_id', $result['organization_id']);
(new AccessService())->resetCache();
return redirect()->to('/');
}
/**
* Авторизация пользователя после регистрации
*/
protected function loginUser(array $user): void
{
session()->set([
'user_id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'logged_in' => true,
]);
}
}
+599 -45
View File
@@ -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);
}
}