user profile
This commit is contained in:
@@ -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: Убрать перед релизом!
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<?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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user