add superadmin dashboard. subscriptions

This commit is contained in:
root 2026-01-13 20:03:16 +03:00
parent 3c24c250e5
commit edb4df7e37
36 changed files with 3329 additions and 100 deletions

View File

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

View File

@ -5,7 +5,9 @@ use CodeIgniter\Router\RouteCollection;
/** /**
* @var RouteCollection $routes * @var RouteCollection $routes
*/ */
# Публичные маршруты (без фильтра org) # =============================================================================
# ПУБЛИЧНЫЕ МАРШРУТЫ (без фильтров)
# =============================================================================
$routes->get('/', 'Home::index'); $routes->get('/', 'Home::index');
$routes->get('login', 'Auth::login'); $routes->get('login', 'Auth::login');
$routes->post('login', 'Auth::login'); $routes->post('login', 'Auth::login');
@ -17,7 +19,13 @@ $routes->get('auth/verify/(:any)', 'Auth::verify/$1');
$routes->get('auth/resend-verification', 'Auth::resendVerification'); $routes->get('auth/resend-verification', 'Auth::resendVerification');
$routes->post('auth/resend-verification', 'Auth::resendVerification'); $routes->post('auth/resend-verification', 'Auth::resendVerification');
# Маршруты для приглашений (публичные, без фильтра org) # Маршруты для восстановления пароля
$routes->get('forgot-password', 'ForgotPassword::index');
$routes->post('forgot-password/send', 'ForgotPassword::sendResetLink');
$routes->get('forgot-password/reset/(:any)', 'ForgotPassword::reset/$1');
$routes->post('forgot-password/update', 'ForgotPassword::updatePassword');
# Маршруты для приглашений (публичные)
$routes->group('invitation', static function ($routes) { $routes->group('invitation', static function ($routes) {
$routes->get('accept/(:any)', 'InvitationController::accept/$1'); $routes->get('accept/(:any)', 'InvitationController::accept/$1');
$routes->post('accept/(:any)', 'InvitationController::processAccept'); $routes->post('accept/(:any)', 'InvitationController::processAccept');
@ -25,17 +33,38 @@ $routes->group('invitation', static function ($routes) {
$routes->match(['GET', 'POST'], 'complete/(:any)', 'InvitationController::complete/$1'); $routes->match(['GET', 'POST'], 'complete/(:any)', 'InvitationController::complete/$1');
}); });
# Защищённые маршруты (с фильтром org) # =============================================================================
$routes->group('', ['filter' => 'org'], static function ($routes) { # АВТОРИЗОВАННЫЕ МАРШРУТЫ (требуется auth, НЕ требуется org)
# =============================================================================
$routes->group('', ['filter' => 'auth'], static function ($routes) {
# Профиль (доступен без выбора организации)
$routes->get('profile', 'Profile::index');
$routes->get('profile/organizations', 'Profile::organizations');
$routes->get('profile/security', 'Profile::security');
$routes->post('profile/update-name', 'Profile::updateName');
$routes->post('profile/upload-avatar', 'Profile::uploadAvatar');
$routes->post('profile/change-password', 'Profile::changePassword');
$routes->post('profile/session/revoke', 'Profile::revokeSession');
$routes->post('profile/sessions/revoke-all', 'Profile::revokeAllSessions');
$routes->post('profile/leave-org/(:num)', 'Profile::leaveOrganization/$1');
# Выбор организации (доступен без выбора организации)
$routes->get('organizations', 'Organizations::index'); $routes->get('organizations', 'Organizations::index');
$routes->get('organizations/(:num)/dashboard', 'Organizations::dashboard/$1');
$routes->get('organizations/create', 'Organizations::create'); $routes->get('organizations/create', 'Organizations::create');
$routes->post('organizations/create', 'Organizations::create'); $routes->post('organizations/create', 'Organizations::create');
$routes->get('organizations/switch/(:num)', 'Organizations::switch/$1');
});
# =============================================================================
# ЗАЩИЩЁННЫЕ МАРШУТЫ (требуется auth И org)
# =============================================================================
$routes->group('', ['filter' => 'auth'], static function ($routes) {
$routes->group('', ['filter' => 'org'], static function ($routes) {
$routes->get('organizations/(:num)/dashboard', 'Organizations::dashboard/$1');
$routes->get('organizations/edit/(:num)', 'Organizations::edit/$1'); $routes->get('organizations/edit/(:num)', 'Organizations::edit/$1');
$routes->post('organizations/edit/(:num)', 'Organizations::edit/$1'); $routes->post('organizations/edit/(:num)', 'Organizations::edit/$1');
$routes->get('organizations/delete/(:num)', 'Organizations::delete/$1'); $routes->get('organizations/delete/(:num)', 'Organizations::delete/$1');
$routes->post('organizations/delete/(:num)', 'Organizations::delete/$1'); $routes->post('organizations/delete/(:num)', 'Organizations::delete/$1');
$routes->get('organizations/switch/(:num)', 'Organizations::switch/$1');
# Управление пользователями организации # Управление пользователями организации
$routes->get('organizations/(:num)/users', 'Organizations::users/$1'); $routes->get('organizations/(:num)/users', 'Organizations::users/$1');
@ -49,16 +78,42 @@ $routes->group('', ['filter' => 'org'], static function ($routes) {
$routes->post('organizations/(:num)/users/leave', 'Organizations::leaveOrganization/$1'); $routes->post('organizations/(:num)/users/leave', 'Organizations::leaveOrganization/$1');
$routes->post('organizations/(:num)/users/(:num)/resend', 'Organizations::resendInvite/$1/$2'); $routes->post('organizations/(:num)/users/(:num)/resend', 'Organizations::resendInvite/$1/$2');
$routes->post('organizations/(:num)/users/(:num)/cancel', 'Organizations::cancelInvite/$1/$2'); $routes->post('organizations/(:num)/users/(:num)/cancel', 'Organizations::cancelInvite/$1/$2');
});
}); });
# Маршруты профиля # =============================================================================
$routes->get('profile', 'Profile::index'); # ПОДКЛЮЧЕНИЕ РОУТОВ МОДУЛЕЙ (требуется auth)
$routes->get('profile/organizations', 'Profile::organizations'); # =============================================================================
$routes->get('profile/security', 'Profile::security'); $routes->group('', ['filter' => 'auth'], static function ($routes) {
$routes->post('profile/update-name', 'Profile::updateName'); require_once APPPATH . 'Modules/Clients/Config/Routes.php';
$routes->post('profile/upload-avatar', 'Profile::uploadAvatar'); });
$routes->post('profile/change-password', 'Profile::changePassword');
$routes->post('profile/leave-org/(:num)', 'Profile::leaveOrganization/$1');
# Подключение роутов модулей # =============================================================================
require_once APPPATH . 'Modules/Clients/Config/Routes.php'; # СУПЕРАДМИН ПАНЕЛЬ (требуется system_role: superadmin)
# =============================================================================
$routes->group('superadmin', ['filter' => 'role:system:superadmin'], static function ($routes) {
$routes->get('/', 'Superadmin::index');
$routes->get('plans', 'Superadmin::plans');
$routes->get('plans/create', 'Superadmin::createPlan');
$routes->post('plans/store', 'Superadmin::storePlan');
$routes->get('plans/edit/(:num)', 'Superadmin::editPlan/$1');
$routes->post('plans/update/(:num)', 'Superadmin::updatePlan/$1');
$routes->get('plans/delete/(:num)', 'Superadmin::deletePlan/$1');
$routes->get('organizations', 'Superadmin::organizations');
$routes->get('organizations/table', 'Superadmin::organizationsTable');
$routes->get('organizations/view/(:num)', 'Superadmin::viewOrganization/$1');
$routes->post('organizations/set-plan/(:num)', 'Superadmin::setOrganizationPlan/$1');
$routes->get('organizations/block/(:num)', 'Superadmin::blockOrganization/$1');
$routes->get('organizations/unblock/(:num)', 'Superadmin::unblockOrganization/$1');
$routes->get('organizations/delete/(:num)', 'Superadmin::deleteOrganization/$1');
$routes->get('users', 'Superadmin::users');
$routes->get('users/table', 'Superadmin::usersTable');
$routes->post('users/update-role/(:num)', 'Superadmin::updateUserRole/$1');
$routes->get('users/block/(:num)', 'Superadmin::blockUser/$1');
$routes->get('users/unblock/(:num)', 'Superadmin::unblockUser/$1');
$routes->get('users/delete/(:num)', 'Superadmin::deleteUser/$1');
$routes->get('statistics', 'Superadmin::statistics');
});

View File

@ -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',
'Пароль успешно изменён. Теперь вы можете войти с новым паролем.'
);
}
}

View File

@ -96,13 +96,146 @@ class Profile extends BaseController
$userId = $this->getCurrentUserId(); $userId = $this->getCurrentUserId();
$user = $this->userModel->find($userId); $user = $this->userModel->find($userId);
// Получаем список активных сессий
$sessions = $this->getUserSessions($userId);
return $this->renderTwig('profile/security', [ return $this->renderTwig('profile/security', [
'title' => 'Безопасность', 'title' => 'Безопасность',
'user' => $user, 'user' => $user,
'active_tab' => 'security', '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',
'Все сессии на других устройствах завершены. Вы остались авторизованы на текущем устройстве.'
);
}
/** /**
* Обновление имени пользователя * Обновление имени пользователя
*/ */

View File

@ -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'));
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Миграция для таблицы сессий CodeIgniter 4
*
* Эта таблица используется для хранения сессий в базе данных,
* что позволяет управлять активными сессиями пользователей.
*/
class CreateCiSessionsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'VARCHAR',
'constraint' => 128,
],
'ip_address' => [
'type' => 'VARCHAR',
'constraint' => 45,
],
'timestamp' => [
'type' => 'INT',
'unsigned' => true,
'default' => 0,
],
'data' => [
'type' => 'BLOB',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('timestamp');
$this->forge->createTable('ci_sessions');
}
public function down()
{
$this->forge->dropTable('ci_sessions');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddPasswordResetFieldsToUsers extends Migration
{
public function up()
{
$fields = [
'reset_token' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'after' => 'verified_at',
],
'reset_expires_at' => [
'type' => 'DATETIME',
'null' => true,
'after' => 'reset_token',
],
];
$this->forge->addColumn('users', $fields);
}
public function down()
{
$this->forge->dropColumn('users', ['reset_token', 'reset_expires_at']);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSystemRoleToUsers extends Migration
{
public function up()
{
$fields = [
'system_role' => [
'type' => 'ENUM',
'constraint' => ['user', 'admin', 'superadmin'],
'default' => 'user',
'after' => 'password',
],
];
$this->forge->addColumn('users', $fields);
}
public function down()
{
$this->forge->dropColumn('users', 'system_role');
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Migration для создания таблицы тарифов (plans)
*/
class AddPlansTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'name' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => false,
],
'description' => [
'type' => 'TEXT',
'null' => true,
],
'price' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0.00,
],
'currency' => [
'type' => 'VARCHAR',
'constraint' => 3,
'default' => 'RUB',
],
'billing_period' => [
'type' => 'ENUM',
'constraint' => ['monthly', 'yearly', 'quarterly'],
'default' => 'monthly',
],
'max_users' => [
'type' => 'INT',
'constraint' => 11,
'default' => 5,
],
'max_clients' => [
'type' => 'INT',
'constraint' => 11,
'default' => 100,
],
'max_storage' => [
'type' => 'INT',
'constraint' => 11,
'default' => 10,
],
'features' => [
'type' => 'JSON',
'null' => true,
],
'is_active' => [
'type' => 'TINYINT',
'default' => 1,
],
'is_default' => [
'type' => 'TINYINT',
'default' => 0,
],
'created_at' => [
'type' => 'DATETIME',
'null' => false,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey(['name']);
$this->forge->createTable('plans');
// Добавление базовых тарифов по умолчанию
$seedData = [
[
'name' => 'Бесплатный',
'description' => 'Базовый тариф для небольших команд',
'price' => 0,
'currency' => 'RUB',
'billing_period' => 'monthly',
'max_users' => 3,
'max_clients' => 50,
'max_storage' => 5,
'features' => json_encode([
'Базовые модули',
'Email поддержка',
'Экспорт в CSV',
]),
'is_active' => 1,
'is_default' => 1,
'created_at' => date('Y-m-d H:i:s'),
],
[
'name' => 'Старт',
'description' => 'Тариф для растущих компаний',
'price' => 990,
'currency' => 'RUB',
'billing_period' => 'monthly',
'max_users' => 10,
'max_clients' => 500,
'max_storage' => 50,
'features' => json_encode([
'Все модули',
'Приоритетная поддержка',
'Экспорт в PDF и Excel',
'API доступ',
]),
'is_active' => 1,
'is_default' => 0,
'created_at' => date('Y-m-d H:i:s'),
],
[
'name' => 'Бизнес',
'description' => 'Полный функционал для крупных компаний',
'price' => 4990,
'currency' => 'RUB',
'billing_period' => 'monthly',
'max_users' => 50,
'max_clients' => 5000,
'max_storage' => 500,
'features' => json_encode([
'Все модули',
'Персональный менеджер',
'Экспорт в PDF и Excel',
'Полный API доступ',
'Интеграции',
'Брендинг',
]),
'is_active' => 1,
'is_default' => 0,
'created_at' => date('Y-m-d H:i:s'),
],
];
$this->db->table('plans')->insertBatch($seedData);
}
public function down()
{
$this->forge->dropTable('plans');
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
/**
* Migration для создания таблицы подписок организаций на тарифы
* Использует ту же структуру что и organization_subscriptions для модулей
*/
class CreateOrganizationPlanSubscriptionsTable extends Migration
{
public function up()
{
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'organization_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'plan_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
],
'status' => [
'type' => 'ENUM',
'constraint' => ['trial', 'active', 'expired', 'cancelled'],
'default' => 'trial',
],
'trial_ends_at' => [
'type' => 'DATETIME',
'null' => true,
],
'expires_at' => [
'type' => 'DATETIME',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => true,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
// Организация не может иметь две активные подписки на один тариф одновременно
$this->forge->addUniqueKey(['organization_id', 'plan_id']);
$this->forge->addForeignKey('organization_id', 'organizations', 'id', 'CASCADE', 'CASCADE');
$this->forge->addForeignKey('plan_id', 'plans', 'id', 'CASCADE', 'CASCADE');
$this->forge->createTable('organization_plan_subscriptions');
}
public function down()
{
$this->forge->dropTable('organization_plan_subscriptions');
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Database\Seeds;
use CodeIgniter\Database\Seeder;
use App\Models\UserModel;
/**
* Seeder для назначения системных ролей пользователям
*
* Использование:
* php spark db:seed "App\Database\Seeds\SetSystemRoleSeeder" -email=admin@example.com -role=superadmin
*
* Только email (по умолчанию роль superadmin):
* php spark db:seed "App\Database\Seeds\SetSystemRoleSeeder" -email=admin@example.com
*/
class SetSystemRoleSeeder extends Seeder
{
/**
* Системные роли
*/
public const ROLE_USER = 'user';
public const ROLE_ADMIN = 'admin';
public const ROLE_SUPERADMIN = 'superadmin';
/**
* {@inheritDoc}
*/
public function run()
{
// Получаем параметры из командной строки
$email = $this->parseArg('email');
$role = $this->parseArg('role') ?? self::ROLE_SUPERADMIN;
if (empty($email)) {
echo "Ошибка: Не указан email пользователя\n";
echo "\n";
echo "Использование:\n";
echo " php spark db:seed \"App\Database\Seeds\SetSystemRoleSeeder\" -email=admin@example.com -role=superadmin\n";
echo "\n";
echo "Доступные роли: user, admin, superadmin\n";
echo "По умолчанию: superadmin\n";
return;
}
$this->assignRole($email, $role);
}
/**
* Парсинг аргумента из командной строки
*/
protected function parseArg(string $name): ?string
{
global $argv;
foreach ($argv as $arg) {
// Формат: -email=value
if (preg_match("/^-{$name}=(.+)$/", $arg, $matches)) {
return $matches[1];
}
}
return null;
}
/**
* Назначение роли пользователю по email
*/
public function assignRole(string $email, string $role): bool
{
$userModel = new UserModel();
// Ищем пользователя по email
$user = $userModel->where('email', $email)->first();
if (!$user) {
echo "Ошибка: Пользователь с email '{$email}' не найден в базе данных\n";
return false;
}
// Отладка
echo "DEBUG: Found user ID = " . $user['id'] . "\n";
echo "DEBUG: Current system_role = " . ($user['system_role'] ?? 'NULL') . "\n";
// Валидируем роль
$validRoles = [self::ROLE_USER, self::ROLE_ADMIN, self::ROLE_SUPERADMIN];
if (!in_array($role, $validRoles)) {
echo "Ошибка: Неизвестная роль '{$role}'. Доступные роли: " . implode(', ', $validRoles) . "\n";
return false;
}
// Используем DB Builder для обновления
$db = \Config\Database::connect();
$result = $db->table('users')
->where('id', $user['id'])
->set('system_role', $role)
->update();
if (!$result) {
echo "Ошибка обновления\n";
return false;
}
// Проверяем что обновилось
$updatedUser = $userModel->find($user['id']);
echo "DEBUG: New system_role = " . ($updatedUser['system_role'] ?? 'NULL') . "\n";
$roleLabels = [
self::ROLE_USER => 'Пользователь',
self::ROLE_ADMIN => 'Администратор',
self::ROLE_SUPERADMIN => 'Суперадмин',
];
$roleLabel = $roleLabels[$role] ?? $role;
echo "Успех!\n";
echo " Email: {$email}\n";
echo " User ID: {$user['id']}\n";
echo " Назначенная роль: {$roleLabel}\n";
return true;
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Filters;
use App\Controllers\Auth;
use App\Models\UserModel;
use App\Models\OrganizationUserModel;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
/**
* AuthFilter - Фильтр аутентификации с поддержкой Remember Me
*
* Проверяет авторизацию пользователя двумя способами:
* 1. По сессии (стандартный способ)
* 2. По remember-токену из cookies (если сессия отсутствует)
*
* Если пользователь не авторизован редирект на /login.
*
* Применяется к маршрутам, которые требуют авторизации, но НЕ требуют
* выбранной организации (например, профиль, выбор организации).
*/
class AuthFilter implements FilterInterface
{
/**
* Проверка аутентификации перед обработкой запроса
*
* @param RequestInterface $request
* @param array|null $arguments
* @return ResponseInterface|void
*/
public function before(RequestInterface $request, $arguments = null)
{
$session = session();
// Если пользователь уже авторизован по сессии — пропускаем
if ($session->get('isLoggedIn')) {
return;
}
// Проверяем remember-токен
$userId = Auth::checkRememberToken();
if ($userId !== null) {
// Токен найден и валиден — восстанавливаем сессию
$userModel = new UserModel();
$user = $userModel->find($userId);
if ($user && $user['email_verified']) {
$orgUserModel = new OrganizationUserModel();
$userOrgs = $orgUserModel->where('user_id', $user['id'])->findAll();
if (!empty($userOrgs)) {
// Восстанавливаем данные сессии
$sessionData = [
'user_id' => $user['id'],
'email' => $user['email'],
'name' => $user['name'],
'isLoggedIn' => true,
];
// Выбираем организацию если она была
if (count($userOrgs) === 1) {
$sessionData['active_org_id'] = $userOrgs[0]['organization_id'];
}
$session->set($sessionData);
log_message('info', "User {$user['email']} logged in via remember token");
return;
}
}
// Токен недействителен — удаляем его
$response = service('response');
$response->deleteCookie('remember_selector');
$response->deleteCookie('remember_token');
}
// Пользователь не авторизован — редирект на логин
return redirect()->to('/login');
}
/**
* Обработка после выполнения запроса
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @param array|null $arguments
* @return void
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{
// Ничего не делаем после запроса
}
}

View File

@ -6,74 +6,47 @@ use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\ResponseInterface;
/**
* OrganizationFilter - Фильтр выбора организации
*
* Проверяет, что у авторизованного пользователя выбрана организация.
* Если организация не выбрана редирект на /organizations.
*
* Применяется к маршрутам, которые требуют выбранной организации
* (например, дашборд, управление клиентами, задачи и т.д.).
*/
class OrganizationFilter implements FilterInterface class OrganizationFilter implements FilterInterface
{ {
/**
* Проверка выбора организации перед обработкой запроса
*
* @param RequestInterface $request
* @param array|null $arguments
* @return ResponseInterface|void
*/
public function before(RequestInterface $request, $arguments = null) public function before(RequestInterface $request, $arguments = null)
{ {
$session = session(); // Если пользователь не авторизован — пропускаем (AuthFilter разберётся)
$uri = $request->getUri(); if (!session()->get('isLoggedIn')) {
$currentPath = $uri->getPath();
// === БЛОК ДЛЯ ГОСТЕЙ (НЕЗАЛОГИНЕННЫХ) ===
if (!$session->get('isLoggedIn')) {
// Список публичных маршрутов для гостей
$publicRoutes = [
'/',
'/login',
'/register',
'/register/success',
'/auth/verify',
'/auth/resend-verification',
];
if (!in_array($currentPath, $publicRoutes)) {
// Если гость лезет не туда (например /organizations) — на главную
return redirect()->to('/');
}
// Иначе пропускаем
return;
}
// =======================================
// === БЛОК ДЛЯ ЗАЛОГИНЕННЫХ ===
// Список маршрутов, доступных БЕЗ выбранной организации
$publicAuthRoutes = [
'/login',
'/register',
'/logout',
'/register/success',
'/auth/verify',
'/auth/resend-verification',
'/organizations',
'/organizations/create',
'/organizations/switch' // Начало пути для переключения
];
// Если мы на этих маршрутах — проверка orgId не нужна (мы просто выбираем её)
if (in_array($currentPath, $publicAuthRoutes) || strpos($currentPath, '/organizations/switch') === 0) {
return; return;
} }
// Если мы попадаем сюда — значит пользователь идет на закрытую страницу (например /crm) // Проверяем, выбрана ли организация
// или на главную (/).
// Главную страницу (/) мы проверяем на уровне контроллера Home::index,
// поэтому фильтр для '/' можно пропускать, ИЛИ проверить здесь.
// Пропустим '/', чтобы контроллер сам решил: редиректить на /organizations или показывать дашборд.
if ($currentPath === '/') {
return;
}
// Для всех остальных закрытых страниц проверяем active_org_id
if (empty(session()->get('active_org_id'))) { if (empty(session()->get('active_org_id'))) {
return redirect()->to('/organizations'); return redirect()->to('/organizations');
} }
} }
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) /**
* Обработка после выполнения запроса
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @param array|null $arguments
* @return void
*/
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
{ {
// Do nothing // Ничего не делаем после запроса
} }
} }

View File

@ -34,24 +34,43 @@ class RoleFilter implements FilterInterface
$access = AccessService::getInstance(); $access = AccessService::getInstance();
// Разбор аргументов
// Формат: 'role:admin,manager' или 'role:system:superadmin' или 'permission:manage_users:users'
// Проверка системных ролей (system: prefix)
if (is_string($arguments) && str_starts_with($arguments, 'role:system:')) {
$roles = explode(',', substr($arguments, 13)); // 13 = длина 'role:system:'
$roles = array_map('trim', $roles);
// Для системных ролей НЕ требуется авторизация в организации
if (!$access->isSystemRole($roles)) {
return $this->forbiddenResponse();
}
return null;
}
// Проверка организационных ролей
if (is_string($arguments) && str_starts_with($arguments, 'role:')) {
$roles = explode(',', substr($arguments, 5));
$roles = array_map('trim', $roles);
// Проверка авторизации в организации // Проверка авторизации в организации
if (!$access->isAuthenticated()) { if (!$access->isAuthenticated()) {
// Если пользователь не авторизован в организации - редирект на выбор организации // Если пользователь не авторизован в организации - редирект на выбор организации
return redirect()->to('/organizations'); return redirect()->to('/organizations');
} }
// Разбор аргументов
// Формат: 'role:admin,manager' или 'permission:manage_users:users'
if (is_string($arguments) && str_starts_with($arguments, 'role:')) {
$roles = explode(',', substr($arguments, 5));
$roles = array_map('trim', $roles);
if (!$access->isRole($roles)) { if (!$access->isRole($roles)) {
return $this->forbiddenResponse(); return $this->forbiddenResponse();
} }
} }
if (is_string($arguments) && str_starts_with($arguments, 'permission:')) { if (is_string($arguments) && str_starts_with($arguments, 'permission:')) {
// Проверка авторизации в организации для разрешений
if (!$access->isAuthenticated()) {
return redirect()->to('/organizations');
}
$parts = explode(':', substr($arguments, 11)); $parts = explode(':', substr($arguments, 11));
if (count($parts) >= 2) { if (count($parts) >= 2) {
$permission = $parts[0]; $permission = $parts[0];

View File

@ -65,4 +65,36 @@ class EmailLibrary
return false; return false;
} }
} }
/**
* Отправить письмо для сброса пароля
*/
public function sendPasswordResetEmail(string $email, string $name, string $token): bool
{
$emailConfig = config('Email');
// Генерируем URL для сброса пароля
$resetUrl = base_url('/forgot-password/reset/' . $token);
// Рендерим HTML письма через Twig
$twig = Services::twig();
$htmlBody = $twig->render('emails/password_reset', [
'name' => $name,
'reset_url' => $resetUrl,
'app_name' => $emailConfig->fromName ?? 'Бизнес.Точка',
]);
$emailer = Services::email($emailConfig);
$emailer->setTo($email);
$emailer->setFrom($emailConfig->fromEmail, $emailConfig->fromName);
$emailer->setSubject('Сброс пароля');
$emailer->setMessage($htmlBody);
try {
return $emailer->send();
} catch (\Exception $e) {
log_message('error', 'Ошибка отправки email: ' . $e->getMessage());
return false;
}
}
} }

View File

@ -42,6 +42,11 @@ class TwigGlobalsExtension extends AbstractExtension
new TwigFunction('role_badge', [$this, 'roleBadge'], ['is_safe' => ['html']]), new TwigFunction('role_badge', [$this, 'roleBadge'], ['is_safe' => ['html']]),
new TwigFunction('status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]), new TwigFunction('status_badge', [$this, 'statusBadge'], ['is_safe' => ['html']]),
new TwigFunction('get_all_roles', [$this, 'getAllRoles'], ['is_safe' => ['html']]), new TwigFunction('get_all_roles', [$this, 'getAllRoles'], ['is_safe' => ['html']]),
// System role functions (superadmin)
new TwigFunction('is_superadmin', [$this, 'isSuperadmin'], ['is_safe' => ['html']]),
new TwigFunction('is_system_admin', [$this, 'isSystemAdmin'], ['is_safe' => ['html']]),
new TwigFunction('get_system_role', [$this, 'getSystemRole'], ['is_safe' => ['html']]),
]; ];
} }
@ -137,6 +142,26 @@ class TwigGlobalsExtension extends AbstractExtension
return '<span class="badge ' . esc($color) . '">' . esc($label) . '</span>'; return '<span class="badge ' . esc($color) . '">' . esc($label) . '</span>';
} }
// ========================================
// System Role Functions (superadmin)
// ========================================
public function isSuperadmin(): bool
{
return service('access')->isSuperadmin();
}
public function isSystemAdmin(): bool
{
return service('access')->isSystemAdmin();
}
public function getSystemRole(): ?string
{
return service('access')->getSystemRole();
}
public function statusBadge(string $status): string public function statusBadge(string $status): string
{ {
$colors = [ $colors = [

View File

@ -11,7 +11,16 @@ class OrganizationModel extends Model
protected $useAutoIncrement = true; protected $useAutoIncrement = true;
protected $returnType = 'array'; protected $returnType = 'array';
protected $useSoftDeletes = true; // Включаем мягкое удаление (deleted_at) protected $useSoftDeletes = true; // Включаем мягкое удаление (deleted_at)
protected $allowedFields = ['owner_id', 'name', 'type', 'logo', 'requisites', 'trial_ends_at', 'settings']; protected $allowedFields = [
'owner_id',
'name',
'type',
'logo',
'requisites',
'trial_ends_at',
'settings',
'status',
];
protected $useTimestamps = true; protected $useTimestamps = true;
protected $dateFormat = 'datetime'; protected $dateFormat = 'datetime';

79
app/Models/PlanModel.php Normal file
View File

@ -0,0 +1,79 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
* PlanModel - Модель тарифов
*/
class PlanModel extends Model
{
protected $table = 'plans';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'name',
'description',
'price',
'currency',
'billing_period',
'max_users',
'max_clients',
'max_storage',
'features',
'is_active',
'is_default',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'name' => 'required|min_length[2]|max_length[100]',
'price' => 'required|numeric|greater_than_equal[0]',
'max_users' => 'required|integer|greater_than[0]',
'max_clients' => 'required|integer|greater_than[0]',
'max_storage' => 'required|integer|greater_than[0]',
];
protected $validationMessages = [
'name' => [
'required' => 'Название тарифа обязательно',
'min_length' => 'Название должно быть минимум 2 символа',
],
'price' => [
'required' => 'Цена обязательна',
'numeric' => 'Цена должна быть числом',
],
];
/**
* Получение активных тарифов
*/
public function getActivePlans()
{
return $this->where('is_active', 1)->findAll();
}
/**
* Получение тарифа по умолчанию
*/
public function getDefaultPlan()
{
return $this->where('is_default', 1)->where('is_active', 1)->first();
}
/**
* Проверка, является ли тариф системным
*/
public function isSystemPlan($planId)
{
$plan = $this->find($planId);
return $plan !== null;
}
}

View File

@ -24,7 +24,13 @@ class UserModel extends Model
// Поля для верификации email // Поля для верификации email
'verification_token', 'verification_token',
'email_verified', 'email_verified',
'verified_at' 'verified_at',
// Поля для восстановления пароля
'reset_token',
'reset_expires_at',
//системная роль
'system_role',
'status',
]; ];
// Dates // Dates
@ -44,4 +50,75 @@ class UserModel extends Model
} }
return $data; return $data;
} }
/**
* Генерация токена для сброса пароля
*
* @param int $userId ID пользователя
* @param int $expiresInHours Срок действия токена в часах (по умолчанию 24 часа)
* @return string Сгенерированный токен
*/
public function generateResetToken(int $userId, int $expiresInHours = 24): string
{
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', strtotime("+{$expiresInHours} hours"));
$this->update($userId, [
'reset_token' => $token,
'reset_expires_at' => $expiresAt,
]);
return $token;
}
/**
* Проверка токена сброса пароля
*
* @param string $token Токен сброса
* @return array|null Данные пользователя или null если токен недействителен
*/
public function verifyResetToken(string $token): ?array
{
$user = $this->where('reset_token', $token)->first();
if (!$user) {
return null;
}
// Проверяем срок действия токена
if (empty($user['reset_expires_at'])) {
return null;
}
if (strtotime($user['reset_expires_at']) < time()) {
return null;
}
return $user;
}
/**
* Очистка токена сброса пароля
*
* @param int $userId ID пользователя
* @return bool
*/
public function clearResetToken(int $userId): bool
{
return $this->update($userId, [
'reset_token' => null,
'reset_expires_at' => null,
]);
}
/**
* Поиск пользователя по email
*
* @param string $email Email адрес
* @return array|null
*/
public function findByEmail(string $email): ?array
{
return $this->where('email', $email)->first();
}
} }

