user profile

This commit is contained in:
root
2026-01-13 07:11:21 +03:00
parent 24ea8deeec
commit 3d39c1ba07
12 changed files with 1312 additions and 47 deletions
+75
View File
@@ -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: Убрать перед релизом!
+26
View File
@@ -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');
}
+245
View File
@@ -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);
}
}