add superadmin dashboard. subscriptions
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\UserModel;
|
||||
use App\Libraries\EmailLibrary;
|
||||
use App\Services\RateLimitService;
|
||||
|
||||
/**
|
||||
* ForgotPasswordController - Восстановление пароля
|
||||
*
|
||||
* Обрабатывает запросы на сброс пароля:
|
||||
* 1. Форма ввода email для отправки ссылки
|
||||
* 2. Отправка email с ссылкой на сброс
|
||||
* 3. Форма ввода нового пароля
|
||||
* 4. Обновление пароля
|
||||
*/
|
||||
class ForgotPassword extends BaseController
|
||||
{
|
||||
protected UserModel $userModel;
|
||||
protected EmailLibrary $emailLibrary;
|
||||
protected ?RateLimitService $rateLimitService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new UserModel();
|
||||
$this->emailLibrary = new EmailLibrary();
|
||||
|
||||
try {
|
||||
$this->rateLimitService = RateLimitService::getInstance();
|
||||
} catch (\Exception $e) {
|
||||
log_message('warning', 'RateLimitService недоступен: ' . $e->getMessage());
|
||||
$this->rateLimitService = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка rate limiting
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс счётчика после успешного действия
|
||||
*/
|
||||
protected function resetRateLimit(string $action): void
|
||||
{
|
||||
if ($this->rateLimitService !== null) {
|
||||
$this->rateLimitService->resetAttempts($action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запись неудачной попытки
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображение формы запроса сброса пароля
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Если пользователь уже авторизован - редирект на главную
|
||||
if (session()->get('isLoggedIn')) {
|
||||
return redirect()->to('/');
|
||||
}
|
||||
|
||||
return $this->renderTwig('auth/forgot_password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправка email для сброса пароля
|
||||
*/
|
||||
public function sendResetLink()
|
||||
{
|
||||
if ($this->request->getMethod() !== 'POST') {
|
||||
return redirect()->to('/forgot-password');
|
||||
}
|
||||
|
||||
// Проверка rate limiting
|
||||
$rateLimitError = $this->checkRateLimit('reset');
|
||||
if ($rateLimitError !== null) {
|
||||
return redirect()->back()
|
||||
->with('error', $rateLimitError['message'])
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$email = trim($this->request->getPost('email'));
|
||||
|
||||
if (empty($email)) {
|
||||
return redirect()->back()->with('error', 'Введите email адрес')->withInput();
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return redirect()->back()->with('error', 'Введите корректный email адрес')->withInput();
|
||||
}
|
||||
|
||||
$user = $this->userModel->findByEmail($email);
|
||||
|
||||
// Независимо от результата показываем одно и то же сообщение
|
||||
// Это предотвращает перебор email адресов
|
||||
if (!$user) {
|
||||
// Засчитываем неудачную попытку для защиты от перебора
|
||||
$this->recordFailedAttempt('reset');
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
// Генерируем токен сброса
|
||||
$token = $this->userModel->generateResetToken($user['id']);
|
||||
|
||||
// Отправляем email
|
||||
$this->emailLibrary->sendPasswordResetEmail(
|
||||
$user['email'],
|
||||
$user['name'],
|
||||
$token
|
||||
);
|
||||
|
||||
log_message('info', "Password reset link sent to {$email}");
|
||||
}
|
||||
|
||||
// Всегда показываем одно и то же сообщение
|
||||
$this->resetRateLimit('reset');
|
||||
|
||||
return redirect()->back()->with(
|
||||
'success',
|
||||
'Если email зарегистрирован в системе, на него будет отправлена ссылка для сброса пароля.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отображение формы сброса пароля (по токену из URL)
|
||||
*/
|
||||
public function reset($token = null)
|
||||
{
|
||||
// Если пользователь уже авторизован - редирект
|
||||
if (session()->get('isLoggedIn')) {
|
||||
return redirect()->to('/');
|
||||
}
|
||||
|
||||
if (empty($token)) {
|
||||
return redirect()->to('/forgot-password')->with('error', 'Недействительная ссылка для сброса пароля.');
|
||||
}
|
||||
|
||||
// Проверяем токен
|
||||
$user = $this->userModel->verifyResetToken($token);
|
||||
|
||||
if (!$user) {
|
||||
return redirect()->to('/forgot-password')->with('error', 'Ссылка для сброса пароля истекла или недействительна.');
|
||||
}
|
||||
|
||||
return $this->renderTwig('auth/reset_password', [
|
||||
'token' => $token,
|
||||
'email' => $user['email'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление пароля
|
||||
*/
|
||||
public function updatePassword()
|
||||
{
|
||||
if ($this->request->getMethod() !== 'POST') {
|
||||
return redirect()->to('/forgot-password');
|
||||
}
|
||||
|
||||
$token = $this->request->getPost('token');
|
||||
$password = $this->request->getPost('password');
|
||||
$passwordConfirm = $this->request->getPost('password_confirm');
|
||||
|
||||
// Валидация
|
||||
if (empty($token)) {
|
||||
return redirect()->back()->with('error', 'Ошибка валидации токена.');
|
||||
}
|
||||
|
||||
if (empty($password)) {
|
||||
return redirect()->back()->with('error', 'Введите новый пароль')->withInput();
|
||||
}
|
||||
|
||||
if (strlen($password) < 6) {
|
||||
return redirect()->back()->with('error', 'Пароль должен содержать минимум 6 символов')->withInput();
|
||||
}
|
||||
|
||||
if ($password !== $passwordConfirm) {
|
||||
return redirect()->back()->with('error', 'Пароли не совпадают')->withInput();
|
||||
}
|
||||
|
||||
// Проверяем токен
|
||||
$user = $this->userModel->verifyResetToken($token);
|
||||
|
||||
if (!$user) {
|
||||
return redirect()->to('/forgot-password')->with('error', 'Ссылка для сброса пароля истекла или недействительна.');
|
||||
}
|
||||
|
||||
// Обновляем пароль
|
||||
$this->userModel->update($user['id'], ['password' => $password]);
|
||||
|
||||
// Очищаем токен
|
||||
$this->userModel->clearResetToken($user['id']);
|
||||
|
||||
// Удаляем все remember-токены пользователя (нужна будет новая авторизация)
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('remember_tokens')->where('user_id', $user['id'])->delete();
|
||||
|
||||
log_message('info', "Password reset completed for user {$user['email']}");
|
||||
|
||||
return redirect()->to('/login')->with(
|
||||
'success',
|
||||
'Пароль успешно изменён. Теперь вы можете войти с новым паролем.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -96,13 +96,146 @@ class Profile extends BaseController
|
||||
$userId = $this->getCurrentUserId();
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
// Получаем список активных сессий
|
||||
$sessions = $this->getUserSessions($userId);
|
||||
|
||||
return $this->renderTwig('profile/security', [
|
||||
'title' => 'Безопасность',
|
||||
'user' => $user,
|
||||
'active_tab' => 'security',
|
||||
'sessions' => $sessions,
|
||||
'currentSessionId' => session_id(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение списка активных сессий пользователя
|
||||
*/
|
||||
protected function getUserSessions(int $userId): array
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Получаем сессии из таблицы ci_sessions (требует настройки DatabaseHandler)
|
||||
// Также получаем remember-токены
|
||||
$rememberTokens = $db->table('remember_tokens')
|
||||
->where('user_id', $userId)
|
||||
->where('expires_at >', date('Y-m-d H:i:s'))
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$sessions = [];
|
||||
|
||||
// Добавляем remember-токены как сессии
|
||||
foreach ($rememberTokens as $token) {
|
||||
$sessions[] = [
|
||||
'id' => 'remember_' . $token['id'],
|
||||
'type' => 'remember',
|
||||
'device' => $this->parseUserAgent($token['user_agent'] ?? ''),
|
||||
'ip_address' => $token['ip_address'] ?? 'Unknown',
|
||||
'created_at' => $token['created_at'],
|
||||
'expires_at' => $token['expires_at'],
|
||||
'is_current' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг User Agent для получения информации об устройстве
|
||||
*/
|
||||
protected function parseUserAgent(string $userAgent): string
|
||||
{
|
||||
if (empty($userAgent)) {
|
||||
return 'Неизвестное устройство';
|
||||
}
|
||||
|
||||
// Определяем браузер
|
||||
$browser = 'Unknown';
|
||||
if (preg_match('/Firefox\/([0-9.]+)/', $userAgent, $matches)) {
|
||||
$browser = 'Firefox';
|
||||
} elseif (preg_match('/Chrome\/([0-9.]+)/', $userAgent, $matches)) {
|
||||
$browser = 'Chrome';
|
||||
} elseif (preg_match('/Safari\/([0-9.]+)/', $userAgent, $matches)) {
|
||||
$browser = 'Safari';
|
||||
} elseif (preg_match('/MSIE\s+([0-9.]+)/', $userAgent, $matches) || preg_match('/Trident\/([0-9.]+)/', $userAgent, $matches)) {
|
||||
$browser = 'Internet Explorer';
|
||||
} elseif (preg_match('/Edg\/([0-9.]+)/', $userAgent, $matches)) {
|
||||
$browser = 'Edge';
|
||||
}
|
||||
|
||||
// Определяем ОС
|
||||
$os = 'Unknown OS';
|
||||
if (preg_match('/Windows/', $userAgent)) {
|
||||
$os = 'Windows';
|
||||
} elseif (preg_match('/Mac OS X/', $userAgent)) {
|
||||
$os = 'macOS';
|
||||
} elseif (preg_match('/Linux/', $userAgent)) {
|
||||
$os = 'Linux';
|
||||
} elseif (preg_match('/Android/', $userAgent)) {
|
||||
$os = 'Android';
|
||||
} elseif (preg_match('/iPhone|iPad|iPod/', $userAgent)) {
|
||||
$os = 'iOS';
|
||||
}
|
||||
|
||||
return "{$browser} на {$os}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершение конкретной сессии
|
||||
*/
|
||||
public function revokeSession()
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
$sessionId = $this->request->getPost('session_id');
|
||||
|
||||
if (empty($sessionId)) {
|
||||
return redirect()->to('/profile/security')->with('error', 'Сессия не найдена');
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// Проверяем, что сессия принадлежит пользователю
|
||||
if (strpos($sessionId, 'remember_') === 0) {
|
||||
// Это remember-токен
|
||||
$tokenId = (int) str_replace('remember_', '', $sessionId);
|
||||
$token = $db->table('remember_tokens')
|
||||
->where('id', $tokenId)
|
||||
->where('user_id', $userId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($token) {
|
||||
$db->table('remember_tokens')->where('id', $tokenId)->delete();
|
||||
log_message('info', "User {$userId} revoked remember token {$tokenId}");
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('/profile/security')->with('success', 'Сессия завершена');
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершение всех сессий (кроме текущей)
|
||||
*/
|
||||
public function revokeAllSessions()
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
|
||||
// Удаляем все remember-токены
|
||||
$db = \Config\Database::connect();
|
||||
$db->table('remember_tokens')->where('user_id', $userId)->delete();
|
||||
|
||||
// Регенерируем текущую сессию
|
||||
$this->session->regenerate(true);
|
||||
|
||||
log_message('info', "User {$userId} revoked all sessions");
|
||||
|
||||
return redirect()->to('/profile/security')->with(
|
||||
'success',
|
||||
'Все сессии на других устройствах завершены. Вы остались авторизованы на текущем устройстве.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление имени пользователя
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
/**
|
||||
* Superadmin - Панель суперадмина
|
||||
*
|
||||
* Управление системой: тарифы, организации, пользователи.
|
||||
*/
|
||||
class Superadmin extends BaseController
|
||||
{
|
||||
protected $organizationModel;
|
||||
protected $userModel;
|
||||
protected $planModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->organizationModel = new \App\Models\OrganizationModel();
|
||||
$this->userModel = new \App\Models\UserModel();
|
||||
$this->planModel = new \App\Models\PlanModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Дашборд суперадмина
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Статистика
|
||||
$stats = [
|
||||
'total_users' => $this->userModel->countAll(),
|
||||
'total_orgs' => $this->organizationModel->countAll(),
|
||||
'active_today' => $this->userModel->where('created_at >=', date('Y-m-d'))->countAllResults(),
|
||||
'total_plans' => $this->planModel->countAll(),
|
||||
];
|
||||
|
||||
// Последние организации
|
||||
$recentOrgs = $this->organizationModel
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll(5);
|
||||
|
||||
// Последние пользователи
|
||||
$recentUsers = $this->userModel
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll(5);
|
||||
|
||||
return $this->renderTwig('superadmin/dashboard', compact('stats', 'recentOrgs', 'recentUsers'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// УПРАВЛЕНИЕ ТАРИФАМИ
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Список тарифов
|
||||
*/
|
||||
public function plans()
|
||||
{
|
||||
$plans = $this->planModel->findAll();
|
||||
// Декодируем features для Twig
|
||||
foreach ($plans as &$plan) {
|
||||
$plan['features'] = json_decode($plan['features'] ?? '[]', true);
|
||||
}
|
||||
|
||||
return $this->renderTwig('superadmin/plans/index', compact('plans'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание тарифа (форма)
|
||||
*/
|
||||
public function createPlan()
|
||||
{
|
||||
return $this->renderTwig('superadmin/plans/create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение тарифа
|
||||
*/
|
||||
public function storePlan()
|
||||
{
|
||||
// Получаем features из текстового поля (каждая строка - отдельная возможность)
|
||||
$featuresText = $this->request->getPost('features_list');
|
||||
$features = [];
|
||||
if ($featuresText) {
|
||||
$lines = explode("\n", trim($featuresText));
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line)) {
|
||||
$features[] = $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $this->request->getPost('name'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
'price' => (float) $this->request->getPost('price'),
|
||||
'currency' => $this->request->getPost('currency') ?? 'RUB',
|
||||
'billing_period' => $this->request->getPost('billing_period') ?? 'monthly',
|
||||
'max_users' => (int) $this->request->getPost('max_users'),
|
||||
'max_clients' => (int) $this->request->getPost('max_clients'),
|
||||
'max_storage' => (int) $this->request->getPost('max_storage'),
|
||||
'features' => json_encode($features),
|
||||
'is_active' => $this->request->getPost('is_active') ?? 1,
|
||||
'is_default' => $this->request->getPost('is_default') ?? 0,
|
||||
];
|
||||
|
||||
if (!$this->planModel->insert($data)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Ошибка создания тарифа: ' . implode(', ', $this->planModel->errors()));
|
||||
}
|
||||
|
||||
return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно создан');
|
||||
}
|
||||
|
||||
/**
|
||||
* Редактирование тарифа (форма)
|
||||
*/
|
||||
public function editPlan($id)
|
||||
{
|
||||
$plan = $this->planModel->find($id);
|
||||
if (!$plan) {
|
||||
throw new \CodeIgniter\Exceptions\PageNotFoundException('Тариф не найден');
|
||||
}
|
||||
|
||||
// Декодируем features для отображения в textarea
|
||||
$plan['features'] = json_decode($plan['features'] ?? '[]', true);
|
||||
|
||||
return $this->renderTwig('superadmin/plans/edit', compact('plan'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление тарифа
|
||||
*/
|
||||
public function updatePlan($id)
|
||||
{
|
||||
// Получаем features из текстового поля (каждая строка - отдельная возможность)
|
||||
$featuresText = $this->request->getPost('features_list');
|
||||
$features = [];
|
||||
if ($featuresText) {
|
||||
$lines = explode("\n", trim($featuresText));
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line)) {
|
||||
$features[] = $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $this->request->getPost('name'),
|
||||
'description' => $this->request->getPost('description'),
|
||||
'price' => (float) $this->request->getPost('price'),
|
||||
'currency' => $this->request->getPost('currency') ?? 'RUB',
|
||||
'billing_period' => $this->request->getPost('billing_period') ?? 'monthly',
|
||||
'max_users' => (int) $this->request->getPost('max_users'),
|
||||
'max_clients' => (int) $this->request->getPost('max_clients'),
|
||||
'max_storage' => (int) $this->request->getPost('max_storage'),
|
||||
'features' => json_encode($features),
|
||||
'is_active' => $this->request->getPost('is_active') ?? 1,
|
||||
'is_default' => $this->request->getPost('is_default') ?? 0,
|
||||
];
|
||||
|
||||
if (!$this->planModel->update($id, $data)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Ошибка обновления тарифа: ' . implode(', ', $this->planModel->errors()));
|
||||
}
|
||||
|
||||
return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно обновлён');
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление тарифа
|
||||
*/
|
||||
public function deletePlan($id)
|
||||
{
|
||||
if (!$this->planModel->delete($id)) {
|
||||
return redirect()->to('/superadmin/plans')->with('error', 'Ошибка удаления тарифа');
|
||||
}
|
||||
|
||||
return redirect()->to('/superadmin/plans')->with('success', 'Тариф успешно удалён');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// УПРАВЛЕНИЕ ОРГАНИЗАЦИЯМИ
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Конфигурация таблицы организаций
|
||||
*/
|
||||
protected function getOrganizationsTableConfig(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'organizations-table',
|
||||
'url' => '/superadmin/organizations/table',
|
||||
'model' => $this->organizationModel,
|
||||
'columns' => [
|
||||
'id' => ['label' => 'ID', 'width' => '60px'],
|
||||
'name' => ['label' => 'Название'],
|
||||
'type' => ['label' => 'Тип', 'width' => '100px'],
|
||||
'user_count' => ['label' => 'Пользователей', 'width' => '100px'],
|
||||
'status' => ['label' => 'Статус', 'width' => '120px'],
|
||||
'created_at' => ['label' => 'Дата', 'width' => '100px'],
|
||||
],
|
||||
'searchable' => ['name', 'id'],
|
||||
'sortable' => ['id', 'name', 'created_at'],
|
||||
'defaultSort' => 'created_at',
|
||||
'order' => 'desc',
|
||||
'scope' => function ($builder) {
|
||||
// JOIN с подсчётом пользователей организации
|
||||
$builder->from('organizations')
|
||||
->select('organizations.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.organization_id = organizations.id AND status = "active") as user_count');
|
||||
},
|
||||
'actions' => ['label' => 'Действия', 'width' => '140px'],
|
||||
'actionsConfig' => [
|
||||
[
|
||||
'label' => '',
|
||||
'url' => '/superadmin/organizations/view/{id}',
|
||||
'icon' => 'fa-solid fa-eye',
|
||||
'class' => 'btn-outline-primary',
|
||||
'title' => 'Просмотр',
|
||||
],
|
||||
[
|
||||
'label' => '',
|
||||
'url' => '/superadmin/organizations/block/{id}',
|
||||
'icon' => 'fa-solid fa-ban',
|
||||
'class' => 'btn-outline-warning',
|
||||
'title' => 'Заблокировать',
|
||||
'confirm' => 'Заблокировать организацию?',
|
||||
],
|
||||
[
|
||||
'label' => '',
|
||||
'url' => '/superadmin/organizations/delete/{id}',
|
||||
'icon' => 'fa-solid fa-trash',
|
||||
'class' => 'btn-outline-danger',
|
||||
'title' => 'Удалить',
|
||||
'confirm' => 'Удалить организацию? Это действие нельзя отменить!',
|
||||
],
|
||||
],
|
||||
'emptyMessage' => 'Организации не найдены',
|
||||
'emptyIcon' => 'bi bi-building',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Список организаций
|
||||
*/
|
||||
public function organizations()
|
||||
{
|
||||
$config = $this->getOrganizationsTableConfig();
|
||||
$tableHtml = $this->renderTable($config);
|
||||
|
||||
return $this->renderTwig('superadmin/organizations/index', [
|
||||
'tableHtml' => $tableHtml,
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX таблица организаций
|
||||
*/
|
||||
public function organizationsTable()
|
||||
{
|
||||
$config = $this->getOrganizationsTableConfig();
|
||||
return $this->table($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Просмотр организации
|
||||
*/
|
||||
public function viewOrganization($id)
|
||||
{
|
||||
$organization = $this->organizationModel->find($id);
|
||||
if (!$organization) {
|
||||
throw new \CodeIgniter\Exceptions\PageNotFoundException('Организация не найдена');
|
||||
}
|
||||
|
||||
// Пользователи организации
|
||||
$orgUserModel = new \App\Models\OrganizationUserModel();
|
||||
$users = $orgUserModel->getOrganizationUsers($id);
|
||||
|
||||
// Список тарифов для выбора
|
||||
$plans = $this->planModel->where('is_active', 1)->findAll();
|
||||
|
||||
// Текущая подписка организации из таблицы связей
|
||||
$db = \Config\Database::connect();
|
||||
$subscriptionTable = $db->table('organization_plan_subscriptions');
|
||||
$currentSubscription = $subscriptionTable
|
||||
->where('organization_id', $id)
|
||||
->orderBy('id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return $this->renderTwig('superadmin/organizations/view', compact('organization', 'users', 'plans', 'currentSubscription'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка организации
|
||||
*/
|
||||
public function blockOrganization($id)
|
||||
{
|
||||
$this->organizationModel->update($id, ['status' => 'blocked']);
|
||||
|
||||
return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Организация заблокирована');
|
||||
}
|
||||
|
||||
/**
|
||||
* Разблокировка организации
|
||||
*/
|
||||
public function unblockOrganization($id)
|
||||
{
|
||||
$this->organizationModel->update($id, ['status' => 'active']);
|
||||
|
||||
return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Организация разблокирована');
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление организации (полное удаление)
|
||||
*/
|
||||
public function deleteOrganization($id)
|
||||
{
|
||||
// Полное удаление без soft delete
|
||||
$this->organizationModel->delete($id, true);
|
||||
|
||||
return redirect()->to('/superadmin/organizations')->with('success', 'Организация удалена');
|
||||
}
|
||||
|
||||
/**
|
||||
* Назначение тарифа организации
|
||||
*/
|
||||
public function setOrganizationPlan($id)
|
||||
{
|
||||
$planId = $this->request->getPost('plan_id');
|
||||
$durationDays = (int) $this->request->getPost('duration_days');
|
||||
|
||||
if (!$planId) {
|
||||
return redirect()->back()->with('error', 'Выберите тариф');
|
||||
}
|
||||
|
||||
$plan = $this->planModel->find($planId);
|
||||
if (!$plan) {
|
||||
return redirect()->back()->with('error', 'Тариф не найден');
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$subscriptionsTable = $db->table('organization_plan_subscriptions');
|
||||
|
||||
$startDate = date('Y-m-d H:i:s');
|
||||
$endDate = $durationDays > 0
|
||||
? date('Y-m-d H:i:s', strtotime("+{$durationDays} days"))
|
||||
: null;
|
||||
|
||||
// Проверяем существующую подписку
|
||||
$existingSub = $subscriptionsTable
|
||||
->where('organization_id', $id)
|
||||
->where('plan_id', $planId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($existingSub) {
|
||||
// Обновляем существующую подписку
|
||||
$subscriptionsTable->where('id', $existingSub['id'])->update([
|
||||
'status' => $durationDays > 0 ? 'active' : 'trial',
|
||||
'trial_ends_at' => $endDate,
|
||||
'expires_at' => $endDate,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} else {
|
||||
// Создаём новую подписку
|
||||
$subscriptionsTable->insert([
|
||||
'organization_id' => $id,
|
||||
'plan_id' => $planId,
|
||||
'status' => $durationDays > 0 ? 'active' : 'trial',
|
||||
'trial_ends_at' => $endDate,
|
||||
'expires_at' => $endDate,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->to("/superadmin/organizations/view/{$id}")->with('success', 'Тариф успешно назначен');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Конфигурация таблицы пользователей
|
||||
*/
|
||||
protected function getUsersTableConfig(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'users-table',
|
||||
'url' => '/superadmin/users/table',
|
||||
'model' => $this->userModel,
|
||||
'columns' => [
|
||||
'id' => ['label' => 'ID', 'width' => '60px'],
|
||||
'name' => ['label' => 'Имя'],
|
||||
'email' => ['label' => 'Email'],
|
||||
'system_role' => ['label' => 'Роль', 'width' => '140px'],
|
||||
'org_count' => ['label' => 'Организаций', 'width' => '100px'],
|
||||
'status' => ['label' => 'Статус', 'width' => '120px'],
|
||||
'created_at' => ['label' => 'Дата', 'width' => '100px'],
|
||||
],
|
||||
'searchable' => ['name', 'email', 'id'],
|
||||
'sortable' => ['id', 'name', 'email', 'created_at'],
|
||||
'defaultSort' => 'created_at',
|
||||
'order' => 'desc',
|
||||
'scope' => function ($builder) {
|
||||
// JOIN с подсчётом организаций пользователя
|
||||
$builder->from('users')
|
||||
->select('users.*, (SELECT COUNT(*) FROM organization_users WHERE organization_users.user_id = users.id) as org_count');
|
||||
},
|
||||
'actions' => ['label' => 'Действия', 'width' => '140px'],
|
||||
'actionsConfig' => [
|
||||
[
|
||||
'label' => '',
|
||||
'url' => '/superadmin/users/block/{id}',
|
||||
'icon' => 'fa-solid fa-ban',
|
||||
'class' => 'btn-outline-warning',
|
||||
'title' => 'Заблокировать',
|
||||
'confirm' => 'Заблокировать пользователя?',
|
||||
],
|
||||
[
|
||||
'label' => '',
|
||||
'url' => '/superadmin/users/delete/{id}',
|
||||
'icon' => 'fa-solid fa-trash',
|
||||
'class' => 'btn-outline-danger',
|
||||
'title' => 'Удалить',
|
||||
'confirm' => 'Удалить пользователя? Это действие нельзя отменить!',
|
||||
],
|
||||
],
|
||||
'emptyMessage' => 'Пользователи не найдены',
|
||||
'emptyIcon' => 'bi bi-people',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Список пользователей
|
||||
*/
|
||||
public function users()
|
||||
{
|
||||
$config = $this->getUsersTableConfig();
|
||||
$tableHtml = $this->renderTable($config);
|
||||
|
||||
return $this->renderTwig('superadmin/users/index', [
|
||||
'tableHtml' => $tableHtml,
|
||||
'config' => $config,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX таблица пользователей
|
||||
*/
|
||||
public function usersTable()
|
||||
{
|
||||
$config = $this->getUsersTableConfig();
|
||||
return $this->table($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменение системной роли пользователя
|
||||
*/
|
||||
public function updateUserRole($id)
|
||||
{
|
||||
$newRole = $this->request->getPost('system_role');
|
||||
|
||||
$allowedRoles = ['user', 'admin', 'superadmin'];
|
||||
if (!in_array($newRole, $allowedRoles)) {
|
||||
return redirect()->back()->with('error', 'Недопустимая роль');
|
||||
}
|
||||
|
||||
$this->userModel->update($id, ['system_role' => $newRole]);
|
||||
|
||||
return redirect()->back()->with('success', 'Роль пользователя обновлена');
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокировка пользователя
|
||||
*/
|
||||
public function blockUser($id)
|
||||
{
|
||||
$this->userModel->update($id, ['status' => 'blocked']);
|
||||
|
||||
return redirect()->back()->with('success', 'Пользователь заблокирован');
|
||||
}
|
||||
|
||||
/**
|
||||
* Разблокировка пользователя
|
||||
*/
|
||||
public function unblockUser($id)
|
||||
{
|
||||
$this->userModel->update($id, ['status' => 'active']);
|
||||
|
||||
return redirect()->back()->with('success', 'Пользователь разблокирован');
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаление пользователя (полное удаление)
|
||||
*/
|
||||
public function deleteUser($id)
|
||||
{
|
||||
// Полное удаление без soft delete
|
||||
$this->userModel->delete($id, true);
|
||||
|
||||
return redirect()->to('/superadmin/users')->with('success', 'Пользователь удалён');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// СТАТИСТИКА
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Статистика использования
|
||||
*/
|
||||
public function statistics()
|
||||
{
|
||||
// Статистика по дням (последние 30 дней)
|
||||
$dailyStats = [];
|
||||
for ($i = 29; $i >= 0; $i--) {
|
||||
$date = date('Y-m-d', strtotime("-{$i} days"));
|
||||
$dailyStats[] = [
|
||||
'date' => $date,
|
||||
'users' => $this->userModel->where('DATE(created_at)', $date)->countAllResults(),
|
||||
'orgs' => $this->organizationModel->where('DATE(created_at)', $date)->countAllResults(),
|
||||
];
|
||||
}
|
||||
|
||||
// Статистика по тарифам (через таблицу подписок)
|
||||
$planStats = [];
|
||||
$plans = $this->planModel->where('is_active', 1)->findAll();
|
||||
|
||||
// Проверяем существование таблицы подписок
|
||||
$db = \Config\Database::connect();
|
||||
$tableExists = $db->tableExists('organization_plan_subscriptions');
|
||||
|
||||
if ($tableExists) {
|
||||
$subscriptionsTable = $db->table('organization_plan_subscriptions');
|
||||
foreach ($plans as $plan) {
|
||||
$count = $subscriptionsTable->where('plan_id', $plan['id'])
|
||||
->where('status', 'active')
|
||||
->countAllResults();
|
||||
$planStats[$plan['id']] = [
|
||||
'name' => $plan['name'],
|
||||
'orgs_count' => $count,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Таблица подписок ещё не создана - показываем 0 для всех тарифов
|
||||
foreach ($plans as $plan) {
|
||||
$planStats[$plan['id']] = [
|
||||
'name' => $plan['name'],
|
||||
'orgs_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderTwig('superadmin/statistics', compact('dailyStats', 'planStats'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user