View File

@ -1,6 +1,5 @@
<?php <?php
// Подключаем роуты модуля Clients
$routes->group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) { $routes->group('clients', ['filter' => 'org', 'namespace' => 'App\Modules\Clients\Controllers'], static function ($routes) {
$routes->get('/', 'Clients::index'); $routes->get('/', 'Clients::index');
$routes->get('table', 'Clients::table'); // AJAX endpoint для таблицы $routes->get('table', 'Clients::table'); // AJAX endpoint для таблицы

View File

@ -17,7 +17,14 @@ class AccessService
private OrganizationUserModel $orgUserModel; private OrganizationUserModel $orgUserModel;
/** /**
* Роли и их уровни (для быстрого сравнения) * Системные роли (уровень всей системы, не организации)
*/
public const SYSTEM_ROLE_USER = 'user';
public const SYSTEM_ROLE_ADMIN = 'admin';
public const SYSTEM_ROLE_SUPERADMIN = 'superadmin';
/**
* Роли организации (для быстрого сравнения)
*/ */
public const ROLE_OWNER = 'owner'; public const ROLE_OWNER = 'owner';
public const ROLE_ADMIN = 'admin'; public const ROLE_ADMIN = 'admin';
@ -84,6 +91,8 @@ class AccessService
], ],
]; ];
private ?string $cachedSystemRole = null;
public function __construct() public function __construct()
{ {
$this->orgUserModel = new OrganizationUserModel(); $this->orgUserModel = new OrganizationUserModel();
@ -195,6 +204,81 @@ class AccessService
return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_MANAGER], true); return in_array($role, [self::ROLE_OWNER, self::ROLE_ADMIN, self::ROLE_MANAGER], true);
} }
// =========================================================================
// СИСТЕМНЫЕ РОЛИ (суперадмин)
// =========================================================================
/**
* Получение системной роли пользователя
*
* @return string|null
*/
public function getSystemRole(): ?string
{
if ($this->cachedSystemRole !== null) {
return $this->cachedSystemRole;
}
$userId = session()->get('user_id');
if (!$userId) {
return null;
}
$userModel = new \App\Models\UserModel();
$user = $userModel->find($userId);
$this->cachedSystemRole = $user['system_role'] ?? null;
return $this->cachedSystemRole;
}
/**
* Проверка системной роли пользователя
*
* @param string|array $roles Роль или массив ролей для проверки
* @return bool
*/
public function isSystemRole($roles): bool
{
$currentRole = $this->getSystemRole();
if ($currentRole === null) {
return false;
}
$roles = (array) $roles;
return in_array($currentRole, $roles, true);
}
/**
* Проверка, является ли пользователь суперадмином
*
* @return bool
*/
public function isSuperadmin(): bool
{
return $this->getSystemRole() === self::SYSTEM_ROLE_SUPERADMIN;
}
/**
* Проверка, является ли пользователь системным администратором
*
* @return bool
*/
public function isSystemAdmin(): bool
{
$role = $this->getSystemRole();
return in_array($role, [self::SYSTEM_ROLE_ADMIN, self::SYSTEM_ROLE_SUPERADMIN], true);
}
/**
* Сброс кэша системной роли
*
* @return void
*/
public function resetSystemRoleCache(): void
{
$this->cachedSystemRole = null;
}
/** /**
* Проверка права на действие * Проверка права на действие
* *
@ -409,6 +493,7 @@ class AccessService
public function resetCache(): void public function resetCache(): void
{ {
$this->currentMembership = null; $this->currentMembership = null;
$this->cachedSystemRole = null;
} }
/** /**
@ -459,4 +544,27 @@ class AccessService
], ],
]; ];
} }
/**
* Получение всех системных ролей с описаниями
*
* @return array
*/
public static function getAllSystemRoles(): array
{
return [
self::SYSTEM_ROLE_USER => [
'label' => 'Пользователь',
'description' => 'Обычный пользователь системы',
],
self::SYSTEM_ROLE_ADMIN => [
'label' => 'Администратор',
'description' => 'Администратор системы',
],
self::SYSTEM_ROLE_SUPERADMIN => [
'label' => 'Суперадмин',
'description' => 'Полный доступ ко всем функциям системы',
],
];
}
} }

