user profile
This commit is contained in:
parent
24ea8deeec
commit
3d39c1ba07
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => 'Статус участия в организации',
|
||||
],
|
||||
];
|
||||
// Примечание: Таблица уже создана с полями:
|
||||
// id, organization_id, user_id, role, status, joined_at, created_at
|
||||
//
|
||||
// Добавляем только недостающие поля для системы приглашений:
|
||||
// - invite_token: токен для принятия приглашения
|
||||
// - invited_by: кто отправил приглашение
|
||||
// - invited_at: когда отправлено приглашение
|
||||
|
||||
$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");
|
||||
// Проверяем, существует ли уже колонка invite_token
|
||||
$fields = $this->db->getFieldData('organization_users');
|
||||
$existingFields = array_column($fields, 'name');
|
||||
|
||||
// Индекс для быстрого поиска по токену
|
||||
$this->db->simpleQuery("CREATE INDEX idx_org_users_token ON organization_users(invite_token)");
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateRememberTokensTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 '<span class="badge ' . esc($color) . '">' . esc($label) . '</span>';
|
||||
}
|
||||
|
||||
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 '<img src="' . esc($url) . '" alt="' . esc($name) . '" class="' . esc($class) . '" style="' . esc($style) . '">';
|
||||
}
|
||||
|
||||
// Генерируем фон на основе имени
|
||||
$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 '<div class="' . esc($class) . '" style="' . esc($style) . '">' . esc($initial) . '</div>';
|
||||
}
|
||||
|
||||
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
|
||||
? '<img src="' . esc($avatar) . '" alt="" style="width: 32px; height: 32px; object-fit: cover; border-radius: 50%;">'
|
||||
: '<div class="bg-light rounded-circle d-flex align-items-center justify-content-center" style="width: 32px; height: 32px;"><i class="fa-solid fa-user text-muted small"></i></div>';
|
||||
|
|
|
|||
|
|
@ -120,14 +120,12 @@
|
|||
<!-- ПРОФИЛЬ ПОЛЬЗОВАТЕЛЯ -->
|
||||
<div class="dropdown">
|
||||
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<div class="bg-primary text-white rounded-circle d-flex justify-content-center align-items-center me-2" style="width:32px; height:32px;">
|
||||
{{ session_data.name|first|upper }}
|
||||
</div>
|
||||
{{ get_avatar(null, 32, 'me-2') }}
|
||||
<span class="d-none d-md-inline text-dark">{{ session_data.name }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm">
|
||||
<li><h6 class="dropdown-header">{{ session_data.email }}</h6></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fa-regular fa-user me-2"></i> Профиль</a></li>
|
||||
<li><a class="dropdown-item" href="{{ base_url('/profile') }}"><i class="fa-regular fa-user me-2"></i> Профиль</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{{ base_url('/logout') }}"><i class="fa-solid fa-arrow-right-from-bracket me-2"></i> Выйти</a></li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,266 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.avatar-upload-btn {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #0d6efd;
|
||||
color: white;
|
||||
border: 3px solid #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.avatar-upload-btn:hover {
|
||||
background: #0b5ed7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-pills .nav-link {
|
||||
color: #495057;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link:hover:not(.active) {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#avatarPreviewModal {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-3">
|
||||
<!-- Боковая панель навигации -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="profile-header">
|
||||
<div class="avatar-container">
|
||||
{% if user.avatar %}
|
||||
<img src="{{ base_url('/uploads/avatars/' ~ user.avatar) }}" alt="Аватар" class="avatar-img">
|
||||
{% else %}
|
||||
<div class="avatar-placeholder">
|
||||
{{ user.name|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<label for="avatarInput" class="avatar-upload-btn" title="Изменить аватар">
|
||||
<i class="fa-solid fa-camera"></i>
|
||||
</label>
|
||||
<input type="file" id="avatarInput" name="avatar" accept="image/jpeg,image/png,image/gif" style="display: none;">
|
||||
</div>
|
||||
<h5 class="mb-1">{{ user.name }}</h5>
|
||||
<p class="text-muted mb-0">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<nav class="nav-pills flex-column">
|
||||
<a href="{{ base_url('/profile') }}" class="nav-link {{ active_tab == 'general' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-user me-2"></i> Основное
|
||||
</a>
|
||||
<a href="{{ base_url('/profile/organizations') }}" class="nav-link {{ active_tab == 'organizations' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-building me-2"></i> Мои организации
|
||||
</a>
|
||||
<a href="{{ base_url('/profile/security') }}" class="nav-link {{ active_tab == 'security' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-shield-halved me-2"></i> Безопасность
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
<!-- Основной контент -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<h4 class="mb-0">Основная информация</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ base_url('/profile/update-name') }}" method="post">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Имя</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ user.name }}" required minlength="3">
|
||||
<div class="form-text">Ваше имя будет отображаться в системе</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" value="{{ user.email }}" disabled>
|
||||
<div class="form-text">Email нельзя изменить (он является вашим логином)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="created_at" class="form-label">Дата регистрации</label>
|
||||
<input type="text" class="form-control" id="created_at" value="{{ user.created_at|date('d.m.Y H:i') }}" disabled>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
<i class="fa-solid fa-check me-1"></i> Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно загрузки аватара -->
|
||||
<div class="modal fade" id="avatarModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Загрузка аватара</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form action="{{ base_url('/profile/upload-avatar') }}" method="post" enctype="multipart/form-data" id="avatarForm">
|
||||
{{ csrf_field()|raw }}
|
||||
<div class="modal-body text-center">
|
||||
<p class="text-muted mb-3">Выберите изображение JPG, PNG или GIF (максимум 2 МБ)</p>
|
||||
|
||||
<div id="avatarPreviewContainer" style="display: none; margin-bottom: 15px;">
|
||||
<img id="avatarPreviewModal" src="" alt="Превью аватара">
|
||||
</div>
|
||||
|
||||
<input type="file" class="form-control" id="avatarFile" name="avatar" accept="image/jpeg,image/png,image/gif" required>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa-solid fa-upload me-1"></i> Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const avatarInput = document.getElementById('avatarInput');
|
||||
const avatarFile = document.getElementById('avatarFile');
|
||||
const avatarModal = new bootstrap.Modal(document.getElementById('avatarModal'));
|
||||
const avatarPreviewModal = document.getElementById('avatarPreviewModal');
|
||||
const avatarPreviewContainer = document.getElementById('avatarPreviewContainer');
|
||||
|
||||
// При клике на кнопку камеры - открываем модалку
|
||||
avatarInput.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
avatarModal.show();
|
||||
});
|
||||
|
||||
// При изменении файла в модалке - показываем превью
|
||||
avatarFile.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
avatarPreviewContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка типа файла
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
alert('Разрешены только файлы JPG, PNG и GIF');
|
||||
this.value = '';
|
||||
avatarPreviewContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка размера (2 МБ)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
alert('Максимальный размер файла - 2 МБ');
|
||||
this.value = '';
|
||||
avatarPreviewContainer.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем превью
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
avatarPreviewModal.src = event.target.result;
|
||||
avatarPreviewContainer.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// Очистка при закрытии модалки
|
||||
avatarModal._element.addEventListener('hidden.bs.modal', function() {
|
||||
avatarFile.value = '';
|
||||
avatarPreviewContainer.style.display = 'none';
|
||||
avatarPreviewModal.src = '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.nav-pills .nav-link {
|
||||
color: #495057;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link:hover:not(.active) {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.org-current-badge {
|
||||
background-color: #198754;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-3">
|
||||
<!-- Боковая панель навигации -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="profile-header">
|
||||
<div class="avatar-container">
|
||||
{% if user.avatar %}
|
||||
<img src="{{ base_url('/uploads/avatars/' ~ user.avatar) }}" alt="Аватар" class="avatar-img">
|
||||
{% else %}
|
||||
<div class="avatar-placeholder">
|
||||
{{ user.name|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5 class="mb-1">{{ user.name }}</h5>
|
||||
<p class="text-muted mb-0">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<nav class="nav-pills flex-column">
|
||||
<a href="{{ base_url('/profile') }}" class="nav-link {{ active_tab == 'general' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-user me-2"></i> Основное
|
||||
</a>
|
||||
<a href="{{ base_url('/profile/organizations') }}" class="nav-link {{ active_tab == 'organizations' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-building me-2"></i> Мои организации
|
||||
</a>
|
||||
<a href="{{ base_url('/profile/security') }}" class="nav-link {{ active_tab == 'security' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-shield-halved me-2"></i> Безопасность
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
<!-- Список организаций -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0">Мои организации</h4>
|
||||
<a href="{{ base_url('/organizations/create') }}" class="btn btn-primary btn-sm">
|
||||
<i class="fa-solid fa-plus me-1"></i> Создать организацию
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if organizations is empty %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fa-solid fa-building text-muted mb-3" style="font-size: 48px;"></i>
|
||||
<p class="text-muted mb-3">У вас пока нет организаций</p>
|
||||
<a href="{{ base_url('/organizations/create') }}" class="btn btn-primary">
|
||||
<i class="fa-solid fa-plus me-1"></i> Создать первую организацию
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Организация</th>
|
||||
<th>Тип</th>
|
||||
<th>Ваша роль</th>
|
||||
<th>Дата входа</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for org in organizations %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-primary text-white rounded me-3 d-flex align-items-center justify-content-center" style="width:40px; height:40px;">
|
||||
<i class="fa-solid fa-building"></i>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ org.name }}</strong>
|
||||
{% if org.is_current_org %}
|
||||
<span class="org-current-badge">Текущая</span>
|
||||
{% endif %}
|
||||
<br>
|
||||
<small class="text-muted">ID: {{ org.id }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if org.type == 'personal' %}
|
||||
<span class="badge bg-info">Личное пространство</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Бизнес</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if org.role == 'owner' %}
|
||||
<span class="badge bg-warning text-dark">Владелец</span>
|
||||
{% elseif org.role == 'admin' %}
|
||||
<span class="badge bg-primary">Администратор</span>
|
||||
{% elseif org.role == 'manager' %}
|
||||
<span class="badge bg-secondary">Менеджер</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-dark">Гость</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ org.joined_at ? org.joined_at|date('d.m.Y H:i') : '—' }}</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
{# ВЛАДЕЛЕЦ #}
|
||||
{% if org.is_owner %}
|
||||
{# Текущая организация #}
|
||||
{% if org.is_current_org %}
|
||||
<a href="{{ base_url('/organizations/' ~ org.id ~ '/dashboard') }}" class="btn btn-outline-primary" title="Дашборд">
|
||||
<i class="fa-solid fa-gauge-high"></i>
|
||||
</a>
|
||||
<a href="{{ base_url('/organizations/' ~ org.id ~ '/edit') }}" class="btn btn-outline-secondary" title="Редактировать">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger delete-org-btn"
|
||||
data-org-id="{{ org.id }}"
|
||||
data-org-name="{{ org.name }}"
|
||||
title="Удалить">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
{# Не текущая организация #}
|
||||
<a href="{{ base_url('/organizations/switch/' ~ org.id) }}" class="btn btn-outline-success" title="Выбрать">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</a>
|
||||
<a href="{{ base_url('/organizations/' ~ org.id ~ '/edit') }}" class="btn btn-outline-secondary" title="Редактировать">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger delete-org-btn"
|
||||
data-org-id="{{ org.id }}"
|
||||
data-org-name="{{ org.name }}"
|
||||
title="Удалить">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% elseif org.role in ['admin', 'manager'] %}
|
||||
{# Админ/Менеджер #}
|
||||
{% if org.is_current_org %}
|
||||
<a href="{{ base_url('/organizations/' ~ org.id ~ '/dashboard') }}" class="btn btn-outline-primary" title="Дашборд">
|
||||
<i class="fa-solid fa-gauge-high"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger leave-org-btn"
|
||||
data-org-id="{{ org.id }}"
|
||||
data-org-name="{{ org.name }}"
|
||||
title="Покинуть">
|
||||
<i class="fa-solid fa-person-walking-arrow-right"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{{ base_url('/organizations/switch/' ~ org.id) }}" class="btn btn-outline-success" title="Выбрать">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger leave-org-btn"
|
||||
data-org-id="{{ org.id }}"
|
||||
data-org-name="{{ org.name }}"
|
||||
title="Покинуть">
|
||||
<i class="fa-solid fa-person-walking-arrow-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Гость (не owner, не admin, не manager) #}
|
||||
{% if org.is_current_org %}
|
||||
<button type="button" class="btn btn-outline-danger leave-org-btn"
|
||||
data-org-id="{{ org.id }}"
|
||||
data-org-name="{{ org.name }}"
|
||||
title="Покинуть">
|
||||
<i class="fa-solid fa-person-walking-arrow-right"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{{ base_url('/organizations/switch/' ~ org.id) }}" class="btn btn-outline-success" title="Выбрать">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger leave-org-btn"
|
||||
data-org-id="{{ org.id }}"
|
||||
data-org-name="{{ org.name }}"
|
||||
title="Покинуть">
|
||||
<i class="fa-solid fa-person-walking-arrow-right"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно подтверждения выхода из организации -->
|
||||
<div class="modal fade" id="leaveOrgModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Покинуть организацию</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Вы уверены, что хотите покинуть организацию <strong id="leaveOrgName"></strong>?</p>
|
||||
<p class="text-muted mb-0">После выхода вы потеряете доступ к данным этой организации.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmLeaveOrg">
|
||||
<i class="fa-solid fa-person-walking-arrow-right me-1"></i> Покинуть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно подтверждения удаления организации -->
|
||||
<div class="modal fade" id="deleteOrgModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Удалить организацию</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation me-2"></i>Внимание! Это действие нельзя отменить.</p>
|
||||
<p>Вы уверены, что хотите удалить организацию <strong id="deleteOrgName"></strong>?</p>
|
||||
<p class="text-muted mb-0">Все данные организации и её участники будут удалены.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<a href="#" class="btn btn-danger" id="confirmDeleteOrg">
|
||||
<i class="fa-solid fa-trash me-1"></i> Удалить
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const leaveOrgModal = new bootstrap.Modal(document.getElementById('leaveOrgModal'));
|
||||
const deleteOrgModal = new bootstrap.Modal(document.getElementById('deleteOrgModal'));
|
||||
let selectedOrgId = null;
|
||||
|
||||
// Обработчики кнопок выхода из организации
|
||||
document.querySelectorAll('.leave-org-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
selectedOrgId = this.dataset.orgId;
|
||||
const orgName = this.dataset.orgName;
|
||||
|
||||
document.getElementById('leaveOrgName').textContent = orgName;
|
||||
leaveOrgModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики кнопок удаления организации
|
||||
document.querySelectorAll('.delete-org-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const orgId = this.dataset.orgId;
|
||||
const orgName = this.dataset.orgName;
|
||||
|
||||
document.getElementById('deleteOrgName').textContent = orgName;
|
||||
document.getElementById('confirmDeleteOrg').href = '{{ base_url("/organizations/") }}' + orgId + '/delete';
|
||||
deleteOrgModal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Подтверждение выхода
|
||||
document.getElementById('confirmLeaveOrg').addEventListener('click', function() {
|
||||
if (!selectedOrgId) return;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
fetch('{{ base_url("/organizations/") }}' + selectedOrgId + '/leave', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
leaveOrgModal.hide();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
leaveOrgModal.hide();
|
||||
alert('Ошибка при выходе из организации');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
{% extends 'layouts/base.twig' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
color: white;
|
||||
border: 4px solid #fff;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.nav-pills .nav-link {
|
||||
color: #495057;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link:hover:not(.active) {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-3">
|
||||
<!-- Боковая панель навигации -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="profile-header">
|
||||
<div class="avatar-container">
|
||||
{% if user.avatar %}
|
||||
<img src="{{ base_url('/uploads/avatars/' ~ user.avatar) }}" alt="Аватар" class="avatar-img">
|
||||
{% else %}
|
||||
<div class="avatar-placeholder">
|
||||
{{ user.name|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5 class="mb-1">{{ user.name }}</h5>
|
||||
<p class="text-muted mb-0">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<nav class="nav-pills flex-column">
|
||||
<a href="{{ base_url('/profile') }}" class="nav-link {{ active_tab == 'general' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-user me-2"></i> Основное
|
||||
</a>
|
||||
<a href="{{ base_url('/profile/organizations') }}" class="nav-link {{ active_tab == 'organizations' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-building me-2"></i> Мои организации
|
||||
</a>
|
||||
<a href="{{ base_url('/profile/security') }}" class="nav-link {{ active_tab == 'security' ? 'active' : '' }}">
|
||||
<i class="fa-solid fa-shield-halved me-2"></i> Безопасность
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
<!-- Смена пароля -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h4 class="mb-0"><i class="fa-solid fa-key me-2"></i>Смена пароля</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ base_url('/profile/change-password') }}" method="post">
|
||||
{{ csrf_field()|raw }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Текущий пароль</label>
|
||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Новый пароль</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="6">
|
||||
<div class="form-text">Минимум 6 символов</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Подтвердите новый пароль</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fa-solid fa-info-circle me-2"></i>
|
||||
После смены пароля вы будете автоматически разлогинены на всех устройствах для безопасности.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa-solid fa-check me-1"></i> Изменить пароль
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Информация о безопасности -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<h4 class="mb-0"><i class="fa-solid fa-shield-halved me-2"></i>Рекомендации по безопасности</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li class="mb-2">Используйте пароль длиной не менее 8 символов</li>
|
||||
<li class="mb-2">Комбинируйте буквы, цифры и специальные символы</li>
|
||||
<li class="mb-2">Не используйте один и тот же пароль для разных сервисов</li>
|
||||
<li class="mb-2">Регулярно меняйте пароль</li>
|
||||
<li>Не сообщайте пароль третьим лицам</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Loading…
Reference in New Issue