| @copyright 2007-2026 (c) AVE.cms | @link https://ave-cms.ru | @version 3.3 */ namespace App\Adminx\Users; defined('BASEPATH') || die('Direct access to this location is not allowed.'); use App\Common\AdminAssets; use App\Common\AuditLog; use App\Common\Auth; use App\Common\Controller as BaseController; use App\Common\Permission; use App\Adminx\Support\BulkActionExecutor; use App\Adminx\Support\Roles; use App\Helpers\Request; /** * CRUD пользователей. Список — server-render (таблица AdminKit), создание/правка — * ajax из drawer-формы по единому контракту success/error (§3). */ class Controller extends BaseController { /** GET /users */ public function index(array $params = []) { if (!Permission::check('view_users')) { return $this->renderStatus('@adminx/404.twig', array('title' => 'Недостаточно прав'), 403); } AdminAssets::addStyle($this->base() . '/modules/Users/assets/users.css', 50); AdminAssets::addScript($this->base() . '/modules/Users/assets/users.js', 50); $search = Request::getStr('q'); $role = Request::getStr('role'); return $this->render('@users/list.twig', [ 'users' => Model::all($search, $role), 'roles' => Roles::map(), 'stats' => Model::stats(), 'q' => $search, 'role' => $role, 'can_manage' => Permission::check('manage_users'), 'current_id' => Auth::id(), ]); } /** GET /users/{id} — данные пользователя (JSON, для формы правки). */ public function show(array $params = []) { if (!Permission::check('view_users')) { return $this->error('Недостаточно прав', array(), 403); } $user = Model::find($params['id'] ?? 0); if (!$user) { return $this->error('Пользователь не найден', [], 404); } return $this->success('', ['data' => [ 'id' => (int) $user->id, 'name' => $user->name, 'email' => $user->email, 'login' => $user->login, 'phone' => $user->phone, 'role' => $user->role, 'is_active' => (int) $user->is_active, 'must_change_password' => (int) $user->must_change_password, 'password_changed_at' => $user->password_changed_at, 'created_at' => $user->created_at, 'updated_at' => $user->updated_at, ]]); } /** POST /users — создать. */ public function store(array $params = []) { if (($resp = $this->guard()) !== null) { return $resp; } $data = $this->input(); $errors = $this->validate($data, 0, true); if ($errors) { return $this->error('Проверьте поля формы', $errors); } $id = Model::create($data); AuditLog::record('user.created', array( 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, )); return $this->success('Пользователь создан', ['redirect' => $this->base() . '/users']); } /** POST /users/{id} — обновить. */ public function update(array $params = []) { if (($resp = $this->guard()) !== null) { return $resp; } $id = (int) ($params['id'] ?? 0); $user = Model::find($id); if (!$user) { return $this->error('Пользователь не найден', [], 404); } $data = $this->input(); if ($id === Auth::id()) { $data['role'] = (string) $user->role; $data['is_active'] = true; } $errors = $this->validate($data, $id, false); if ($errors) { return $this->error('Проверьте поля формы', $errors); } Model::update($id, $data); AuditLog::record('user.updated', array( 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, 'meta' => array( 'password_changed' => $data['password'] !== '', 'must_change_password' => !empty($data['must_change_password']), ), )); return $this->success('Изменения сохранены', ['redirect' => $this->base() . '/users']); } /** POST /users/{id}/toggle — вкл/выкл активность. */ public function toggle(array $params = []) { if (($resp = $this->guard()) !== null) { return $resp; } $id = (int) ($params['id'] ?? 0); if (!Model::find($id)) { return $this->error('Пользователь не найден', [], 404); } if ($id === Auth::id()) { return $this->error('Нельзя отключить собственную учётную запись', [], 422); } $active = Model::toggle($id); AuditLog::record('user.status_updated', array( 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, 'meta' => array('is_active' => $active), )); return $this->success($active ? 'Пользователь включён' : 'Пользователь отключён', [ 'data' => ['is_active' => $active], ]); } /** POST /users/{id}/delete — удалить. */ public function destroy(array $params = []) { if (($resp = $this->guard()) !== null) { return $resp; } $id = (int) ($params['id'] ?? 0); if (!Model::find($id)) { return $this->error('Пользователь не найден', [], 404); } if ($id === Auth::id()) { return $this->error('Нельзя удалить самого себя', [], 422); } Model::delete($id); AuditLog::record('user.deleted', array( 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, )); return $this->success('Пользователь удалён', ['redirect' => $this->base() . '/users']); } /** GET /users/{id}/security — active browser sessions and failed logins. */ public function security(array $params = array()) { if (!Permission::check('manage_users')) { return $this->error('Недостаточно прав', array(), 403); } $id = (int) (isset($params['id']) ? $params['id'] : 0); $user = Model::find($id); if (!$user) { return $this->error('Пользователь не найден', array(), 404); } $data = Model::security($id); $data['password_changed_at'] = isset($user->password_changed_at) ? (string) $user->password_changed_at : ''; $data['must_change_password'] = !empty($user->must_change_password); $data['is_self'] = $id === (int) Auth::id(); return $this->success('', array('data' => $data)); } /** POST /users/{id}/sessions/{session}/revoke. */ public function revokeSession(array $params = array()) { if (($response = $this->guard()) !== null) { return $response; } $id = (int) (isset($params['id']) ? $params['id'] : 0); $result = Model::revokeSession($id, isset($params['session']) ? $params['session'] : 0); if (empty($result['removed'])) { return $this->error('Сессия не найдена', array(), 404); } AuditLog::record('user.session_revoked', array( 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, 'meta' => array('session_id' => (int) $params['session']), )); if (!empty($result['current'])) { Auth::logout(); return $this->success('Текущая сессия завершена', array('redirect' => $this->base() . '/login')); } return $this->success('Сессия завершена'); } /** POST /users/{id}/sessions/revoke-others. */ public function revokeOtherSessions(array $params = array()) { if (($response = $this->guard()) !== null) { return $response; } $id = (int) (isset($params['id']) ? $params['id'] : 0); if (!Model::find($id)) { return $this->error('Пользователь не найден', array(), 404); } $preserve = $id === (int) Auth::id() ? Auth::currentBrowserTokenHash() : ''; $count = Model::revokeSessions($id, $preserve); AuditLog::record('user.sessions_revoked', array( 'actor_id' => Auth::id(), 'target_type' => 'user', 'target_id' => $id, 'meta' => array('count' => $count, 'current_preserved' => $preserve !== ''), )); return $this->success($preserve !== '' ? 'Остальные сессии завершены' : 'Все сессии завершены', array( 'data' => array('count' => $count), )); } public function bulk(array $params = array()) { if (($response = $this->guard()) !== null) { return $response; } $action = Request::postStr('action', ''); $currentId = (int) Auth::id(); $handler = function ($id) use ($action, $currentId) { if ((int) $id === $currentId || !Model::find($id)) { return false; } Model::setActive($id, $action === 'activate'); return true; }; try { $result = BulkActionExecutor::execute($action, Request::post('ids', array()), array( 'activate' => $handler, 'deactivate' => $handler, ), 200); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage(), array(), 422); } AuditLog::record('user.bulk_status_updated', array( 'actor_id' => Auth::id(), 'target_type' => 'user', 'meta' => array( 'action' => $action, 'requested' => $result['requested'], 'done' => $result['done'], 'skipped' => $result['skipped'], ), )); return $this->success('Состояние пользователей изменено', array('data' => $result)); } // ------------------------------------------------------------------ // /** CSRF + право на изменение. */ protected function guard() { return $this->guardPermission('manage_users'); } protected function input() { return [ 'name' => trim(Request::postStr('name')), 'email' => trim(Request::postStr('email')), 'login' => trim(Request::postStr('login')), 'phone' => trim(Request::postStr('phone')), 'role' => Request::postStr('role'), 'password' => (string) Request::post('password', ''), 'is_active' => Request::postBool('is_active', false), 'must_change_password' => Request::postBool('must_change_password', false), ]; } /** @return array field => message */ protected function validate(array $data, $id, $isCreate) { $errors = []; if ($data['name'] === '') { $errors['name'] = 'Укажите имя.'; } if ($data['email'] === '') { $errors['email'] = 'Укажите email.'; } elseif (!preg_match('/^[^@\s]+@[^@\s]+$/', $data['email'])) { //-- Допускаем локальные адреса вида user@local (демо/дев), которые //-- FILTER_VALIDATE_EMAIL отвергает из-за отсутствия TLD. $errors['email'] = 'Некорректный email.'; } elseif (Model::emailExists($data['email'], (int) $id)) { $errors['email'] = 'Этот email уже занят.'; } if ($data['login'] !== '' && Model::loginExists($data['login'], (int) $id)) { $errors['login'] = 'Этот логин уже занят.'; } if (!in_array($data['role'], Roles::codes(), true)) { $errors['role'] = 'Выберите роль.'; } if ($isCreate && mb_strlen($data['password'], 'UTF-8') < 8) { $errors['password'] = 'Пароль не короче 8 символов.'; } elseif (!$isCreate && $data['password'] !== '' && mb_strlen($data['password'], 'UTF-8') < 8) { $errors['password'] = 'Пароль не короче 8 символов.'; } return $errors; } }