View File

@ -0,0 +1,71 @@
{% extends 'layouts/public.twig' %}
{% block title %}Восстановление пароля - {{ parent() }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow">
<div class="card-header bg-white">
<h4 class="mb-0 text-center">
<i class="fa-solid fa-key me-2"></i>Восстановление пароля
</h4>
</div>
<div class="card-body">
{% if success %}
<div class="alert alert-success">
<i class="fa-solid fa-check-circle me-2"></i>
{{ success }}
</div>
<div class="text-center mt-3">
<a href="{{ base_url('/login') }}" class="btn btn-primary">
<i class="fa-solid fa-arrow-left me-1"></i>Вернуться ко входу
</a>
</div>
{% else %}
<p class="text-muted text-center mb-4">
Введите email, на который зарегистрирована ваша учётная запись.
Мы отправим вам ссылку для сброса пароля.
</p>
{% if error %}
<div class="alert alert-danger">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
{{ error }}
</div>
{% endif %}
<form action="{{ base_url('/forgot-password/send') }}" method="post">
{{ csrf_field()|raw }}
<div class="mb-3">
<label for="email" class="form-label">Email адрес</label>
<input type="email"
class="form-control"
id="email"
name="email"
value="{{ old('email') }}"
placeholder="name@example.com"
required
autofocus>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-paper-plane me-1"></i>Отправить ссылку
</button>
</div>
</form>
{% endif %}
</div>
<div class="card-footer bg-white text-center">
<a href="{{ base_url('/login') }}" class="text-muted">
<i class="fa-solid fa-arrow-left me-1"></i>Вернуться ко входу
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -10,6 +10,11 @@
</div> </div>
{{ form_open(base_url('/login'), 'class="needs-validation"') }} {{ form_open(base_url('/login'), 'class="needs-validation"') }}
{{ csrf_field()|raw }}
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Email</label> <label for="email" class="form-label">Email</label>
@ -19,11 +24,20 @@
<label for="password" class="form-label">Пароль</label> <label for="password" class="form-label">Пароль</label>
<input type="password" name="password" value="{{ old.password|default('') }}" class="form-control" id="password" required> <input type="password" name="password" value="{{ old.password|default('') }}" class="form-control" id="password" required>
</div> </div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">Запомнить меня</label>
</div>
<button type="submit" class="btn btn-primary w-100">Войти</button> <button type="submit" class="btn btn-primary w-100">Войти</button>
{{ form_close() }} {{ form_close() }}
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<small><a href="{{ base_url('/forgot-password') }}">Забыли пароль?</a></small>
</div>
<div class="mt-2 text-center">
<small>Нет аккаунта? <a href="{{ base_url('/register') }}">Зарегистрироваться</a></small> <small>Нет аккаунта? <a href="{{ base_url('/register') }}">Зарегистрироваться</a></small>
</div> </div>
</div> </div>

View File

@ -0,0 +1,81 @@
{% extends 'layouts/public.twig' %}
{% block title %}Сброс пароля - {{ parent() }}{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow">
<div class="card-header bg-white">
<h4 class="mb-0 text-center">
<i class="fa-solid fa-key me-2"></i>Сброс пароля
</h4>
</div>
<div class="card-body">
{% if error %}
<div class="alert alert-danger">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
{{ error }}
</div>
<div class="text-center mt-3">
<a href="{{ base_url('/forgot-password') }}" class="btn btn-primary">
<i class="fa-solid fa-repeat me-1"></i>Запросить ссылку снова
</a>
</div>
{% else %}
<p class="text-muted text-center mb-4">
Введите новый пароль для учётной записи <strong>{{ email }}</strong>
</p>
<form action="{{ base_url('/forgot-password/update') }}" method="post">
{{ csrf_field()|raw }}
<input type="hidden" name="token" value="{{ token }}">
<div class="mb-3">
<label for="password" class="form-label">Новый пароль</label>
<input type="password"
class="form-control"
id="password"
name="password"
placeholder="Минимум 6 символов"
required
minlength="6"
autofocus>
<div class="form-text">Минимум 6 символов</div>
</div>
<div class="mb-3">
<label for="password_confirm" class="form-label">Подтвердите пароль</label>
<input type="password"
class="form-control"
id="password_confirm"
name="password_confirm"
placeholder="Повторите пароль"
required>
</div>
<div class="alert alert-info">
<i class="fa-solid fa-info-circle me-2"></i>
После смены пароля вы будете автоматически разлогинены на всех устройствах.
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-check me-1"></i>Изменить пароль
</button>
</div>
</form>
{% endif %}
</div>
<div class="card-footer bg-white text-center">
<a href="{{ base_url('/login') }}" class="text-muted">
<i class="fa-solid fa-arrow-left me-1"></i>Вернуться ко входу
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Сброс пароля</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.email-card {
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 40px;
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.logo-text {
font-size: 24px;
font-weight: bold;
color: #0d6efd;
}
h1 {
color: #333;
font-size: 24px;
margin-bottom: 20px;
}
.content {
color: #666;
margin-bottom: 30px;
}
.button {
display: inline-block;
padding: 14px 28px;
background-color: #0d6efd;
color: #ffffff;
text-decoration: none;
border-radius: 6px;
font-weight: 500;
margin: 20px 0;
}
.button:hover {
background-color: #0b5ed7;
}
.button-container {
text-align: center;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #999;
font-size: 14px;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 12px;
margin: 20px 0;
font-size: 14px;
}
</style>
</head>
<body>
<div class="container">
<div class="email-card">
<div class="logo">
<span class="logo-text">{{ app_name }}</span>
</div>
<h1>Сброс пароля</h1>
<div class="content">
<p>Здравствуйте, {{ name }}!</p>
<p>Мы получили запрос на сброс пароля для вашей учётной записи. Если вы не отправляли этот запрос, просто проигнорируйте это письмо.</p>
</div>
<div class="button-container">
<a href="{{ reset_url }}" class="button">Сбросить пароль</a>
</div>
<div class="content">
<p>Ссылка действительна в течение 24 часов.</p>
</div>
<div class="warning">
<strong>Внимание:</strong> Если вы не запрашивали сброс пароля, рекомендуем проверить безопасность вашей учётной записи и изменить пароль.
</div>
<div class="footer">
<p>С уважением,<br>Команда {{ app_name }}</p>
<p style="font-size: 12px; color: #999;">
Если кнопка не работает, скопируйте ссылку и вставьте её в адресную строку браузера:<br>
<a href="{{ reset_url }}" style="color: #0d6efd;">{{ reset_url }}</a>
</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -125,6 +125,14 @@
</a> </a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm"> <ul class="dropdown-menu dropdown-menu-end shadow-sm">
<li><h6 class="dropdown-header">{{ session_data.email }}</h6></li> <li><h6 class="dropdown-header">{{ session_data.email }}</h6></li>
{% if is_superadmin() %}
<li>
<a class="dropdown-item" href="{{ base_url('/superadmin') }}">
<i class="fa-solid fa-shield-halved text-warning me-2"></i> Панель суперадмина
</a>
</li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ base_url('/profile') }}"><i class="fa-regular fa-user me-2"></i> Профиль</a></li> <li><a class="dropdown-item" href="{{ base_url('/profile') }}"><i class="fa-regular fa-user me-2"></i> Профиль</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{{ base_url('/logout') }}"><i class="fa-solid fa-arrow-right-from-bracket me-2"></i> Выйти</a></li> <li><a class="dropdown-item text-danger" href="{{ base_url('/logout') }}"><i class="fa-solid fa-arrow-right-from-bracket me-2"></i> Выйти</a></li>

View File

@ -59,6 +59,56 @@
font-weight: 500; font-weight: 500;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.session-item {
padding: 1rem;
border: 1px solid #e9ecef;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
background: #fafafa;
}
.session-item:last-child {
margin-bottom: 0;
}
.session-device {
font-weight: 500;
color: #333;
}
.session-meta {
font-size: 0.875rem;
color: #6c757d;
margin-top: 0.25rem;
}
.session-current {
border-color: #198754;
background: #f0fff4;
}
.session-current .badge {
background: #198754;
}
.session-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.empty-sessions {
text-align: center;
padding: 2rem;
color: #6c757d;
}
.empty-sessions i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
</style> </style>
{% endblock %} {% endblock %}
@ -137,6 +187,65 @@
</div> </div>
</div> </div>
<!-- Активные сессии и устройства -->
<div class="card mb-4">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="fa-solid fa-desktop me-2"></i>Активные сессии</h4>
{% if sessions|length > 0 %}
<form action="{{ base_url('/profile/sessions/revoke-all') }}" method="post" onsubmit="return confirm('Вы уверены? Это завершит сессии на всех устройствах, кроме текущего.');">
{{ csrf_field()|raw }}
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="fa-solid fa-times me-1"></i>Завершить все
</button>
</form>
{% endif %}
</div>
<div class="card-body">
{% if sessions|length > 0 %}
{% for session in sessions %}
<div class="session-item{% if session.is_current %} session-current{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="session-device">
<i class="fa-solid fa-desktop me-2"></i>{{ session.device }}
{% if session.is_current %}
<span class="badge session-badge ms-2">Текущая сессия</span>
{% endif %}
</div>
<div class="session-meta">
<i class="fa-solid fa-globe me-1"></i>{{ session.ip_address }}
{% if session.expires_at %}
&nbsp;|&nbsp;
<i class="fa-solid fa-clock me-1"></i>истекает {{ session.expires_at|date('d.m.Y H:i') }}
{% endif %}
</div>
</div>
{% if not session.is_current %}
<form action="{{ base_url('/profile/session/revoke') }}" method="post">
{{ csrf_field()|raw }}
<input type="hidden" name="session_id" value="{{ session.id }}">
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Завершить эту сессию?');">
<i class="fa-solid fa-times"></i>
</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
<div class="alert alert-info mt-3 mb-0">
<i class="fa-solid fa-info-circle me-2"></i>
<strong>Запомненные устройства:</strong> Если вы отметили "Запомнить меня" при входе, устройство будет автоматически авторизовано в течение 30 дней. Вы можете завершить эти сессии вручную.
</div>
{% else %}
<div class="empty-sessions">
<i class="fa-solid fa-shield-check"></i>
<p>Нет активных сессий на других устройствах</p>
</div>
{% endif %}
</div>
</div>
<!-- Информация о безопасности --> <!-- Информация о безопасности -->
<div class="card"> <div class="card">
<div class="card-header bg-white"> <div class="card-header bg-white">

View File

@ -0,0 +1,116 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Дашборд</h1>
<div class="user-menu">
<span>Добро пожаловать, {{ session_data.name }}</span>
{{ get_avatar(null, 40, '') }}
</div>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="stats-grid">
<div class="stat-card">
<h3>Всего пользователей</h3>
<div class="value">{{ stats.total_users|number_format(0, '', ' ') }}</div>
<div class="icon">👥</div>
</div>
<div class="stat-card">
<h3>Всего организаций</h3>
<div class="value">{{ stats.total_orgs|number_format(0, '', ' ') }}</div>
<div class="icon">🏢</div>
</div>
<div class="stat-card">
<h3>Зарегистрировано сегодня</h3>
<div class="value">{{ stats.active_today|number_format(0, '', ' ') }}</div>
<div class="icon">📅</div>
</div>
<div class="stat-card">
<h3>Всего тарифов</h3>
<div class="value">{{ stats.total_plans|number_format(0, '', ' ') }}</div>
<div class="icon">📋</div>
</div>
</div>
<div class="grid-2">
<div class="sa-card">
<div class="sa-card-header">
<h2>Последние организации</h2>
<a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-primary btn-sm">Все организации</a>
</div>
<div class="sa-card-body">
{% if recentOrgs is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 20px;">Организаций пока нет</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Название</th>
<th>Тип</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
{% for org in recentOrgs %}
<tr>
<td>
<a href="{{ base_url('/superadmin/organizations/view/' ~ org.id) }}" style="color: #3498db; text-decoration: none;">
{{ org.name }}
</a>
</td>
<td>
{% if org.type == 'business' %}
<span class="badge badge-info">Бизнес</span>
{% else %}
<span class="badge badge-warning">Личное</span>
{% endif %}
</td>
<td>{{ org.created_at|date('d.m.Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="sa-card">
<div class="sa-card-header">
<h2>Последние пользователи</h2>
<a href="{{ base_url('/superadmin/users') }}" class="btn btn-primary btn-sm">Все пользователи</a>
</div>
<div class="sa-card-body">
{% if recentUsers is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 20px;">Пользователей пока нет</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Имя</th>
<th>Email</th>
<th>Роль</th>
</tr>
</thead>
<tbody>
{% for user in recentUsers %}
<tr>
<td>{{ user.name|default('—') }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge {{ user.system_role == 'superadmin' ? 'badge-danger' : (user.system_role == 'admin' ? 'badge-warning' : 'badge-success') }}">
{{ user.system_role|default('user') }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Панель суперадмина{% endblock %} — Бизнес.Точка</title>
<link href="{{ base_url('assets/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ base_url('assets/css/all.min.css') }}" rel="stylesheet">
<style>
.sa-layout { display: flex; min-height: 100vh; }
.sa-sidebar { width: 260px; background: #1a1a2e; color: #fff; padding: 20px 0; flex-shrink: 0; }
.sa-sidebar .logo { padding: 0 20px 30px; font-size: 24px; font-weight: bold; border-bottom: 1px solid #333; margin-bottom: 20px; }
.sa-sidebar .logo a { color: #fff; text-decoration: none; }
.sa-sidebar nav ul { list-style: none; padding: 0; margin: 0; }
.sa-sidebar nav ul li { border-bottom: 1px solid #252540; }
.sa-sidebar nav ul li a { display: block; padding: 15px 20px; color: #aaa; text-decoration: none; transition: 0.3s; }
.sa-sidebar nav ul li a:hover, .sa-sidebar nav ul li a.active { background: #252540; color: #fff; padding-left: 25px; }
.sa-sidebar nav ul li a i { margin-right: 10px; }
.sa-content { flex: 1; padding: 30px; background: #f5f6fa; overflow-y: auto; }
.sa-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; }
.sa-header h1 { margin: 0; font-size: 28px; color: #2c3e50; }
.sa-header .user-menu { display: flex; align-items: center; gap: 15px; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
.stat-card h3 { margin: 0 0 10px; font-size: 14px; color: #7f8c8d; text-transform: uppercase; }
.stat-card .value { font-size: 36px; font-weight: bold; color: #2c3e50; }
.stat-card .icon { float: right; font-size: 40px; opacity: 0.1; }
.sa-card { background: #fff; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 20px; }
.sa-card-header { padding: 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
.sa-card-header h2 { margin: 0; font-size: 18px; color: #2c3e50; }
.sa-card-body { padding: 20px; }
.btn { display: inline-block; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; text-decoration: none; font-size: 14px; transition: 0.3s; }
.btn-primary { background: #3498db; color: #fff; }
.btn-primary:hover { background: #2980b9; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover { background: #219a52; }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover { background: #c0392b; }
.btn-warning { background: #f39c12; color: #fff; }
.btn-warning:hover { background: #d68910; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-block { display: block; width: 100%; text-align: center; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 15px; text-align: left; border-bottom: 1px solid #eee; }
.table th { background: #f8f9fa; font-weight: 600; color: #2c3e50; }
.table tr:hover { background: #f8f9fa; }
.badge { display: inline-block; padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.badge-success { background: #d4edda; color: #155724; }
.badge-warning { background: #fff3cd; color: #856404; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-info { background: #d1ecf1; color: #0c5460; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 8px; font-weight: 500; color: #2c3e50; }
.form-control { width: 100%; padding: 12px 15px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; box-sizing: border-box; }
.form-control:focus { outline: none; border-color: #3498db; }
.alert { padding: 15px 20px; border-radius: 5px; margin-bottom: 20px; }
.alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.alert-danger { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
</style>
{% block styles %}{% endblock %}
</head>
<body>
<div class="sa-layout">
<aside class="sa-sidebar">
<div class="logo">
<a href="{{ base_url('/superadmin') }}">⚡ Business.Point</a>
</div>
<nav>
<ul>
<li><a href="{{ base_url('/superadmin') }}" class="{{ is_active_route('superadmin') and not is_active_route('superadmin/') ? 'active' : '' }}">📊 Дашборд</a></li>
<li><a href="{{ base_url('/superadmin/plans') }}" class="{{ is_active_route('superadmin/plans') ? 'active' : '' }}">📋 Тарифы</a></li>
<li><a href="{{ base_url('/superadmin/organizations') }}" class="{{ is_active_route('superadmin/organizations') ? 'active' : '' }}">🏢 Организации</a></li>
<li><a href="{{ base_url('/superadmin/users') }}" class="{{ is_active_route('superadmin/users') ? 'active' : '' }}">👥 Пользователи</a></li>
<li><a href="{{ base_url('/superadmin/statistics') }}" class="{{ is_active_route('superadmin/statistics') ? 'active' : '' }}">📈 Статистика</a></li>
<li><hr style="border-color: #333; margin: 15px 20px;"></li>
<li><a href="{{ base_url('/') }}" target="_blank">🔗 Вернуться на сайт</a></li>
</ul>
</nav>
</aside>
<main class="sa-content">
{% block content %}{% endblock %}
</main>
</div>
<script src="{{ base_url('assets/js/bootstrap.bundle.min.js') }}"></script>
<script src="{{ base_url('assets/js/base.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,49 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Организации</h1>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="sa-card">
<div class="sa-card-body">
<div id="organizations-table">
{# Динамическая таблица #}
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
</div>
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
<script src="/assets/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация DataTable
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,220 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Организация: {{ organization.name }}</h1>
<a href="{{ base_url('/superadmin/organizations') }}" class="btn btn-primary">← Назад</a>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="grid-2">
<div class="sa-card">
<div class="sa-card-header">
<h2>Информация об организации</h2>
</div>
<div class="sa-card-body">
<table class="table">
<tr>
<th style="width: 150px;">ID</th>
<td>{{ organization.id }}</td>
</tr>
<tr>
<th>Название</th>
<td>{{ organization.name }}</td>
</tr>
<tr>
<th>Тип</th>
<td>
{% if organization.type == 'business' %}
<span class="badge badge-info">Бизнес</span>
{% else %}
<span class="badge badge-warning">Личное пространство</span>
{% endif %}
</td>
</tr>
<tr>
<th>Тариф</th>
<td>
{% if currentSubscription %}
{% set plan = plans|filter(p => p.id == currentSubscription.plan_id)|first %}
{% if plan %}
<span class="badge badge-primary">{{ plan.name }}</span>
{% else %}
<span class="badge badge-secondary">ID: {{ currentSubscription.plan_id }}</span>
{% endif %}
{% else %}
<span class="badge badge-secondary">Не назначен</span>
{% endif %}
</td>
</tr>
<tr>
<th>Статус подписки</th>
<td>
{% if currentSubscription %}
{% if currentSubscription.status == 'active' %}
<span class="badge badge-success">Активна</span>
{% elseif currentSubscription.status == 'expired' %}
<span class="badge badge-danger">Истёкшая</span>
{% elseif currentSubscription.status == 'trial' %}
<span class="badge badge-info">Пробный период</span>
{% else %}
<span class="badge badge-warning">{{ currentSubscription.status }}</span>
{% endif %}
{% else %}
<span class="badge badge-secondary">Нет подписки</span>
{% endif %}
</td>
</tr>
<tr>
<th>Срок действия</th>
<td>
{% if currentSubscription and currentSubscription.expires_at %}
до {{ currentSubscription.expires_at|date('d.m.Y H:i') }}
{% else %}
Не ограничен
{% endif %}
</td>
</tr>
<tr>
<th>Статус организации</th>
<td>
{% if organization.status == 'active' %}
<span class="badge badge-success">Активна</span>
{% elseif organization.status == 'blocked' %}
<span class="badge badge-danger">Заблокирована</span>
{% else %}
<span class="badge badge-warning">{{ organization.status|default('Не определён') }}</span>
{% endif %}
</td>
</tr>
<tr>
<th>Создана</th>
<td>{{ organization.created_at|date('d.m.Y H:i') }}</td>
</tr>
</table>
<div style="margin-top: 20px; display: flex; gap: 10px; flex-wrap: wrap;">
{% if organization.status == 'active' %}
<a href="{{ base_url('/superadmin/organizations/block/' ~ organization.id) }}" class="btn btn-warning" onclick="return confirm('Заблокировать организацию?')">🚫 Заблокировать</a>
{% else %}
<a href="{{ base_url('/superadmin/organizations/unblock/' ~ organization.id) }}" class="btn btn-success" onclick="return confirm('Разблокировать организацию?')">✅ Разблокировать</a>
{% endif %}
<a href="{{ base_url('/superadmin/organizations/delete/' ~ organization.id) }}" class="btn btn-danger" onclick="return confirm('Удалить организацию? Это действие нельзя отменить!')">🗑️ Удалить</a>
</div>
</div>
</div>
<div class="sa-card">
<div class="sa-card-header">
<h2>Управление тарифом</h2>
</div>
<div class="sa-card-body">
<form action="{{ base_url('/superadmin/organizations/set-plan/' ~ organization.id) }}" method="post">
{{ csrf_field()|raw }}
<div class="form-group">
<label for="plan_id">Выберите тариф</label>
<select name="plan_id" id="plan_id" class="form-control" required>
<option value="">-- Выберите тариф --</option>
{% for plan in plans %}
<option value="{{ plan.id }}" {{ currentSubscription and currentSubscription.plan_id == plan.id ? 'selected' : '' }}>
{{ plan.name }} - {{ plan.price }} {{ plan.currency }}/{{ plan.billing_period }}
</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="duration_days">Срок действия (дней)</label>
<input type="number" name="duration_days" id="duration_days" class="form-control"
value="30" min="0" max="365000" placeholder="30">
<small class="text-muted">Оставьте пустым или 0 для неограниченного срока</small>
</div>
<button type="submit" class="btn btn-primary">Назначить тариф</button>
</form>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<h4>Статистика</h4>
<div style="text-align: center; padding: 20px;">
<div style="font-size: 48px; font-weight: bold; color: #3498db;">{{ users|length }}</div>
<div style="color: #7f8c8d;">Участников</div>
</div>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
<div style="display: flex; justify-content: space-around; text-align: center;">
<div>
<div style="font-size: 24px; font-weight: bold; color: #27ae60;">
{% set owner_count = users|filter(u => u.role == 'owner')|length %}
{{ owner_count }}
</div>
<div style="color: #7f8c8d; font-size: 12px;">Владельцы</div>
</div>
<div>
<div style="font-size: 24px; font-weight: bold; color: #f39c12;">
{% set admin_count = users|filter(u => u.role == 'admin')|length %}
{{ admin_count }}
</div>
<div style="color: #7f8c8d; font-size: 12px;">Админы</div>
</div>
<div>
<div style="font-size: 24px; font-weight: bold; color: #9b59b6;">
{% set manager_count = users|filter(u => u.role == 'manager')|length %}
{{ manager_count }}
</div>
<div style="color: #7f8c8d; font-size: 12px;">Менеджеры</div>
</div>
</div>
</div>
</div>
</div>
<div class="sa-card" style="margin-top: 20px;">
<div class="sa-card-header">
<h2>Участники организации</h2>
</div>
<div class="sa-card-body">
{% if users is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 40px;">Участников пока нет</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Пользователь</th>
<th>Email</th>
<th>Роль</th>
<th>Статус</th>
<th>Дата добавления</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.name|default('—') }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge {{ user.role == 'owner' ? 'badge-danger' : (user.role == 'admin' ? 'badge-warning' : 'badge-info') }}">
{{ user.role }}
</span>
</td>
<td>
{% if user.status == 'active' %}
<span class="badge badge-success">Активен</span>
{% elseif user.status == 'blocked' %}
<span class="badge badge-danger">Заблокирован</span>
{% else %}
<span class="badge badge-warning">{{ user.status }}</span>
{% endif %}
</td>
<td>{{ user.created_at|date('d.m.Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,95 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Создание тарифа</h1>
<a href="{{ base_url('/superadmin/plans') }}" class="btn btn-primary">← Назад</a>
</div>
<div class="sa-card">
<div class="sa-card-body">
<form action="{{ base_url('/superadmin/plans/store') }}" method="post">
{{ csrf_field()|raw }}
<div class="grid-2">
<div class="form-group">
<label for="name">Название тарифа *</label>
<input type="text" name="name" id="name" class="form-control" value="{{ old('name') }}" required>
</div>
<div class="form-group">
<label for="price">Цена *</label>
<input type="number" name="price" id="price" class="form-control" value="{{ old('price', 0) }}" step="0.01" min="0" required>
</div>
</div>
<div class="form-group">
<label for="description">Описание</label>
<textarea name="description" id="description" class="form-control" rows="3">{{ old('description') }}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label for="currency">Валюта</label>
<select name="currency" id="currency" class="form-control">
<option value="RUB" {{ old('currency', 'RUB') == 'RUB' ? 'selected' : '' }}>Рубль (RUB)</option>
<option value="USD" {{ old('currency') == 'USD' ? 'selected' : '' }}>Доллар (USD)</option>
<option value="EUR" {{ old('currency') == 'EUR' ? 'selected' : '' }}>Евро (EUR)</option>
</select>
</div>
<div class="form-group">
<label for="billing_period">Период оплаты</label>
<select name="billing_period" id="billing_period" class="form-control">
<option value="monthly" {{ old('billing_period', 'monthly') == 'monthly' ? 'selected' : '' }}>Ежемесячно</option>
<option value="yearly" {{ old('billing_period') == 'yearly' ? 'selected' : '' }}>Ежегодно</option>
<option value="quarterly" {{ old('billing_period') == 'quarterly' ? 'selected' : '' }}>Ежеквартально</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label for="max_users">Максимум пользователей *</label>
<input type="number" name="max_users" id="max_users" class="form-control" value="{{ old('max_users', 5) }}" min="1" required>
</div>
<div class="form-group">
<label for="max_clients">Максимум клиентов *</label>
<input type="number" name="max_clients" id="max_clients" class="form-control" value="{{ old('max_clients', 100) }}" min="1" required>
</div>
</div>
<div class="form-group">
<label for="max_storage">Максимум хранилища (ГБ) *</label>
<input type="number" name="max_storage" id="max_storage" class="form-control" value="{{ old('max_storage', 10) }}" min="1" required>
</div>
<div class="form-group">
<label for="features">Возможности (каждая с новой строки)</label>
<textarea name="features_list" id="features" class="form-control" rows="5" placeholder="Неограниченные проекты&#10;Приоритетная поддержка&#10;Экспорт в PDF">{{ old('features_list') }}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label>
<input type="checkbox" name="is_active" value="1" {{ old('is_active', 1) ? 'checked' : '' }}>
Активен (доступен для выбора)
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1" {{ old('is_default') ? 'checked' : '' }}>
Тариф по умолчанию
</label>
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success btn-block">Создать тариф</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,96 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Редактирование тарифа: {{ plan.name }}</h1>
<a href="{{ base_url('/superadmin/plans') }}" class="btn btn-primary">← Назад</a>
</div>
<div class="sa-card">
<div class="sa-card-body">
<form action="{{ base_url('/superadmin/plans/update/' ~ plan.id) }}" method="post">
{{ csrf_field()|raw }}
<div class="grid-2">
<div class="form-group">
<label for="name">Название тарифа *</label>
<input type="text" name="name" id="name" class="form-control" value="{{ plan.name }}" required>
</div>
<div class="form-group">
<label for="price">Цена *</label>
<input type="number" name="price" id="price" class="form-control" value="{{ plan.price }}" step="0.01" min="0" required>
</div>
</div>
<div class="form-group">
<label for="description">Описание</label>
<textarea name="description" id="description" class="form-control" rows="3">{{ plan.description|default('') }}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label for="currency">Валюта</label>
<select name="currency" id="currency" class="form-control">
<option value="RUB" {{ plan.currency == 'RUB' ? 'selected' : '' }}>Рубль (RUB)</option>
<option value="USD" {{ plan.currency == 'USD' ? 'selected' : '' }}>Доллар (USD)</option>
<option value="EUR" {{ plan.currency == 'EUR' ? 'selected' : '' }}>Евро (EUR)</option>
</select>
</div>
<div class="form-group">
<label for="billing_period">Период оплаты</label>
<select name="billing_period" id="billing_period" class="form-control">
<option value="monthly" {{ plan.billing_period == 'monthly' ? 'selected' : '' }}>Ежемесячно</option>
<option value="yearly" {{ plan.billing_period == 'yearly' ? 'selected' : '' }}>Ежегодно</option>
<option value="quarterly" {{ plan.billing_period == 'quarterly' ? 'selected' : '' }}>Ежеквартально</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label for="max_users">Максимум пользователей *</label>
<input type="number" name="max_users" id="max_users" class="form-control" value="{{ plan.max_users }}" min="1" required>
</div>
<div class="form-group">
<label for="max_clients">Максимум клиентов *</label>
<input type="number" name="max_clients" id="max_clients" class="form-control" value="{{ plan.max_clients }}" min="1" required>
</div>
</div>
<div class="form-group">
<label for="max_storage">Максимум хранилища (ГБ) *</label>
<input type="number" name="max_storage" id="max_storage" class="form-control" value="{{ plan.max_storage }}" min="1" required>
</div>
<div class="form-group">
<label for="features">Возможности (каждая с новой строки)</label>
<textarea name="features_list" id="features" class="form-control" rows="5" placeholder="Неограниченные проекты&#10;Приоритетная поддержка&#10;Экспорт в PDF">{% if plan.features is iterable %}{% for feature in plan.features %}
{{ feature }}{% endfor %}{% endif %}</textarea>
</div>
<div class="grid-2">
<div class="form-group">
<label>
<input type="checkbox" name="is_active" value="1" {{ plan.is_active ? 'checked' : '' }}>
Активен (доступен для выбора)
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_default" value="1" {{ plan.is_default ? 'checked' : '' }}>
Тариф по умолчанию
</label>
</div>
</div>
<div class="form-group">
<button type="submit" class="btn btn-success btn-block">Сохранить изменения</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,77 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Тарифы</h1>
<a href="{{ base_url('/superadmin/plans/create') }}" class="btn btn-success">+ Добавить тариф</a>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="sa-card">
<div class="sa-card-body">
{% if plans is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 40px;">Тарифов пока нет. Создайте первый тариф.</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Название</th>
<th>Описание</th>
<th>Цена</th>
<th>Период</th>
<th>Лимиты</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for plan in plans %}
<tr>
<td>
<strong>{{ plan.name }}</strong>
{% if plan.is_default %}
<span class="badge badge-success">По умолчанию</span>
{% endif %}
</td>
<td>{{ plan.description|default('—') }}</td>
<td>
<strong>{{ plan.price|number_format(0, '', ' ') }}</strong> {{ plan.currency }}
</td>
<td>
{% if plan.billing_period == 'monthly' %}
Месяц
{% elseif plan.billing_period == 'yearly' %}
Год
{% else %}
{{ plan.billing_period }}
{% endif %}
</td>
<td>
<small style="color: #7f8c8d;">
👥 до {{ plan.max_users }} чел.<br>
👤 до {{ plan.max_clients }} клиентов<br>
💾 до {{ plan.max_storage }} ГБ
</small>
</td>
<td>
{% if plan.is_active %}
<span class="badge badge-success">Активен</span>
{% else %}
<span class="badge badge-danger">Неактивен</span>
{% endif %}
</td>
<td>
<a href="{{ base_url('/superadmin/plans/edit/' ~ plan.id) }}" class="btn btn-primary btn-sm">✏️</a>
<a href="{{ base_url('/superadmin/plans/delete/' ~ plan.id) }}" class="btn btn-danger btn-sm" onclick="return confirm('Вы уверены?')">🗑️</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,179 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Статистика</h1>
</div>
<div class="grid-2" style="margin-top: 20px;">
<div class="sa-card">
<div class="sa-card-header">
<h2>Распределение по тарифам</h2>
</div>
<div class="sa-card-body">
{% if planStats is empty %}
<p style="color: #7f8c8d; text-align: center; padding: 40px;">Нет данных о тарифах</p>
{% else %}
<table class="table">
<thead>
<tr>
<th>Тариф</th>
<th>Организаций</th>
<th>Доля</th>
</tr>
</thead>
<tbody>
{% set totalOrgs = 0 %}
{% for plan in planStats %}
{% set totalOrgs = totalOrgs + plan.orgs_count %}
{% endfor %}
{% for plan in planStats %}
{% set percent = totalOrgs > 0 ? (plan.orgs_count / totalOrgs * 100)|round(1) : 0 %}
<tr>
<td>{{ plan.name }}</td>
<td>{{ plan.orgs_count }}</td>
<td>
<div style="display: flex; align-items: center; gap: 10px;">
<div style="flex: 1; background: #eee; height: 20px; border-radius: 10px; overflow: hidden;">
<div style="width: {{ percent }}%; background: #3498db; height: 100%;"></div>
</div>
<span style="min-width: 40px;">{{ percent }}%</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
<div class="sa-card">
<div class="sa-card-header">
<h2>Сводка</h2>
</div>
<div class="sa-card-body">
<div style="padding: 20px;">
<div style="display: flex; justify-content: space-between; padding: 15px 0; border-bottom: 1px solid #eee;">
<span>Всего пользователей (30 дней)</span>
<strong>
{% set totalUsers = 0 %}
{% for stat in dailyStats %}
{% set totalUsers = totalUsers + stat.users %}
{% endfor %}
{{ totalUsers|number_format(0, '', ' ') }}
</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 15px 0; border-bottom: 1px solid #eee;">
<span>Всего организаций (30 дней)</span>
<strong>
{% set totalOrgs = 0 %}
{% for stat in dailyStats %}
{% set totalOrgs = totalOrgs + stat.orgs %}
{% endfor %}
{{ totalOrgs|number_format(0, '', ' ') }}
</strong>
</div>
<div style="display: flex; justify-content: space-between; padding: 15px 0; border-bottom: 1px solid #eee;">
<span>Среднее организаций на пользователя</span>
<strong>
{{ totalUsers > 0 ? (totalOrgs / totalUsers)|round(2) : 0 }}
</strong>
</div>
</div>
</div>
</div>
</div>
<div class="sa-card">
<div class="sa-card-header">
<h2>Регистрации по дням (последние 30 дней)</h2>
</div>
<div style="margin-bottom: 30px;">
<canvas id="statsChart" width="800" height="300"></canvas>
</div>
<div class="sa-card-body">
<div style="overflow-x: auto;">
<table class="table">
<thead>
<tr>
<th>Дата</th>
<th>Новые пользователи</th>
<th>Новые организации</th>
</tr>
</thead>
<tbody>
{% for stat in dailyStats %}
<tr>
<td>{{ stat.date|date('d.m.Y') }}</td>
<td>{{ stat.users }}</td>
<td>{{ stat.orgs }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('statsChart').getContext('2d');
const labels = [
{% for stat in dailyStats %}
'{{ stat.date|date('d.m') }}'{% if not loop.last %},{% endif %}
{% endfor %}
];
const usersData = [
{% for stat in dailyStats %}
{{ stat.users }}{% if not loop.last %},{% endif %}
{% endfor %}
];
const orgsData = [
{% for stat in dailyStats %}
{{ stat.orgs }}{% if not loop.last %},{% endif %}
{% endfor %}
];
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Пользователи',
data: usersData,
borderColor: '#3498db',
backgroundColor: 'rgba(52, 152, 219, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Организации',
data: orgsData,
borderColor: '#27ae60',
backgroundColor: 'rgba(39, 174, 96, 0.1)',
tension: 0.4,
fill: true
}
]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'top'
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
});
</script>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends 'superadmin/layout.twig' %}
{% block content %}
<div class="sa-header">
<h1>Пользователи</h1>
</div>
{% for alert in get_alerts() %}
<div class="alert alert-{{ alert.type }}">{{ alert.message }}</div>
{% endfor %}
<div class="sa-card">
<div class="sa-card-body">
<div id="users-table">
{# Динамическая таблица #}
{{ tableHtml|raw }}
{# CSRF токен для AJAX запросов #}
{{ csrf_field()|raw }}
</div>
</div>
</div>
<link rel="stylesheet" href="/assets/css/modules/data-table.css">
<script src="/assets/js/modules/DataTable.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Инициализация DataTable
document.querySelectorAll('.data-table').forEach(function(container) {
const id = container.id;
const url = container.dataset.url;
const perPage = parseInt(container.dataset.perPage) || 10;
if (window.dataTables && window.dataTables[id]) {
return;
}
const table = new DataTable(id, {
url: url,
perPage: perPage
});
window.dataTables = window.dataTables || {};
window.dataTables[id] = table;
});
});
</script>
{% endblock %}