From 3d39c1ba078b1988a40524446b780024b775303b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Jan 2026 07:11:21 +0300 Subject: [PATCH] user profile --- app/Config/Routes.php | 10 + app/Controllers/Auth.php | 75 ++++ app/Controllers/Organizations.php | 26 ++ app/Controllers/Profile.php | 245 ++++++++++++ ...001_AddInviteFieldsToOrganizationUsers.php | 101 +++-- ...01-13-000001_CreateRememberTokensTable.php | 61 +++ app/Libraries/Twig/TwigGlobalsExtension.php | 44 +++ app/Views/layouts/base.twig | 6 +- app/Views/profile/index.twig | 266 +++++++++++++ app/Views/profile/organizations.twig | 368 ++++++++++++++++++ app/Views/profile/security.twig | 157 ++++++++ .../uploads/avatars/avatar_11_1768273671.jpg | Bin 0 -> 46140 bytes 12 files changed, 1312 insertions(+), 47 deletions(-) create mode 100644 app/Controllers/Profile.php create mode 100644 app/Database/Migrations/2026-01-13-000001_CreateRememberTokensTable.php create mode 100644 app/Views/profile/index.twig create mode 100644 app/Views/profile/organizations.twig create mode 100644 app/Views/profile/security.twig create mode 100644 public/uploads/avatars/avatar_11_1768273671.jpg diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 8c4ba51..a8a01d7 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -45,10 +45,20 @@ $routes->group('', ['filter' => 'org'], static function ($routes) { $routes->post('organizations/(:num)/users/(:num)/block', 'Organizations::blockUser/$1/$2'); $routes->post('organizations/(:num)/users/(:num)/unblock', 'Organizations::unblockUser/$1/$2'); $routes->post('organizations/(:num)/users/(:num)/remove', 'Organizations::removeUser/$1/$2'); + $routes->post('organizations/(:num)/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)/cancel', 'Organizations::cancelInvite/$1/$2'); }); +# Маршруты профиля +$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/leave-org/(:num)', 'Profile::leaveOrganization/$1'); + # Подключение роутов модулей require_once APPPATH . 'Modules/Clients/Config/Routes.php'; \ No newline at end of file diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php index 2bbbfc1..9f44328 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/Auth.php @@ -345,6 +345,14 @@ class Auth extends BaseController 'isLoggedIn' => true ]; + // === ЗАПОМНИТЬ МЕНЯ === + $remember = $this->request->getPost('remember'); + if ($remember) { + $this->createRememberToken($user['id']); + // Устанавливаем сессию на 30 дней + $this->session->setExpiry(30 * 24 * 60 * 60); // 30 дней в секундах + } + // АВТОМАТИЧЕСКИЙ ВЫБОР ОРГАНИЗАЦИИ if (count($userOrgs) === 1) { // Если одна организация — заходим автоматически для удобства @@ -397,11 +405,78 @@ class Auth extends BaseController public function logout() { + $userId = session()->get('user_id'); + + // Удаляем все remember-токены пользователя + if ($userId) { + $db = \Config\Database::connect(); + $db->table('remember_tokens')->where('user_id', $userId)->delete(); + } + session()->destroy(); session()->remove('active_org_id'); return redirect()->to('/'); } + /** + * Создание remember-токена для автологина + */ + protected function createRememberToken(int $userId): void + { + $selector = bin2hex(random_bytes(16)); + $validator = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $validator); + $expiresAt = date('Y-m-d H:i:s', strtotime('+30 days')); + + $db = \Config\Database::connect(); + $db->table('remember_tokens')->insert([ + 'user_id' => $userId, + 'selector' => $selector, + 'token_hash' => $tokenHash, + 'expires_at' => $expiresAt, + 'created_at' => date('Y-m-d H:i:s'), + 'user_agent' => $this->request->getUserAgent()->getAgentString(), + 'ip_address' => $this->request->getIPAddress(), + ]); + + // Устанавливаем cookie на 30 дней + $cookie = \Config\Services::response()->setCookie('remember_selector', $selector, 30 * 24 * 60 * 60); + $cookie = \Config\Services::response()->setCookie('remember_token', $validator, 30 * 24 * 60 * 60); + } + + /** + * Проверка remember-токена (вызывается перед фильтрами авторизации) + * Возвращает user_id если токен валиден, иначе null + */ + public static function checkRememberToken(): ?int + { + $request = \Config\Services::request(); + $selector = $request->getCookie('remember_selector'); + $validator = $request->getCookie('remember_token'); + + if (!$selector || !$validator) { + return null; + } + + $db = \Config\Database::connect(); + $token = $db->table('remember_tokens') + ->where('selector', $selector) + ->where('expires_at >', date('Y-m-d H:i:s')) + ->get() + ->getRowArray(); + + if (!$token) { + return null; + } + + $tokenHash = hash('sha256', $validator); + if (!hash_equals($token['token_hash'], $tokenHash)) { + return null; + } + + return (int) $token['user_id']; + } + /** * DEBUG: Просмотр состояния rate limiting (только для разработки) * DELETE: Убрать перед релизом! diff --git a/app/Controllers/Organizations.php b/app/Controllers/Organizations.php index dbb8b3c..9430ef4 100644 --- a/app/Controllers/Organizations.php +++ b/app/Controllers/Organizations.php @@ -261,6 +261,10 @@ class Organizations extends BaseController $this->session->set('active_org_id', $orgId); $this->session->setFlashdata('success', 'Организация изменена'); + $referer = $this->request->getHeader('Referer'); + if ($referer && strpos($referer->getValue(), '/organizations/switch') === false) { + return redirect()->to($referer->getValue()); + } return redirect()->to('/'); } else { $this->session->setFlashdata('error', 'Доступ запрещен'); @@ -567,11 +571,23 @@ class Organizations extends BaseController $membership = $this->getMembership($orgId); if (!$membership) { + if ($this->request->isAJAX()) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Вы не состоите в этой организации', + ]); + } return $this->redirectWithError('Вы не состоите в этой организации', '/organizations'); } // Владелец не может покинуть организацию if ($membership['role'] === 'owner') { + if ($this->request->isAJAX()) { + return $this->response->setJSON([ + 'success' => false, + 'message' => 'Владелец не может покинуть организацию. Передайте права другому администратору.', + ]); + } return $this->redirectWithError('Владелец не может покинуть организацию. Передайте права другому администратору.', "/organizations/users/{$orgId}"); } @@ -590,6 +606,16 @@ class Organizations extends BaseController } } + // Сбрасываем кэш AccessService + $this->access->resetCache(); + + if ($this->request->isAJAX()) { + return $this->response->setJSON([ + 'success' => true, + 'message' => 'Вы покинули организацию', + ]); + } + $this->session->setFlashdata('success', 'Вы покинули организацию'); return redirect()->to('/organizations'); } diff --git a/app/Controllers/Profile.php b/app/Controllers/Profile.php new file mode 100644 index 0000000..8c52010 --- /dev/null +++ b/app/Controllers/Profile.php @@ -0,0 +1,245 @@ +userModel = new UserModel(); + $this->orgModel = new OrganizationModel(); + } + + /** + * Главная страница профиля + */ + public function index() + { + $userId = $this->getCurrentUserId(); + $user = $this->userModel->find($userId); + + return $this->renderTwig('profile/index', [ + 'title' => 'Профиль', + 'user' => $user, + 'active_tab' => 'general', + ]); + } + + /** + * Вкладка "Организации" + */ + public function organizations() + { + $userId = $this->getCurrentUserId(); + $user = $this->userModel->find($userId); + $currentOrgId = $this->session->get('active_org_id'); + + // Получаем все организации пользователя + $orgUserModel = $this->getOrgUserModel(); + $memberships = $orgUserModel->where('user_id', $userId)->findAll(); + + $orgIds = array_column($memberships, 'organization_id'); + $organizations = []; + + if (!empty($orgIds)) { + $organizations = $this->orgModel->whereIn('id', $orgIds)->findAll(); + } + + // Объединяем данные + $orgList = []; + foreach ($organizations as $org) { + // Находим соответствующий membership + $membership = null; + foreach ($memberships as $m) { + if ($m['organization_id'] == $org['id']) { + $membership = $m; + break; + } + } + + $orgList[] = [ + 'id' => $org['id'], + 'name' => $org['name'], + 'type' => $org['type'], + 'role' => $membership['role'] ?? 'guest', + 'status' => $membership['status'] ?? 'active', + 'joined_at' => $membership['joined_at'] ?? null, + 'is_owner' => ($membership['role'] ?? '') === 'owner', + 'is_current_org' => ((int) $org['id'] === (int) $currentOrgId), + ]; + } + + return $this->renderTwig('profile/organizations', [ + 'title' => 'Мои организации', + 'user' => $user, + 'organizations' => $orgList, + 'active_tab' => 'organizations', + ]); + } + + /** + * Вкладка "Безопасность" + */ + public function security() + { + $userId = $this->getCurrentUserId(); + $user = $this->userModel->find($userId); + + return $this->renderTwig('profile/security', [ + 'title' => 'Безопасность', + 'user' => $user, + 'active_tab' => 'security', + ]); + } + + /** + * Обновление имени пользователя + */ + public function updateName() + { + $userId = $this->getCurrentUserId(); + $name = trim($this->request->getPost('name')); + + if (empty($name)) { + $this->session->setFlashdata('error', 'Имя обязательно для заполнения'); + return redirect()->to('/profile'); + } + + if (strlen($name) < 3) { + $this->session->setFlashdata('error', 'Имя должно содержать минимум 3 символа'); + return redirect()->to('/profile'); + } + + $this->userModel->update($userId, ['name' => $name]); + $this->session->set('name', $name); + $this->session->setFlashdata('success', 'Имя успешно обновлено'); + + return redirect()->to('/profile'); + } + + /** + * Загрузка аватара + */ + public function uploadAvatar() + { + $userId = $this->getCurrentUserId(); + $file = $this->request->getFile('avatar'); + + if (!$file || !$file->isValid()) { + $this->session->setFlashdata('error', 'Ошибка загрузки файла'); + return redirect()->to('/profile'); + } + + // Валидация + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif']; + $maxSize = 2 * 1024 * 1024; // 2MB + + if (!in_array($file->getMimeType(), $allowedTypes)) { + $this->session->setFlashdata('error', 'Разрешены только файлы JPG, PNG и GIF'); + return redirect()->to('/profile'); + } + + if ($file->getSize() > $maxSize) { + $this->session->setFlashdata('error', 'Максимальный размер файла - 2 МБ'); + return redirect()->to('/profile'); + } + + // Создаём директорию для аватаров если нет + $uploadPath = ROOTPATH . 'public/uploads/avatars'; + if (!is_dir($uploadPath)) { + mkdir($uploadPath, 0755, true); + } + + // Генерируем уникальное имя файла + $extension = $file->getClientExtension(); + $newFileName = 'avatar_' . $userId . '_' . time() . '.' . $extension; + + // Перемещаем файл + $file->move($uploadPath, $newFileName); + + // Удаляем старый аватар если был + $user = $this->userModel->find($userId); + if (!empty($user['avatar']) && file_exists($uploadPath . '/' . $user['avatar'])) { + @unlink($uploadPath . '/' . $user['avatar']); + } + + // Обновляем путь к аватару в базе + $this->userModel->update($userId, ['avatar' => $newFileName]); + + $this->session->setFlashdata('success', 'Аватар успешно загружен'); + return redirect()->to('/profile'); + } + + /** + * Смена пароля + */ + public function changePassword() + { + $userId = $this->getCurrentUserId(); + $user = $this->userModel->find($userId); + + $currentPassword = $this->request->getPost('current_password'); + $newPassword = $this->request->getPost('new_password'); + $confirmPassword = $this->request->getPost('confirm_password'); + + // Валидация + if (empty($currentPassword)) { + $this->session->setFlashdata('error', 'Введите текущий пароль'); + return redirect()->to('/profile/security'); + } + + if (empty($newPassword)) { + $this->session->setFlashdata('error', 'Введите новый пароль'); + return redirect()->to('/profile/security'); + } + + if (strlen($newPassword) < 6) { + $this->session->setFlashdata('error', 'Новый пароль должен содержать минимум 6 символов'); + return redirect()->to('/profile/security'); + } + + if ($newPassword !== $confirmPassword) { + $this->session->setFlashdata('error', 'Пароли не совпадают'); + return redirect()->to('/profile/security'); + } + + // Проверяем текущий пароль + if (!password_verify($currentPassword, $user['password'])) { + $this->session->setFlashdata('error', 'Неверный текущий пароль'); + return redirect()->to('/profile/security'); + } + + // Обновляем пароль + $this->userModel->update($userId, ['password' => $newPassword]); + + // Завершаем все сессии пользователя (кроме текущей) + $this->endAllUserSessions($userId); + + $this->session->setFlashdata('success', 'Пароль успешно изменён. Для безопасности вы будете разлогинены на всех устройствах.'); + return redirect()->to('/logout'); + } + + /** + * Завершение всех сессий пользователя + */ + private function endAllUserSessions(int $userId): void + { + // Удаляем все remember-токены пользователя + $db = \Config\Database::connect(); + $db->table('remember_tokens')->where('user_id', $userId)->delete(); + + // Регенерируем ID текущей сессии + $this->session->regenerate(true); + } +} diff --git a/app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php b/app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php index fecfb92..a77e62a 100644 --- a/app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php +++ b/app/Database/Migrations/2026-01-12-000001_AddInviteFieldsToOrganizationUsers.php @@ -8,53 +8,68 @@ class AddInviteFieldsToOrganizationUsers extends Migration { public function up() { - // Добавление полей для системы приглашений - $fields = [ - 'invite_token' => [ - 'type' => 'VARCHAR', - 'constraint' => 64, - 'null' => true, - 'after' => 'role', - 'comment' => 'Токен для принятия приглашения', - ], - 'invited_by' => [ - 'type' => 'INT', - 'unsigned' => true, - 'null' => true, - 'after' => 'invite_token', - 'comment' => 'ID пользователя, который отправил приглашение', - ], - 'invited_at' => [ - 'type' => 'DATETIME', - 'null' => true, - 'after' => 'invited_by', - 'comment' => 'Дата отправки приглашения', - ], - 'status' => [ - 'type' => 'ENUM', - 'values' => ['active', 'pending', 'blocked'], - 'default' => 'pending', - 'after' => 'invited_at', - 'comment' => 'Статус участия в организации', - ], - ]; - - $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN status ENUM('active', 'pending', 'blocked') DEFAULT 'pending' AFTER invited_at"); - $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invite_token VARCHAR(64) NULL AFTER role"); - $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_by INT UNSIGNED NULL AFTER invite_token"); - $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_at DATETIME NULL AFTER invited_by"); - - // Индекс для быстрого поиска по токену - $this->db->simpleQuery("CREATE INDEX idx_org_users_token ON organization_users(invite_token)"); + // Примечание: Таблица уже создана с полями: + // id, organization_id, user_id, role, status, joined_at, created_at + // + // Добавляем только недостающие поля для системы приглашений: + // - invite_token: токен для принятия приглашения + // - invited_by: кто отправил приглашение + // - invited_at: когда отправлено приглашение + + // Проверяем, существует ли уже колонка invite_token + $fields = $this->db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + + if (!in_array('invite_token', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invite_token VARCHAR(64) NULL AFTER role"); + } + + if (!in_array('invited_by', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_by INT UNSIGNED NULL AFTER invite_token"); + } + + if (!in_array('invited_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users ADD COLUMN invited_at DATETIME NULL AFTER invited_by"); + } + + // Индекс для быстрого поиену (еска по токсли ещё нет) + $indexes = $this->db->getIndexData('organization_users'); + $hasTokenIndex = false; + foreach ($indexes as $index) { + if ($index->name === 'idx_org_users_token') { + $hasTokenIndex = true; + break; + } + } + + if (!$hasTokenIndex) { + $this->db->simpleQuery("CREATE INDEX idx_org_users_token ON organization_users(invite_token)"); + } + + // Изменяем поле status, чтобы включить 'pending' и установить его как значение по умолчанию + // Используем прямой SQL для модификации ENUM + $this->db->simpleQuery("ALTER TABLE organization_users MODIFY COLUMN status ENUM('active', 'pending', 'invited', 'blocked') NOT NULL DEFAULT 'pending'"); } public function down() { // Удаление полей и индекса при откате миграции - $this->db->simpleQuery("DROP INDEX idx_org_users_token ON organization_users"); - $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_at"); - $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_by"); - $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invite_token"); - $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN status"); + $fields = $this->db->getFieldData('organization_users'); + $existingFields = array_column($fields, 'name'); + + // Удаляем только если поля существуют + $this->db->simpleQuery("DROP INDEX IF EXISTS idx_org_users_token ON organization_users"); + + if (in_array('invited_at', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_at"); + } + + if (in_array('invited_by', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invited_by"); + } + + if (in_array('invite_token', $existingFields)) { + $this->db->simpleQuery("ALTER TABLE organization_users DROP COLUMN invite_token"); + } } } diff --git a/app/Database/Migrations/2026-01-13-000001_CreateRememberTokensTable.php b/app/Database/Migrations/2026-01-13-000001_CreateRememberTokensTable.php new file mode 100644 index 0000000..541da2b --- /dev/null +++ b/app/Database/Migrations/2026-01-13-000001_CreateRememberTokensTable.php @@ -0,0 +1,61 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'user_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'selector' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + ], + 'token_hash' => [ + 'type' => 'VARCHAR', + 'constraint' => 128, + ], + 'expires_at' => [ + 'type' => 'DATETIME', + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'user_agent' => [ + 'type' => 'VARCHAR', + 'constraint' => 500, + 'null' => true, + ], + 'ip_address' => [ + 'type' => 'VARCHAR', + 'constraint' => 45, + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('user_id'); + $this->forge->addKey('selector'); + $this->forge->addKey('expires_at'); + $this->forge->addForeignKey('user_id', 'users', 'id', false, 'CASCADE'); + $this->forge->createTable('remember_tokens'); + } + + public function down() + { + $this->forge->dropTable('remember_tokens'); + } +} diff --git a/app/Libraries/Twig/TwigGlobalsExtension.php b/app/Libraries/Twig/TwigGlobalsExtension.php index 56ed299..cdc96cf 100644 --- a/app/Libraries/Twig/TwigGlobalsExtension.php +++ b/app/Libraries/Twig/TwigGlobalsExtension.php @@ -21,6 +21,8 @@ class TwigGlobalsExtension extends AbstractExtension new TwigFunction('get_current_route', [$this, 'getCurrentRoute'], ['is_safe' => ['html']]), new TwigFunction('render_actions', [$this, 'renderActions'], ['is_safe' => ['html']]), new TwigFunction('render_cell', [$this, 'renderCell'], ['is_safe' => ['html']]), + new TwigFunction('get_avatar_url', [$this, 'getAvatarUrl'], ['is_safe' => ['html']]), + new TwigFunction('get_avatar', [$this, 'getAvatar'], ['is_safe' => ['html']]), // Access functions new TwigFunction('can', [$this, 'can'], ['is_safe' => ['html']]), @@ -155,6 +157,44 @@ class TwigGlobalsExtension extends AbstractExtension return '' . esc($label) . ''; } + public function getAvatarUrl($avatar = null, $size = 32): string + { + if (empty($avatar)) { + return ''; + } + return base_url('/uploads/avatars/' . $avatar); + } + + public function getAvatar($user = null, $size = 32, $class = ''): string + { + if (!$user) { + $session = session(); + $userId = $session->get('user_id'); + if (!$userId) { + return ''; + } + $userModel = new \App\Models\UserModel(); + $user = $userModel->find($userId); + } + + $name = $user['name'] ?? 'U'; + $avatar = $user['avatar'] ?? null; + + if ($avatar) { + $url = base_url('/uploads/avatars/' . $avatar); + $style = "width: {$size}px; height: {$size}px; object-fit: cover; border-radius: 50%;"; + return '' . esc($name) . ''; + } + + // Генерируем фон на основе имени + $colors = ['667eea', '764ba2', 'f093fb', 'f5576c', '4facfe', '00f2fe']; + $color = $colors[crc32($name) % count($colors)]; + $initial = strtoupper(substr($name, 0, 1)); + $style = "width: {$size}px; height: {$size}px; background: linear-gradient(135deg, #{$color} 0%, #{$color}dd 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: " . ($size / 2) . "px;"; + + return '
' . esc($initial) . '
'; + } + public function getAllRoles(): array { return \App\Services\AccessService::getAllRoles(); @@ -165,6 +205,7 @@ class TwigGlobalsExtension extends AbstractExtension return session(); } + public function getCurrentOrg() { $session = session(); @@ -412,6 +453,9 @@ class TwigGlobalsExtension extends AbstractExtension $name = $itemArray['user_name'] ?? ''; $email = $itemArray['user_email'] ?? $value; $avatar = $itemArray['user_avatar'] ?? ''; + if (!empty($avatar)) { + $avatar = '/uploads/avatars/' . $itemArray['user_avatar']; + } $avatarHtml = $avatar ? '' : '
'; diff --git a/app/Views/layouts/base.twig b/app/Views/layouts/base.twig index f88b308..6ab2d2f 100644 --- a/app/Views/layouts/base.twig +++ b/app/Views/layouts/base.twig @@ -120,14 +120,12 @@