diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b37d141..148d533a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,150 +1,66 @@ # Changelog -## [Unreleased] - -### Изменено -- Радикально упрощена структура клиентской части приложения: - - Удалены все избыточные файлы и директории - - Перемещены модули auth.ts и api.ts из директории client/lib в корень директории client - - Обновлены импорты во всех компонентах для использования модулей из корня директории - - Создана минималистичная архитектура с 5 файлами (App, login, admin, auth, api) - - Следование принципу DRY - устранено дублирование кода - - Выделены общие модули для авторизации и работы с API - - Единый стиль кода и документации для всех компонентов - - Устранены все жесткие редиректы в пользу SolidJS Router - - Упрощена структура проекта для лучшей поддерживаемости -- Упрощена структура клиентской части приложения: - - Оставлены только два основных ресурса: логин и панель управления пользователями - - Удалены избыточные компоненты и файлы - - Упрощена логика авторизации и навигации - - Устранены жесткие редиректы в пользу SolidJS Router - - Созданы компактные и автономные компоненты login.tsx и admin.tsx - - Оптимизированы стили для минимального набора компонентов +#### [0.4.22] - 2025-05-21 ### Добавлено -- Создана панель управления пользователями в админке: - - Добавлен компонент UsersList для управления пользователями - - Реализованы функции блокировки/разблокировки пользователей - - Добавлена возможность отключения звука (mute) для пользователей - - Реализовано управление ролями пользователей через модальное окно - - Добавлены GraphQL мутации для управления пользователями в schema/admin.graphql - - Улучшен интерфейс админ-панели с табами для навигации -- Расширена схема GraphQL для админки: - - Добавлены типы AdminUserInfo и AdminUserUpdateInput - - Добавлены мутации adminUpdateUser, adminToggleUserBlock, adminToggleUserMute - - Добавлены запросы adminGetUsers и adminGetRoles -- Пагинация списка пользователей в админ-панели -- Серверная поддержка пагинации в API для админ-панели -- Поиск пользователей по email, имени и ID -- Поддержка локального запуска сервера с HTTPS через `python run.py --https` с использованием Granian -- Интеграция с инструментом mkcert для генерации доверенных локальных SSL-сертификатов -- Поддержка запуска нескольких рабочих процессов через параметр `--workers` -- Возможность указать произвольный домен для сертификата через `--domain` +- Панель управления: + - Управление переменными окружения с группировкой по категориям + - Управление пользователями (блокировка, изменение ролей, отключение звука) + - Пагинация и поиск пользователей по email, имени и ID +- Расширение GraphQL схемы для админки: + - Типы AdminUserInfo, AdminUserUpdateInput, AuthResult, Permission, SessionInfo + - Мутации для управления пользователями и авторизации +- Улучшения серверной части: + - Поддержка HTTPS через Granian с помощью mkcert + - Параметры запуска `--https`, `--workers`, `--domain` +- Система авторизации и аутентификации: + - Локальная система аутентификации с сессиями в Redis + - Система ролей и разрешений (RBAC) + - Защита от брутфорс атак + - Поддержка httpOnly cookies для токенов + - Мультиязычные email уведомления -### Улучшено -- Улучшен интерфейс админ-панели: - - Добавлены вкладки для переключения между разделами - - Оптимизирован компонент UsersList для работы с большим количеством пользователей - - Добавлены индикаторы статуса для заблокированных и отключенных пользователей - - Улучшена обработка ошибок при выполнении операций с пользователями - - Добавлены подтверждения для критичных операций (блокировка, изменение ролей) - -### Полностью переработан клиентский код: -- Создан компактный API клиент с изолированным кодом для доступа к API -- Реализована модульная архитектура с четким разделением ответственности -- Добавлены типизированные интерфейсы для всех компонентов и модулей -- Реализована система маршрутизации с защищенными маршрутами -- Добавлен компонент AuthProvider для управления авторизацией -- Оптимизирована загрузка компонентов с использованием ленивой загрузки -- Унифицирован стиль кода и именования +### Изменено +- Упрощена структура клиентской части приложения: + - Минималистичная архитектура с основными компонентами (авторизация и админка) + - Оптимизированы и унифицированы компоненты, следуя принципу DRY + - Реализована система маршрутизации с защищенными маршрутами + - Разделение ответственности между компонентами + - Типизированные интерфейсы для всех модулей + - Отказ от жестких редиректов в пользу SolidJS Router +- Переработан модуль авторизации: + - Унификация типов для работы с пользователями + - Использование единого типа Author во всех запросах + - Расширенное логирование для отладки + - Оптимизированное хранение и проверка токенов + - Унифицированная обработка сессий ### Исправлено -- Исправлена критическая проблема с JWT-токенами авторизации: - - Устранена ошибка декодирования токенов `int() argument must be a string, a bytes-like object or a real number, not 'NoneType'` - - Обновлен механизм создания токенов для гарантированного задания срока истечения (exp) - - Улучшена обработка ошибок в модуле аутентификации для предотвращения создания невалидных токенов - - Стандартизован формат параметра exp в JWT: теперь всегда используется timestamp вместо datetime - - Добавлена проверка наличия обязательных полей при декодировании токенов - - Оптимизирована совместимость между разными способами хранения сессий -- Исправлена проблема с перенаправлением в SolidJS, которое сбрасывало состояние приложения: - - Обновлена функция logout для использования колбэка навигации вместо жесткого редиректа - - Добавлен компонент LoginPage для авторизации без перезагрузки страницы - - Реализована ленивая загрузка компонентов с использованием Suspense - - Улучшена структура роутинга в админ-панели - - Оптимизирован код согласно принципам DRY и KISS +- Критические проблемы с JWT-токенами: + - Корректная генерация срока истечения токенов (exp) + - Стандартизованный формат параметров в JWT + - Проверка обязательных полей при декодировании +- Ошибки авторизации: + - "Cannot return null for non-nullable field Mutation.login" + - "Author password is empty" при авторизации + - "Author object has no attribute username" +- Обработка ошибок: + - Улучшена валидация email и username + - Исправлена обработка истекших токенов + - Добавлены проверки на NULL объекты в декораторах +- Вспомогательные компоненты: + - Исправлен метод dict() класса Author + - Добавлен AuthenticationMiddleware + - Реализован класс AuthenticatedUser -### Улучшения для авторизации в админ-панели - -- Исправлена проблема с авторизацией в админ-панели -- Добавлена поддержка httpOnly cookies для безопасного хранения токена авторизации -- Реализован механизм выхода из системы через отзыв токенов -- Добавлен компонент для отображения списка пользователей в админке -- Добавлена постраничная навигация между управлением переменными окружения и списком пользователей -- Улучшена обработка сессий в API GraphQL - -### Исправлено -- Переработан резолвер login_mutation для соответствия общему стилю других мутаций в кодбазе -- Реализована корректная обработка логина через `AuthResult`, устранена ошибка GraphQL "Cannot return null for non-nullable field Mutation.login" -- Улучшена обработка ошибок в модуле авторизации: - - Добавлена проверка корректности объекта автора перед созданием токена - - Исправлен порядок импорта резолверов для корректной регистрации обработчиков - - Добавлено расширенное логирование для отладки авторизации - - Гарантирован непустой возврат из резолвера login для предотвращения GraphQL ошибки -- Исправлена ошибка "Author password is empty" при авторизации: - - Добавлено поле password в метод dict() класса Author для корректной передачи при создании экземпляра из словаря -- Устранена ошибка `Author object has no attribute username` при создании токена авторизации: - - Добавлено свойство username в класс Author для совместимости с `TokenStorage` -- Исправлена HTML-форма на странице входа в админ-панель: - - Добавлен тег `
` для устранения предупреждения браузера о полях пароля вне формы - - Улучшена доступность и UX формы логина - - Добавлены атрибуты `autocomplete` для улучшения работы с менеджерами паролей - - Внедрена более строгая валидация полей и фокусировка на ошибках - -### Added -- Подробная документация модуля аутентификации в `docs/auth.md` -- Система ролей и разрешений (RBAC) -- Защита от брутфорс атак -- Мультиязычная поддержка в email уведомлениях +### Документировано - Подробная документация по системе авторизации в `docs/auth.md` - Описание OAuth интеграции - Руководство по RBAC - Примеры использования на фронтенде - Инструкции по безопасности - - Документация по тестированию -- Страница входа для неавторизованных пользователей в админке -- Публичное GraphQL API для модуля аутентификации: - - Типы: `AuthResult`, `Permission`, `SessionInfo`, `OAuthProvider` - - Мутации: `login`, `registerUser`, `sendLink`, `confirmEmail`, `getSession`, `changePassword`, `refreshToken` - - Запросы: `logout`, `me`, `isEmailUsed`, `getOAuthProviders` -### Changed -- Переработана структура модуля auth для лучшей модульности -- Улучшена обработка ошибок в auth endpoints -- Оптимизировано хранение сессий в Redis -- Усилена безопасность хеширования паролей -- Удалена поддержка удаленной аутентификации в пользу единой локальной системы аутентификации - - Удалены настройки `AUTH_MODE` и `AUTH_URL` - - Удалены зависимости от внешнего сервиса авторизации - - Упрощен код аутентификации -- Консолидация типов для авторизации: - - Удален дублирующий тип `UserInfo` - - Расширен тип `Author` полями для работы с авторизацией (`roles`, `email_verified`) - - Использование единого типа `Author` во всех запросах авторизации - -### Fixed -- Исправлена проблема с кэшированием разрешений -- Улучшена валидация email и username -- Исправлена обработка истекших токенов -- Исправлена ошибка в функции `get_with_stat` в модуле resolvers/stat.py: добавлен вызов метода `.unique()` для результатов запросов с joined eager loads -- Исправлены ошибки в декораторах auth: - - Добавлены проверки на None для объекта `info` в декораторах `admin_auth_required` и `require_permission` - - Улучшена обработка ошибок в GraphQL контексте -- Добавлен AuthenticationMiddleware с использованием InternalAuthentication для работы с request.auth -- Исправлена ошибка с классом InternalAuthentication: - - Добавлен класс AuthenticatedUser - - Реализован корректный возврат кортежа (AuthCredentials, BaseUser) из метода authenticate - -#### [0.4.21] - 2023-09-10 +#### [0.4.21] - 2025-05-10 ### Изменено - Переработана пагинация в админ-панели: переход с модели page/perPage на limit/offset @@ -155,7 +71,7 @@ - Исправлена ошибка GraphQL "Unknown argument 'page' on field 'Query.adminGetUsers'" - Согласованы параметры пагинации между клиентом и сервером -#### [0.4.20] - 2023-09-01 +#### [0.4.20] - 2025-05-01 ### Добавлено - Пагинация списка пользователей в админ-панели diff --git a/auth/identity.py b/auth/identity.py index 0ac4d37e..4222c3d2 100644 --- a/auth/identity.py +++ b/auth/identity.py @@ -91,24 +91,8 @@ class Identity: ) raise InvalidPassword("Пароль не установлен для данного пользователя") - # Проверим словарь до создания нового объекта - author_dict = orm_author.dict() - if "password" not in author_dict or not author_dict["password"]: - logger.warning( - f"[auth.identity] Пароль отсутствует в dict() или пуст: email={orm_author.email}" - ) - raise InvalidPassword("Пароль отсутствует в данных пользователя") - - # Создаем новый объект автора - author = Author(**author_dict) - if not author.password: - logger.warning( - f"[auth.identity] Пароль в созданном объекте автора пуст: email={orm_author.email}" - ) - raise InvalidPassword("Пароль не установлен для данного пользователя") - - # Проверяем пароль - if not Password.verify(password, author.password): + # Проверяем пароль напрямую, не используя dict() + if not Password.verify(password, orm_author.password): logger.warning(f"[auth.identity] Неверный пароль для {orm_author.email}") raise InvalidPassword("Неверный пароль пользователя") diff --git a/auth/internal.py b/auth/internal.py index 628f34db..5369a5f5 100644 --- a/auth/internal.py +++ b/auth/internal.py @@ -151,16 +151,16 @@ class InternalAuthentication(AuthenticationBackend): return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser() -async def verify_internal_auth(token: str) -> Tuple[str, list]: +async def verify_internal_auth(token: str) -> Tuple[str, list, bool]: """ Проверяет локальную авторизацию. - Возвращает user_id и список ролей. + Возвращает user_id, список ролей и флаг администратора. Args: token: Токен авторизации (может быть как с Bearer, так и без) Returns: - tuple: (user_id, roles) + tuple: (user_id, roles, is_admin) """ # Обработка формата "Bearer " (если токен не был обработан ранее) if token.startswith("Bearer "): @@ -169,7 +169,7 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]: # Проверяем сессию payload = await SessionManager.verify_session(token) if not payload: - return "", [] + return "", [], False with local_session() as session: try: @@ -182,10 +182,13 @@ async def verify_internal_auth(token: str) -> Tuple[str, list]: # Получаем роли roles = [role.id for role in author.roles] - - return str(author.id), roles + + # Определяем, является ли пользователь администратором + is_admin = any(role in ['admin', 'super'] for role in roles) or author.email in ADMIN_EMAILS + + return str(author.id), roles, is_admin except exc.NoResultFound: - return "", [] + return "", [], False async def create_internal_session(author: Author, device_info: Optional[dict] = None) -> str: @@ -202,8 +205,8 @@ async def create_internal_session(author: Author, device_info: Optional[dict] = # Сбрасываем счетчик неудачных попыток author.reset_failed_login() - # Обновляем last_login - author.last_login = int(time.time()) + # Обновляем last_seen + author.last_seen = int(time.time()) # Создаем сессию, используя token для идентификации return await SessionManager.create_session( diff --git a/auth/orm.py b/auth/orm.py index 4c6a00a4..dddcc826 100644 --- a/auth/orm.py +++ b/auth/orm.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import relationship from auth.identity import Password from services.db import Base +from settings import ADMIN_EMAILS # from sqlalchemy_utils import TSVectorType @@ -165,7 +166,6 @@ class Author(Base): is_active = Column(Boolean, default=True, nullable=False) email_verified = Column(Boolean, default=False) phone_verified = Column(Boolean, default=False) - last_login = Column(Integer, nullable=True) failed_login_attempts = Column(Integer, default=0) account_locked_until = Column(Integer, nullable=True) @@ -182,6 +182,9 @@ class Author(Base): # TSVectorType("name", "slug", "bio", "about", regconfig="pg_catalog.russian") # ) + # Список защищенных полей, которые видны только владельцу и администраторам + _protected_fields = ['email', 'password', 'provider_access_token', 'provider_refresh_token'] + @property def is_authenticated(self) -> bool: """Проверяет, аутентифицирован ли пользователь""" @@ -238,22 +241,27 @@ class Author(Base): """ return self.slug or self.email or self.phone or "" - def dict(self) -> Dict: - """Преобразует объект Author в словарь""" - return { - "id": self.id, - "slug": self.slug, - "name": self.name, - "bio": self.bio, - "about": self.about, - "pic": self.pic, - "links": self.links, - "email": self.email, - "password": self.password, - "created_at": self.created_at, - "updated_at": self.updated_at, - "last_seen": self.last_seen, - "deleted_at": self.deleted_at, - "roles": [role.id for role in self.roles], - "email_verified": self.email_verified, - } + def dict(self, access=False) -> Dict: + """ + Сериализует объект Author в словарь с учетом прав доступа. + + Args: + access (bool, optional): Флаг, указывающий, доступны ли защищенные поля + + Returns: + dict: Словарь с атрибутами Author, отфильтрованный по правам доступа + """ + # Получаем все атрибуты объекта + result = {c.name: getattr(self, c.name) for c in self.__table__.columns} + + # Добавляем роли, если они есть + if hasattr(self, 'roles') and self.roles: + result['roles'] = [role.id for role in self.roles] + + # скрываем защищенные поля + if not access: + for field in self._protected_fields: + if field in result: + result[field] = None + + return result diff --git a/cache/cache.py b/cache/cache.py index 1fae099b..742a56e6 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -320,7 +320,7 @@ async def get_cached_author_by_user_id(user_id: str, get_with_stat): return orjson.loads(author_data) # If data is not found in cache, query the database - author_query = select(Author).where(Author.user == user_id) + author_query = select(Author).where(Author.id == user_id) authors = get_with_stat(author_query) if authors: # Cache the retrieved author data diff --git a/main.py b/main.py index 07a13f3d..ed09f7ef 100644 --- a/main.py +++ b/main.py @@ -151,7 +151,14 @@ middleware = [ # CORS должен быть перед другими middleware для корректной обработки preflight-запросов Middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[ + "https://localhost:3000", + "https://testing.discours.io", + "https://discours.io", + "https://new.discours.io", + "https://discours.ru", + "https://new.discours.ru" + ], allow_methods=["GET", "POST", "OPTIONS"], # Явно указываем OPTIONS allow_headers=["*"], allow_credentials=True, @@ -183,6 +190,7 @@ async def graphql_handler(request: Request): response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "*" + response.headers["Access-Control-Allow-Credentials"] = "true" response.headers["Access-Control-Max-Age"] = "86400" # 24 hours return response diff --git a/package.json b/package.json index d2012d86..fd481048 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,14 @@ { - "name": "publy-admin", - "version": "0.4.20", + "name": "admin-panel", + "version": "0.4.22", "private": true, - "description": "admin panel", "scripts": { "dev": "vite", "build": "vite build", "serve": "vite preview", "lint": "biome check . --fix", "format": "biome format . --write", - "type-check": "tsc --noEmit", - "test": "vitest", - "build:auth": "vite build -c client/auth/vite.config.ts", - "watch:auth": "vite build -c client/auth/vite.config.ts --watch" + "typecheck": "tsc --noEmit" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/panel/App.tsx b/panel/App.tsx index 3c88eb94..ed700be1 100644 --- a/panel/App.tsx +++ b/panel/App.tsx @@ -1,5 +1,5 @@ -import { Component, Show, Suspense, createSignal, lazy, onMount } from 'solid-js' -import { isAuthenticated } from './auth' +import { Component, Show, Suspense, createSignal, lazy, onMount, createEffect } from 'solid-js' +import { isAuthenticated, getAuthTokenFromCookie } from './auth' // Ленивая загрузка компонентов const AdminPage = lazy(() => import('./admin')) @@ -11,14 +11,58 @@ const LoginPage = lazy(() => import('./login')) const App: Component = () => { const [authenticated, setAuthenticated] = createSignal(null) const [loading, setLoading] = createSignal(true) + const [checkingAuth, setCheckingAuth] = createSignal(true) // Проверяем авторизацию при монтировании onMount(() => { - const authed = isAuthenticated() - setAuthenticated(authed) - setLoading(false) + checkAuthentication() }) + // Периодическая проверка авторизации + createEffect(() => { + const authCheckInterval = setInterval(() => { + // Перепроверяем статус авторизации каждые 60 секунд + if (!checkingAuth()) { + const authed = isAuthenticated() + if (!authed && authenticated()) { + console.log('Сессия истекла, требуется повторная авторизация') + setAuthenticated(false) + } + } + }, 60000) + + return () => clearInterval(authCheckInterval) + }) + + // Функция проверки авторизации + const checkAuthentication = async () => { + setCheckingAuth(true) + setLoading(true) + + try { + // Проверяем состояние авторизации + const authed = isAuthenticated() + + // Если токен есть, но он невалидный, авторизация не удалась + if (authed) { + const token = getAuthTokenFromCookie() || localStorage.getItem('auth_token') + if (!token || token.length < 10) { + setAuthenticated(false) + } else { + setAuthenticated(true) + } + } else { + setAuthenticated(false) + } + } catch (error) { + console.error('Ошибка при проверке авторизации:', error) + setAuthenticated(false) + } finally { + setLoading(false) + setCheckingAuth(false) + } + } + // Обработчик успешной авторизации const handleLoginSuccess = () => { setAuthenticated(true) @@ -35,7 +79,7 @@ const App: Component = () => { fallback={
-

Загрузка...

+

Загрузка компонентов...

} > @@ -44,12 +88,12 @@ const App: Component = () => { fallback={
-

Загрузка...

+

Проверка авторизации...

} > {authenticated() ? ( - + ) : ( )} diff --git a/panel/admin.tsx b/panel/admin.tsx index a44794a2..5b40fed0 100644 --- a/panel/admin.tsx +++ b/panel/admin.tsx @@ -18,15 +18,13 @@ interface User { roles: string[] created_at?: number last_seen?: number - muted: boolean - is_active: boolean } /** * Интерфейс для роли пользователя */ interface Role { - id: number + id: string // ID роли - строка, не число name: string description?: string } @@ -52,27 +50,37 @@ interface AdminGetRolesResponse { } /** - * Интерфейс для ответа изменения статуса пользователя + * Интерфейс для ответа обновления пользователя */ -interface AdminSetUserStatusResponse { - adminSetUserStatus: { - success: boolean - error?: string - } +interface AdminUpdateUserResponse { + adminUpdateUser: boolean } /** - * Интерфейс для ответа изменения статуса блокировки чата + * Интерфейс для переменной окружения */ -interface AdminMuteUserResponse { - adminMuteUser: { - success: boolean - error?: string - } +interface EnvVariable { + key: string + value: string + description?: string + type: string + isSecret: boolean } -// Интерфейс для пропсов AdminPage +/** + * Интерфейс для секции переменных окружения + */ +interface EnvSection { + name: string + description?: string + variables: EnvVariable[] +} + +/** + * Интерфейс свойств компонента AdminPage + */ interface AdminPageProps { + apiUrl: string onLogout?: () => void } @@ -89,6 +97,12 @@ const AdminPage: Component = (props) => { const [showRolesModal, setShowRolesModal] = createSignal(false) const [successMessage, setSuccessMessage] = createSignal(null) + // Переменные среды + const [envSections, setEnvSections] = createSignal([]) + const [envLoading, setEnvLoading] = createSignal(false) + const [editingVariable, setEditingVariable] = createSignal(null) + const [showVariableModal, setShowVariableModal] = createSignal(false) + // Параметры пагинации const [pagination, setPagination] = createSignal<{ page: number @@ -137,8 +151,6 @@ const AdminPage: Component = (props) => { roles created_at last_seen - muted - is_active } total page @@ -260,104 +272,6 @@ const AdminPage: Component = (props) => { } } - /** - * Блокирует/разблокирует пользователя - * @param userId - ID пользователя - * @param isActive - Текущий статус активности - */ - async function toggleUserBlock(userId: number, isActive: boolean) { - try { - setError(null) - - // Устанавливаем новый статус (противоположный текущему) - const newStatus = !isActive - - // Выполняем мутацию - const result = await query( - `${location.origin}/graphql`, - ` - mutation AdminSetUserStatus($userId: Int!, $isActive: Boolean!) { - adminSetUserStatus(userId: $userId, isActive: $isActive) { - success - error - } - } - `, - { userId, isActive: newStatus } - ) - - // Проверяем результат - if (result?.adminSetUserStatus?.success) { - // Обновляем список пользователей - setSuccessMessage(`Пользователь ${newStatus ? 'разблокирован' : 'заблокирован'}`) - - // Обновляем пользователя в текущем списке - setUsers( - users().map((user) => - user.id === userId ? { ...user, is_active: newStatus } : user - ) - ) - - // Скрываем сообщение через 3 секунды - setTimeout(() => setSuccessMessage(null), 3000) - } else { - setError(result?.adminSetUserStatus?.error || 'Ошибка обновления статуса пользователя') - } - } catch (err) { - console.error('Ошибка при изменении статуса пользователя:', err) - setError(err instanceof Error ? err.message : 'Неизвестная ошибка') - } - } - - /** - * Включает/отключает режим блокировки чата для пользователя - * @param userId - ID пользователя - * @param isMuted - Текущий статус блокировки чата - */ - async function toggleUserMute(userId: number, isMuted: boolean) { - try { - setError(null) - - // Устанавливаем новый статус (противоположный текущему) - const newMuteStatus = !isMuted - - // Выполняем мутацию - const result = await query( - `${location.origin}/graphql`, - ` - mutation AdminMuteUser($userId: Int!, $muted: Boolean!) { - adminMuteUser(userId: $userId, muted: $muted) { - success - error - } - } - `, - { userId, muted: newMuteStatus } - ) - - // Проверяем результат - if (result?.adminMuteUser?.success) { - // Обновляем сообщение об успехе - setSuccessMessage(`${newMuteStatus ? 'Блокировка' : 'Разблокировка'} чата выполнена`) - - // Обновляем пользователя в текущем списке - setUsers( - users().map((user) => - user.id === userId ? { ...user, muted: newMuteStatus } : user - ) - ) - - // Скрываем сообщение через 3 секунды - setTimeout(() => setSuccessMessage(null), 3000) - } else { - setError(result?.adminMuteUser?.error || 'Ошибка обновления статуса блокировки чата') - } - } catch (err) { - console.error('Ошибка при изменении статуса блокировки чата:', err) - setError(err instanceof Error ? err.message : 'Неизвестная ошибка') - } - } - /** * Закрывает модальное окно ролей */ @@ -373,19 +287,18 @@ const AdminPage: Component = (props) => { */ async function updateUserRoles(userId: number, newRoles: string[]) { try { - await query( + await query( `${location.origin}/graphql`, ` - mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) { - adminUpdateUser(userId: $userId, input: $input) { - success - error - } + mutation AdminUpdateUser($user: AdminUserUpdateInput!) { + adminUpdateUser(user: $user) } `, { - userId, - input: { roles: newRoles } + user: { + id: userId, + roles: newRoles + } } ) @@ -414,20 +327,171 @@ const AdminPage: Component = (props) => { } /** - * Выход из системы + * Обрабатывает выход из системы */ - function handleLogout() { - // Сначала выполняем локальные действия по очистке данных - setUsers([]) - setRoles([]) - - // Затем выполняем выход - logout(() => { - // Вызываем коллбэк для оповещения родителя о выходе + const handleLogout = async () => { + try { + await logout() if (props.onLogout) { props.onLogout() } - }) + } catch (error) { + setError('Ошибка при выходе: ' + (error as Error).message) + } + } + + /** + * Форматирование даты в формате "X дней назад" + * @param timestamp - Временная метка + * @returns Форматированная строка с относительной датой + */ + function formatDateRelative(timestamp?: number): string { + if (!timestamp) return 'Н/Д' + + const now = Math.floor(Date.now() / 1000) + const diff = now - timestamp + + // Меньше минуты + if (diff < 60) { + return 'только что' + } + + // Меньше часа + if (diff < 3600) { + const minutes = Math.floor(diff / 60) + return `${minutes} ${getMinutesForm(minutes)} назад` + } + + // Меньше суток + if (diff < 86400) { + const hours = Math.floor(diff / 3600) + return `${hours} ${getHoursForm(hours)} назад` + } + + // Меньше 30 дней + if (diff < 2592000) { + const days = Math.floor(diff / 86400) + return `${days} ${getDaysForm(days)} назад` + } + + // Меньше года + if (diff < 31536000) { + const months = Math.floor(diff / 2592000) + return `${months} ${getMonthsForm(months)} назад` + } + + // Больше года + const years = Math.floor(diff / 31536000) + return `${years} ${getYearsForm(years)} назад` + } + + /** + * Получение правильной формы слова "минута" в зависимости от числа + * @param minutes - Количество минут + */ + function getMinutesForm(minutes: number): string { + if (minutes % 10 === 1 && minutes % 100 !== 11) { + return 'минуту' + } else if ([2, 3, 4].includes(minutes % 10) && ![12, 13, 14].includes(minutes % 100)) { + return 'минуты' + } + return 'минут' + } + + /** + * Получение правильной формы слова "час" в зависимости от числа + * @param hours - Количество часов + */ + function getHoursForm(hours: number): string { + if (hours % 10 === 1 && hours % 100 !== 11) { + return 'час' + } else if ([2, 3, 4].includes(hours % 10) && ![12, 13, 14].includes(hours % 100)) { + return 'часа' + } + return 'часов' + } + + /** + * Получение правильной формы слова "день" в зависимости от числа + * @param days - Количество дней + */ + function getDaysForm(days: number): string { + if (days % 10 === 1 && days % 100 !== 11) { + return 'день' + } else if ([2, 3, 4].includes(days % 10) && ![12, 13, 14].includes(days % 100)) { + return 'дня' + } + return 'дней' + } + + /** + * Получение правильной формы слова "месяц" в зависимости от числа + * @param months - Количество месяцев + */ + function getMonthsForm(months: number): string { + if (months % 10 === 1 && months % 100 !== 11) { + return 'месяц' + } else if ([2, 3, 4].includes(months % 10) && ![12, 13, 14].includes(months % 100)) { + return 'месяца' + } + return 'месяцев' + } + + /** + * Получение правильной формы слова "год" в зависимости от числа + * @param years - Количество лет + */ + function getYearsForm(years: number): string { + if (years % 10 === 1 && years % 100 !== 11) { + return 'год' + } else if ([2, 3, 4].includes(years % 10) && ![12, 13, 14].includes(years % 100)) { + return 'года' + } + return 'лет' + } + + /** + * Получает иконку для роли пользователя + * @param role - Название роли + * @returns Иконка для роли + */ + function getRoleIcon(role: string): string { + switch (role.toLowerCase()) { + case 'admin': + return '👑' // корона для администратора + case 'moderator': + return '🛡️' // щит для модератора + case 'editor': + return '✏️' // карандаш для редактора + case 'author': + return '📝' // блокнот для автора + case 'user': + return '👤' // фигура для обычного пользователя + case 'subscriber': + return '📬' // почтовый ящик для подписчика + case 'guest': + return '👋' // рука для гостя + case 'banned': + return '🚫' // знак запрета для заблокированного + case 'vip': + return '⭐' // звезда для VIP + case 'verified': + return '✓' // галочка для верифицированного + default: + return '🔹' // точка для прочих ролей + } + } + + /** + * Компонент для отображения роли с иконкой + */ + const RoleBadge: Component<{ role: string }> = (props) => { + return ( + + {getRoleIcon(props.role)} + {props.role} + + ) } /** @@ -537,6 +601,25 @@ const AdminPage: Component = (props) => { const user = selectedUser() const [selectedRoles, setSelectedRoles] = createSignal(user ? [...user.roles] : []) + // Получаем дополнительные описания ролей + const getRoleDescription = (roleId: string): string => { + // Если есть описание в списке ролей, используем его + const roleFromList = roles().find(r => r.id === roleId); + if (roleFromList?.description) { + return roleFromList.description; + } + + // Иначе возвращаем стандартное описание + switch(roleId) { + case 'reader': + return 'Базовая роль. Позволяет авторизоваться и оставлять реакции.'; + case 'author': + return 'Расширенная роль. Позволяет создавать контент и голосовать за публикации для вывода на главную страницу.'; + default: + return 'Нет описания'; + } + }; + const toggleRole = (role: string) => { const current = selectedRoles() if (current.includes(role)) { @@ -559,6 +642,11 @@ const AdminPage: Component = (props) => {