379 lines
13 KiB
PHP
379 lines
13 KiB
PHP
<?php
|
||
|
||
namespace App\Controllers;
|
||
|
||
use App\Models\UserModel;
|
||
use App\Models\OrganizationModel;
|
||
use App\Models\OrganizationUserModel;
|
||
use App\Services\AccessService;
|
||
|
||
/**
|
||
* ProfileController - Управление профилем пользователя
|
||
*/
|
||
class Profile extends BaseController
|
||
{
|
||
protected UserModel $userModel;
|
||
protected OrganizationModel $orgModel;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->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);
|
||
|
||
// Получаем список активных сессий
|
||
$sessions = $this->getUserSessions($userId);
|
||
|
||
return $this->renderTwig('profile/security', [
|
||
'title' => 'Безопасность',
|
||
'user' => $user,
|
||
'active_tab' => 'security',
|
||
'sessions' => $sessions,
|
||
'currentSessionId' => session_id(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Получение списка активных сессий пользователя
|
||
*/
|
||
protected function getUserSessions(int $userId): array
|
||
{
|
||
$db = \Config\Database::connect();
|
||
|
||
// Получаем сессии из таблицы ci_sessions (требует настройки DatabaseHandler)
|
||
// Также получаем remember-токены
|
||
$rememberTokens = $db->table('remember_tokens')
|
||
->where('user_id', $userId)
|
||
->where('expires_at >', date('Y-m-d H:i:s'))
|
||
->get()
|
||
->getResultArray();
|
||
|
||
$sessions = [];
|
||
|
||
// Добавляем remember-токены как сессии
|
||
foreach ($rememberTokens as $token) {
|
||
$sessions[] = [
|
||
'id' => 'remember_' . $token['id'],
|
||
'type' => 'remember',
|
||
'device' => $this->parseUserAgent($token['user_agent'] ?? ''),
|
||
'ip_address' => $token['ip_address'] ?? 'Unknown',
|
||
'created_at' => $token['created_at'],
|
||
'expires_at' => $token['expires_at'],
|
||
'is_current' => false,
|
||
];
|
||
}
|
||
|
||
return $sessions;
|
||
}
|
||
|
||
/**
|
||
* Парсинг User Agent для получения информации об устройстве
|
||
*/
|
||
protected function parseUserAgent(string $userAgent): string
|
||
{
|
||
if (empty($userAgent)) {
|
||
return 'Неизвестное устройство';
|
||
}
|
||
|
||
// Определяем браузер
|
||
$browser = 'Unknown';
|
||
if (preg_match('/Firefox\/([0-9.]+)/', $userAgent, $matches)) {
|
||
$browser = 'Firefox';
|
||
} elseif (preg_match('/Chrome\/([0-9.]+)/', $userAgent, $matches)) {
|
||
$browser = 'Chrome';
|
||
} elseif (preg_match('/Safari\/([0-9.]+)/', $userAgent, $matches)) {
|
||
$browser = 'Safari';
|
||
} elseif (preg_match('/MSIE\s+([0-9.]+)/', $userAgent, $matches) || preg_match('/Trident\/([0-9.]+)/', $userAgent, $matches)) {
|
||
$browser = 'Internet Explorer';
|
||
} elseif (preg_match('/Edg\/([0-9.]+)/', $userAgent, $matches)) {
|
||
$browser = 'Edge';
|
||
}
|
||
|
||
// Определяем ОС
|
||
$os = 'Unknown OS';
|
||
if (preg_match('/Windows/', $userAgent)) {
|
||
$os = 'Windows';
|
||
} elseif (preg_match('/Mac OS X/', $userAgent)) {
|
||
$os = 'macOS';
|
||
} elseif (preg_match('/Linux/', $userAgent)) {
|
||
$os = 'Linux';
|
||
} elseif (preg_match('/Android/', $userAgent)) {
|
||
$os = 'Android';
|
||
} elseif (preg_match('/iPhone|iPad|iPod/', $userAgent)) {
|
||
$os = 'iOS';
|
||
}
|
||
|
||
return "{$browser} на {$os}";
|
||
}
|
||
|
||
/**
|
||
* Завершение конкретной сессии
|
||
*/
|
||
public function revokeSession()
|
||
{
|
||
$userId = $this->getCurrentUserId();
|
||
$sessionId = $this->request->getPost('session_id');
|
||
|
||
if (empty($sessionId)) {
|
||
return redirect()->to('/profile/security')->with('error', 'Сессия не найдена');
|
||
}
|
||
|
||
$db = \Config\Database::connect();
|
||
|
||
// Проверяем, что сессия принадлежит пользователю
|
||
if (strpos($sessionId, 'remember_') === 0) {
|
||
// Это remember-токен
|
||
$tokenId = (int) str_replace('remember_', '', $sessionId);
|
||
$token = $db->table('remember_tokens')
|
||
->where('id', $tokenId)
|
||
->where('user_id', $userId)
|
||
->get()
|
||
->getRowArray();
|
||
|
||
if ($token) {
|
||
$db->table('remember_tokens')->where('id', $tokenId)->delete();
|
||
log_message('info', "User {$userId} revoked remember token {$tokenId}");
|
||
}
|
||
}
|
||
|
||
return redirect()->to('/profile/security')->with('success', 'Сессия завершена');
|
||
}
|
||
|
||
/**
|
||
* Завершение всех сессий (кроме текущей)
|
||
*/
|
||
public function revokeAllSessions()
|
||
{
|
||
$userId = $this->getCurrentUserId();
|
||
|
||
// Удаляем все remember-токены
|
||
$db = \Config\Database::connect();
|
||
$db->table('remember_tokens')->where('user_id', $userId)->delete();
|
||
|
||
// Регенерируем текущую сессию
|
||
$this->session->regenerate(true);
|
||
|
||
log_message('info', "User {$userId} revoked all sessions");
|
||
|
||
return redirect()->to('/profile/security')->with(
|
||
'success',
|
||
'Все сессии на других устройствах завершены. Вы остались авторизованы на текущем устройстве.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Обновление имени пользователя
|
||
*/
|
||
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);
|
||
}
|
||
}
|