upgrade schema, resolvers, panel added

This commit is contained in:
2025-05-16 09:23:48 +03:00
parent 8a60bec73a
commit 2d382be794
80 changed files with 8641 additions and 1100 deletions

111
panel/App.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { Route, Router, RouteSectionProps } from '@solidjs/router'
import { Component, Suspense, lazy } from 'solid-js'
import { isAuthenticated } from './auth'
// Ленивая загрузка компонентов
const LoginPage = lazy(() => import('./login'))
const AdminPage = lazy(() => import('./admin'))
/**
* Компонент корневого шаблона приложения
* @param props - Свойства маршрута, включающие дочерние элементы
*/
const RootLayout: Component<RouteSectionProps> = (props) => {
return (
<div class="app-container">
{/* Здесь может быть общий хедер, футер или другие элементы */}
{props.children}
</div>
)
}
/**
* Компонент защиты маршрутов
* Проверяет авторизацию и либо показывает дочерние элементы,
* либо перенаправляет на страницу входа
*/
const RequireAuth: Component<RouteSectionProps> = (props) => {
const authed = isAuthenticated()
if (!authed) {
// Если не авторизован, перенаправляем на /login
window.location.href = '/login'
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление на страницу входа...</h2>
</div>
)
}
return <>{props.children}</>
}
/**
* Компонент для публичных маршрутов с редиректом,
* если пользователь уже авторизован
*/
const PublicOnlyRoute: Component<RouteSectionProps> = (props) => {
// Если пользователь авторизован, перенаправляем на админ-панель
if (isAuthenticated()) {
window.location.href = '/admin'
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление в админ-панель...</h2>
</div>
)
}
return <>{props.children}</>
}
/**
* Компонент перенаправления с корневого маршрута
*/
const RootRedirect: Component = () => {
const authenticated = isAuthenticated()
// Выполняем перенаправление сразу после рендеринга
setTimeout(() => {
window.location.href = authenticated ? '/admin' : '/login'
}, 100)
return (
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Перенаправление...</h2>
</div>
)
}
/**
* Корневой компонент приложения с настроенными маршрутами
*/
const App: Component = () => {
return (
<Router root={RootLayout}>
<Suspense fallback={
<div class="loading-screen">
<div class="loading-spinner"></div>
<h2>Загрузка...</h2>
</div>
}>
{/* Корневой маршрут с перенаправлением */}
<Route path="/" component={RootRedirect} />
{/* Маршрут логина (только для неавторизованных) */}
<Route path="/login" component={PublicOnlyRoute}>
<Route path="/" component={LoginPage} />
</Route>
{/* Защищенные маршруты (только для авторизованных) */}
<Route path="/admin" component={RequireAuth}>
<Route path="/*" component={AdminPage} />
</Route>
</Suspense>
</Router>
)
}
export default App

676
panel/admin.tsx Normal file
View File

@@ -0,0 +1,676 @@
/**
* Компонент страницы администратора
* @module AdminPage
*/
import { useNavigate } from '@solidjs/router'
import { Component, For, Show, createEffect, createSignal, onCleanup, onMount } from 'solid-js'
import { query } from './graphql'
import { isAuthenticated, logout } from './auth'
/**
* Интерфейс для данных пользователя
*/
interface User {
id: number
email: string
name?: string
slug?: string
roles: string[]
created_at?: number
last_seen?: number
muted: boolean
is_active: boolean
}
/**
* Интерфейс для роли пользователя
*/
interface Role {
id: number
name: string
description?: string
}
/**
* Интерфейс для ответа API с пользователями
*/
interface AdminGetUsersResponse {
adminGetUsers: {
users: User[]
total: number
page: number
perPage: number
totalPages: number
}
}
/**
* Интерфейс для ответа API с ролями
*/
interface AdminGetRolesResponse {
adminGetRoles: Role[]
}
/**
* Компонент страницы администратора
*/
const AdminPage: Component = () => {
const [activeTab, setActiveTab] = createSignal('users')
const [users, setUsers] = createSignal<User[]>([])
const [roles, setRoles] = createSignal<Role[]>([])
const [loading, setLoading] = createSignal(true)
const [error, setError] = createSignal<string | null>(null)
const [selectedUser, setSelectedUser] = createSignal<User | null>(null)
const [showRolesModal, setShowRolesModal] = createSignal(false)
const [successMessage, setSuccessMessage] = createSignal<string | null>(null)
// Параметры пагинации
const [pagination, setPagination] = createSignal<{
page: number
limit: number
total: number
totalPages: number
}>({
page: 1,
limit: 10,
total: 0,
totalPages: 1
})
// Поиск
const [searchQuery, setSearchQuery] = createSignal('')
const navigate = useNavigate()
// Периодическая проверка авторизации
onMount(() => {
// Загружаем данные при монтировании
loadUsers()
loadRoles()
})
/**
* Загрузка списка пользователей с учетом пагинации и поиска
*/
async function loadUsers() {
setLoading(true)
setError(null)
try {
const { page, limit } = pagination()
const offset = (page - 1) * limit
const search = searchQuery().trim()
const data = await query<AdminGetUsersResponse>(
`
query AdminGetUsers($limit: Int, $offset: Int, $search: String) {
adminGetUsers(limit: $limit, offset: $offset, search: $search) {
users {
id
email
name
slug
roles
created_at
last_seen
muted
is_active
}
total
page
perPage
totalPages
}
}
`,
{ limit, offset, search: search || null }
)
if (data?.adminGetUsers) {
setUsers(data.adminGetUsers.users)
setPagination({
page: data.adminGetUsers.page,
limit: data.adminGetUsers.perPage,
total: data.adminGetUsers.total,
totalPages: data.adminGetUsers.totalPages
})
}
} catch (err) {
console.error('Ошибка загрузки пользователей:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
// Если ошибка авторизации - перенаправляем на логин
if (
err instanceof Error &&
(err.message.includes('401') ||
err.message.includes('авторизации') ||
err.message.includes('unauthorized') ||
err.message.includes('Unauthorized'))
) {
handleLogout()
}
} finally {
setLoading(false)
}
}
/**
* Загрузка списка ролей
*/
async function loadRoles() {
try {
const data = await query<AdminGetRolesResponse>(`
query AdminGetRoles {
adminGetRoles {
id
name
description
}
}
`)
if (data?.adminGetRoles) {
setRoles(data.adminGetRoles)
}
} catch (err) {
console.error('Ошибка загрузки ролей:', err)
// Если ошибка авторизации - перенаправляем на логин
if (
err instanceof Error &&
(err.message.includes('401') ||
err.message.includes('авторизации') ||
err.message.includes('unauthorized') ||
err.message.includes('Unauthorized'))
) {
handleLogout()
}
}
}
/**
* Обработчик изменения страницы
* @param page - Номер страницы
*/
function handlePageChange(page: number) {
if (page < 1 || page > pagination().totalPages) return
setPagination((prev) => ({ ...prev, page }))
loadUsers()
}
/**
* Обработчик изменения количества записей на странице
* @param limit - Количество записей на странице
*/
function handlePerPageChange(limit: number) {
setPagination((prev) => ({ ...prev, page: 1, limit }))
loadUsers()
}
/**
* Обработчик изменения поискового запроса
* @param e - Событие изменения ввода
*/
function handleSearchChange(e: Event) {
const target = e.target as HTMLInputElement
setSearchQuery(target.value)
}
/**
* Выполняет поиск при нажатии Enter или кнопки поиска
*/
function handleSearch() {
setPagination((prev) => ({ ...prev, page: 1 })) // Сбрасываем на первую страницу при поиске
loadUsers()
}
/**
* Обработчик нажатия клавиши в поле поиска
* @param e - Событие нажатия клавиши
*/
function handleSearchKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}
/**
* Блокировка/разблокировка пользователя
* @param userId - ID пользователя
* @param isActive - Текущий статус активности
*/
async function toggleUserBlock(userId: number, isActive: boolean) {
// Запрашиваем подтверждение
const action = isActive ? 'заблокировать' : 'разблокировать'
if (!confirm(`Вы действительно хотите ${action} этого пользователя?`)) {
return
}
try {
await query(
`
mutation AdminToggleUserBlock($userId: Int!) {
adminToggleUserBlock(userId: $userId) {
success
error
}
}
`,
{ userId }
)
// Обновляем статус пользователя
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, is_active: !isActive }
}
return user
})
)
// Показываем сообщение об успехе
setSuccessMessage(`Пользователь успешно ${isActive ? 'заблокирован' : 'разблокирован'}`)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} catch (err) {
console.error('Ошибка изменения статуса блокировки:', err)
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса блокировки')
}
}
/**
* Включение/отключение режима "mute" для пользователя
* @param userId - ID пользователя
* @param isMuted - Текущий статус mute
*/
async function toggleUserMute(userId: number, isMuted: boolean) {
// Запрашиваем подтверждение
const action = isMuted ? 'включить звук' : 'отключить звук'
if (!confirm(`Вы действительно хотите ${action} для этого пользователя?`)) {
return
}
try {
await query(
`
mutation AdminToggleUserMute($userId: Int!) {
adminToggleUserMute(userId: $userId) {
success
error
}
}
`,
{ userId }
)
// Обновляем статус пользователя
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, muted: !isMuted }
}
return user
})
)
// Показываем сообщение об успехе
setSuccessMessage(`Звук для пользователя успешно ${isMuted ? 'включен' : 'отключен'}`)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} catch (err) {
console.error('Ошибка изменения статуса mute:', err)
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса mute')
}
}
/**
* Закрывает модальное окно управления ролями
*/
function closeRolesModal() {
setShowRolesModal(false)
setSelectedUser(null)
}
/**
* Обновляет роли пользователя
* @param userId - ID пользователя
* @param roles - Новый список ролей
*/
async function updateUserRoles(userId: number, newRoles: string[]) {
try {
await query(
`
mutation AdminUpdateUser($userId: Int!, $input: AdminUserUpdateInput!) {
adminUpdateUser(userId: $userId, input: $input) {
success
error
}
}
`,
{
userId,
input: { roles: newRoles }
}
)
// Обновляем роли пользователя в списке
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, roles: newRoles }
}
return user
})
)
// Закрываем модальное окно
closeRolesModal()
// Показываем сообщение об успехе
setSuccessMessage('Роли пользователя успешно обновлены')
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
} catch (err) {
console.error('Ошибка обновления ролей:', err)
setError(err instanceof Error ? err.message : 'Ошибка обновления ролей')
}
}
/**
* Выход из системы
*/
function handleLogout() {
// Сначала выполняем локальные действия по очистке данных
setUsers([])
setRoles([])
// Затем выполняем выход
logout(() => {
// Для гарантии перенаправления после выхода
window.location.href = '/login'
})
}
/**
* Форматирование даты
* @param timestamp - Временная метка
*/
function formatDate(timestamp?: number): string {
if (!timestamp) return 'Н/Д'
return new Date(timestamp * 1000).toLocaleString('ru')
}
/**
* Формирует массив номеров страниц для отображения в пагинации
* @returns Массив номеров страниц
*/
function getPageNumbers(): number[] {
const result: number[] = []
const maxVisible = 5 // Максимальное количество видимых номеров страниц
const paginationData = pagination()
const currentPage = paginationData.page
const totalPages = paginationData.totalPages
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2))
const endPage = Math.min(totalPages, startPage + maxVisible - 1)
// Если endPage достиг предела, сдвигаем startPage назад
if (endPage - startPage + 1 < maxVisible && startPage > 1) {
startPage = Math.max(1, endPage - maxVisible + 1)
}
// Генерируем номера страниц
for (let i = startPage; i <= endPage; i++) {
result.push(i)
}
return result
}
/**
* Компонент пагинации
*/
const Pagination: Component = () => {
const paginationData = pagination()
const currentPage = paginationData.page
const total = paginationData.totalPages
return (
<div class="pagination">
<div class="pagination-info">
Показано {users().length} из {paginationData.total} пользователей
</div>
<div class="pagination-controls">
<button
class="pagination-button"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
&laquo;
</button>
<For each={getPageNumbers()}>
{(page) =>
typeof page === 'number' ? (
<button
class={`pagination-button ${page === currentPage ? 'active' : ''}`}
onClick={() => handlePageChange(page)}
>
{page}
</button>
) : (
<span class="pagination-ellipsis">{page}</span>
)
}
</For>
<button
class="pagination-button"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === total}
>
&raquo;
</button>
</div>
<div class="pagination-per-page">
<label>
Записей на странице:
<select
value={paginationData.limit}
onChange={(e) => handlePerPageChange(Number.parseInt(e.target.value))}
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</label>
</div>
</div>
)
}
/**
* Компонент модального окна для управления ролями
*/
const RolesModal: Component = () => {
const user = selectedUser()
const [selectedRoles, setSelectedRoles] = createSignal<string[]>(user ? [...user.roles] : [])
const toggleRole = (role: string) => {
const current = selectedRoles()
if (current.includes(role)) {
setSelectedRoles(current.filter((r) => r !== role))
} else {
setSelectedRoles([...current, role])
}
}
const saveRoles = () => {
if (user) {
updateUserRoles(user.id, selectedRoles())
}
}
if (!user) return null
return (
<div class="modal-overlay">
<div class="modal-content">
<h2>Управление ролями пользователя</h2>
<p>Пользователь: {user.email}</p>
<div class="roles-list">
<For each={roles()}>
{(role) => (
<div class="role-item">
<label>
<input
type="checkbox"
checked={selectedRoles().includes(role.name)}
onChange={() => toggleRole(role.name)}
/>
{role.name}
</label>
<Show when={role.description}>
<p class="role-description">{role.description}</p>
</Show>
</div>
)}
</For>
</div>
<div class="modal-actions">
<button class="cancel-button" onClick={closeRolesModal}>
Отмена
</button>
<button class="save-button" onClick={saveRoles}>
Сохранить
</button>
</div>
</div>
</div>
)
}
return (
<div class="admin-page">
<header>
<div class="header-container">
<h1>Панель администратора</h1>
<button class="logout-button" onClick={handleLogout}>
Выйти
</button>
</div>
<nav class="admin-tabs">
<button class={activeTab() === 'users' ? 'active' : ''} onClick={() => setActiveTab('users')}>
Пользователи
</button>
</nav>
</header>
<main>
<Show when={error()}>
<div class="error-message">{error()}</div>
</Show>
<Show when={successMessage()}>
<div class="success-message">{successMessage()}</div>
</Show>
<Show when={loading()}>
<div class="loading">Загрузка данных...</div>
</Show>
<Show when={!loading() && users().length === 0 && !error()}>
<div class="empty-state">Нет данных для отображения</div>
</Show>
<Show when={!loading() && users().length > 0}>
<div class="users-controls">
<div class="search-container">
<div class="search-input-group">
<input
type="text"
placeholder="Поиск по email, имени или ID..."
value={searchQuery()}
onInput={handleSearchChange}
onKeyDown={handleSearchKeyDown}
class="search-input"
/>
<button class="search-button" onClick={handleSearch}>
Поиск
</button>
</div>
</div>
</div>
<div class="users-list">
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Имя</th>
<th>Роли</th>
<th>Создан</th>
<th>Последний вход</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={users()}>
{(user) => (
<tr class={user.is_active ? '' : 'blocked'}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.name || '-'}</td>
<td>{user.roles.join(', ') || '-'}</td>
<td>{formatDate(user.created_at)}</td>
<td>{formatDate(user.last_seen)}</td>
<td>
<span class={`status ${user.is_active ? 'active' : 'inactive'}`}>
{user.is_active ? 'Активен' : 'Заблокирован'}
</span>
</td>
<td class="actions">
<button
class={user.is_active ? 'block' : 'unblock'}
onClick={() => toggleUserBlock(user.id, user.is_active)}
>
{user.is_active ? 'Блокировать' : 'Разблокировать'}
</button>
<button
class={user.muted ? 'unmute' : 'mute'}
onClick={() => toggleUserMute(user.id, user.muted)}
>
{user.muted ? 'Unmute' : 'Mute'}
</button>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<Pagination />
</Show>
</main>
<Show when={showRolesModal()}>
<RolesModal />
</Show>
</div>
)
}
export default AdminPage

143
panel/auth.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* Модуль авторизации
* @module auth
*/
import { query } from './graphql'
/**
* Интерфейс для учетных данных
*/
export interface Credentials {
email: string
password: string
}
/**
* Интерфейс для результата авторизации
*/
export interface LoginResult {
success: boolean
token?: string
error?: string
}
/**
* Интерфейс для ответа API при логине
*/
interface LoginResponse {
login: LoginResult
}
/**
* Константа для имени ключа токена в localStorage
*/
const AUTH_TOKEN_KEY = 'auth_token'
/**
* Константа для имени ключа токена в cookie
*/
const AUTH_COOKIE_NAME = 'auth_token'
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
function getAuthTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === AUTH_COOKIE_NAME) {
return value
}
}
return ''
}
/**
* Проверяет, авторизован ли пользователь
* @returns Статус авторизации
*/
export function isAuthenticated(): boolean {
// Проверяем наличие cookie auth_token
const cookieToken = getAuthTokenFromCookie()
const hasCookie = !!cookieToken && cookieToken.length > 10
// Проверяем наличие токена в localStorage
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
const hasLocalToken = !!localToken && localToken.length > 10
// Пользователь авторизован, если есть cookie или токен в localStorage
return hasCookie || hasLocalToken
}
/**
* Выполняет выход из системы
* @param callback - Функция обратного вызова после выхода
*/
export function logout(callback?: () => void): void {
// Очищаем токен из localStorage
localStorage.removeItem(AUTH_TOKEN_KEY)
// Для удаления cookie устанавливаем ей истекшее время жизни
document.cookie = `${AUTH_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
// Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий
try {
fetch('/logout', {
method: 'GET',
credentials: 'include'
}).catch(e => {
console.error('Ошибка при запросе на выход:', e)
})
} catch (e) {
console.error('Ошибка при выходе:', e)
}
// Вызываем функцию обратного вызова после очистки токенов
if (callback) callback()
}
/**
* Выполняет вход в систему
* @param credentials - Учетные данные
* @returns Результат авторизации
*/
export async function login(credentials: Credentials): Promise<boolean> {
try {
// Используем query из graphql.ts для выполнения запроса
const data = await query<LoginResponse>(
`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
success
token
error
}
}
`,
{
email: credentials.email,
password: credentials.password
}
)
if (data?.login?.success) {
// Проверяем, установил ли сервер cookie
const cookieToken = getAuthTokenFromCookie()
const hasCookie = !!cookieToken && cookieToken.length > 10
// Если cookie не установлена, но есть токен в ответе, сохраняем его в localStorage
if (!hasCookie && data.login.token) {
localStorage.setItem(AUTH_TOKEN_KEY, data.login.token)
}
return true
}
throw new Error(data?.login?.error || 'Ошибка авторизации')
} catch (error) {
console.error('Ошибка при входе:', error)
throw error
}
}

189
panel/graphql.ts Normal file
View File

@@ -0,0 +1,189 @@
/**
* API-клиент для работы с GraphQL
* @module api
*/
/**
* Базовый URL для API
*/
// Всегда используем абсолютный путь к API
const API_URL = window.location.origin + '/graphql'
/**
* Константа для имени ключа токена в localStorage
*/
const AUTH_TOKEN_KEY = 'auth_token'
/**
* Тип для произвольных данных GraphQL
*/
type GraphQLData = Record<string, unknown>
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
function getAuthTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === 'auth_token') {
return value
}
}
return ''
}
/**
* Обрабатывает ошибки от API
* @param response - Ответ от сервера
* @returns Обработанный текст ошибки
*/
async function handleApiError(response: Response): Promise<string> {
try {
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
const errorData = await response.json()
// Проверяем GraphQL ошибки
if (errorData.errors && errorData.errors.length > 0) {
return errorData.errors[0].message
}
// Проверяем сообщение об ошибке
if (errorData.error || errorData.message) {
return errorData.error || errorData.message
}
}
// Если не JSON или нет структурированной ошибки, читаем как текст
const errorText = await response.text()
return `Ошибка сервера: ${response.status} ${response.statusText}. ${errorText.substring(0, 100)}...`
} catch (_e) {
// Если не можем прочитать ответ
return `Ошибка сервера: ${response.status} ${response.statusText}`
}
}
/**
* Проверяет наличие ошибок авторизации в ответе GraphQL
* @param errors - Массив ошибок GraphQL
* @returns true если есть ошибки авторизации
*/
function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: string } }>): boolean {
return errors.some(
(error) =>
(error.message && (
error.message.toLowerCase().includes('unauthorized') ||
error.message.toLowerCase().includes('авторизации') ||
error.message.toLowerCase().includes('authentication') ||
error.message.toLowerCase().includes('unauthenticated') ||
error.message.toLowerCase().includes('token')
)) ||
error.extensions?.code === 'UNAUTHENTICATED' ||
error.extensions?.code === 'FORBIDDEN'
)
}
/**
* Выполняет GraphQL запрос
* @param query - GraphQL запрос
* @param variables - Переменные запроса
* @returns Результат запроса
*/
export async function query<T = GraphQLData>(
query: string,
variables: Record<string, unknown> = {}
): Promise<T> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
// Проверяем наличие токена в localStorage
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
// Проверяем наличие токена в cookie
const cookieToken = getAuthTokenFromCookie()
// Используем токен из localStorage или cookie
const token = localToken || cookieToken
// Если есть токен, добавляем его в заголовок Authorization с префиксом Bearer
if (token && token.length > 10) {
// В соответствии с логами сервера, формат должен быть: Bearer <token>
headers['Authorization'] = `Bearer ${token}`
// Для отладки
console.debug('Отправка запроса с токеном авторизации')
}
const response = await fetch(API_URL, {
method: 'POST',
headers,
// Важно: credentials: 'include' - для передачи cookies с запросом
credentials: 'include',
body: JSON.stringify({
query,
variables
})
})
// Проверяем статус ответа
if (!response.ok) {
const errorMessage = await handleApiError(response)
console.error('Ошибка API:', {
status: response.status,
statusText: response.statusText,
error: errorMessage
})
// Если получен 401 Unauthorized, перенаправляем на страницу входа
if (response.status === 401) {
localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/login'
throw new Error('Unauthorized')
}
throw new Error(errorMessage)
}
// Проверяем, что ответ содержит JSON
const contentType = response.headers.get('content-type')
if (!contentType?.includes('application/json')) {
const text = await response.text()
throw new Error(`Неверный формат ответа: ${text.substring(0, 100)}...`)
}
const result = await response.json()
if (result.errors) {
// Проверяем ошибки на признаки проблем с авторизацией
if (hasAuthErrors(result.errors)) {
localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/login'
throw new Error('Unauthorized')
}
throw new Error(result.errors[0].message)
}
return result.data as T
} catch (error) {
console.error('API Error:', error)
throw error
}
}
/**
* Выполняет GraphQL мутацию
* @param mutation - GraphQL мутация
* @param variables - Переменные мутации
* @returns Результат мутации
*/
export function mutate<T = GraphQLData>(
mutation: string,
variables: Record<string, unknown> = {}
): Promise<T> {
return query<T>(mutation, variables)
}

12
panel/index.tsx Normal file
View File

@@ -0,0 +1,12 @@
/**
* Точка входа в клиентское приложение
* @module index
*/
import { render } from 'solid-js/web'
import App from './App'
import './styles.css'
// Рендеринг приложения в корневой элемент
render(() => <App />, document.getElementById('root') as HTMLElement)

112
panel/login.tsx Normal file
View File

@@ -0,0 +1,112 @@
/**
* Компонент страницы входа
* @module LoginPage
*/
import { useNavigate } from '@solidjs/router'
import { Component, createSignal, onMount } from 'solid-js'
import { login, isAuthenticated } from './auth'
/**
* Компонент страницы входа
*/
const LoginPage: Component = () => {
const [email, setEmail] = createSignal('')
const [password, setPassword] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const navigate = useNavigate()
/**
* Проверка авторизации при загрузке компонента
* и перенаправление если пользователь уже авторизован
*/
onMount(() => {
// Если пользователь уже авторизован, перенаправляем на админ-панель
if (isAuthenticated()) {
window.location.href = '/admin'
}
})
/**
* Обработчик отправки формы входа
* @param e - Событие отправки формы
*/
const handleSubmit = async (e: Event) => {
e.preventDefault()
// Очищаем пробелы в email
const cleanEmail = email().trim()
if (!cleanEmail || !password()) {
setError('Пожалуйста, заполните все поля')
return
}
setIsLoading(true)
setError(null)
try {
// Используем функцию login из модуля auth
const loginSuccessful = await login({
email: cleanEmail,
password: password()
})
if (loginSuccessful) {
// Используем прямое перенаправление для надежности
window.location.href = '/admin'
} else {
throw new Error('Вход не выполнен')
}
} catch (err) {
console.error('Ошибка при входе:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
setIsLoading(false)
}
}
return (
<div class="login-page">
<div class="login-container">
<h1>Вход в систему</h1>
{error() && <div class="error-message">{error()}</div>}
<form onSubmit={handleSubmit}>
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
disabled={isLoading()}
autocomplete="username"
required
/>
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input
type="password"
id="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
disabled={isLoading()}
autocomplete="current-password"
required
/>
</div>
<button type="submit" disabled={isLoading()}>
{isLoading() ? 'Вход...' : 'Войти'}
</button>
</form>
</div>
</div>
)
}
export default LoginPage

587
panel/styles.css Normal file
View File

@@ -0,0 +1,587 @@
/**
* Основные стили приложения
*/
/* Сброс стилей */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Общие стили */
:root {
--primary-color: #3498db;
--primary-dark: #2980b9;
--success-color: #2ecc71;
--success-light: #d1fae5;
--danger-color: #e74c3c;
--danger-light: #fee2e2;
--warning-color: #f39c12;
--warning-light: #fef3c7;
--text-color: #333;
--bg-color: #f5f5f5;
--card-bg: #fff;
--border-color: #ddd;
}
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--text-color);
}
/* Общие элементы интерфейса */
.loading-screen, .loading {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 200px;
padding: 20px;
text-align: center;
color: var(--primary-color);
}
.loading-spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary-color);
border-radius: 50%;
width: 40px;
height: 40px;
margin-bottom: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
background-color: var(--danger-light);
border-left: 4px solid var(--danger-color);
color: var(--danger-color);
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
}
.success-message {
background-color: var(--success-light);
border-left: 4px solid var(--success-color);
color: var(--success-color);
padding: 10px;
margin-bottom: 20px;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #999;
font-style: italic;
}
/* Стили для формы и кнопок */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 16px;
}
button {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
padding: 10px 15px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
width: 100%;
}
button:hover {
background-color: var(--primary-dark);
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* Стили для страницы входа */
.login-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-container {
background-color: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 30px;
width: 100%;
max-width: 400px;
}
.login-container h1 {
margin-top: 0;
margin-bottom: 20px;
text-align: center;
color: var(--primary-color);
}
/* Стили для админ-панели */
.admin-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: var(--card-bg);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
padding: 15px 20px;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
width: 100%;
}
header h1 {
margin: 0;
color: var(--primary-color);
font-size: 24px;
}
.logout-button {
background-color: transparent;
color: var(--danger-color);
border: 1px solid var(--danger-color);
width: auto;
padding: 8px 16px;
font-size: 14px;
}
.logout-button:hover {
background-color: var(--danger-color);
color: white;
}
.admin-tabs {
display: flex;
border-bottom: 1px solid #ddd;
margin-bottom: 1.5rem;
gap: 10px;
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
.admin-tabs button {
background: none;
border: none;
padding: 8px 16px;
cursor: pointer;
font-size: 16px;
border-bottom: 3px solid transparent;
transition: all 0.2s;
width: auto;
color: var(--text-color);
}
.admin-tabs button.active {
border-bottom-color: var(--primary-color);
color: var(--primary-color);
font-weight: 600;
background-color: transparent;
}
.admin-tabs button:hover {
background-color: rgba(52, 152, 219, 0.1);
}
main {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
flex-grow: 1;
}
/* Таблица пользователей */
.users-list {
overflow-x: auto;
margin-top: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--border-color);
}
thead {
background-color: #f3f4f6;
}
th, td {
padding: 10px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
background-color: #f9f9f9;
}
tr:hover {
background-color: rgba(52, 152, 219, 0.05);
}
tr.blocked {
background-color: rgba(231, 76, 60, 0.05);
}
/* Статусы пользователей */
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-right: 4px;
}
.status.active {
background-color: var(--success-light);
color: var(--success-color);
}
.status.blocked {
background-color: var(--danger-light);
color: var(--danger-color);
}
.status.muted {
background-color: var(--warning-light);
color: var(--warning-color);
}
/* Кнопки действий */
.actions {
display: flex;
gap: 5px;
}
.actions button {
padding: 5px 10px;
font-size: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
width: auto;
}
button.block {
background-color: var(--danger-color);
}
button.unblock {
background-color: var(--success-color);
}
button.mute {
background-color: var(--warning-color);
}
button.unmute {
background-color: var(--primary-color);
}
/* Стили для редактирования ролей */
.roles-container {
display: flex;
align-items: center;
gap: 8px;
}
.roles-text {
flex: 1;
}
.edit-roles-button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 0;
opacity: 0.6;
transition: opacity 0.2s;
width: auto;
color: var(--primary-color);
}
.edit-roles-button:hover {
opacity: 1;
background-color: rgba(52, 152, 219, 0.1);
border-radius: 4px;
}
/* Модальное окно */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.modal-content h2 {
margin-top: 0;
color: var(--primary-color);
}
.roles-list {
margin: 16px 0;
}
.role-item {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.role-item:last-child {
border-bottom: none;
}
.role-item label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.role-description {
margin-top: 4px;
margin-left: 24px;
font-size: 14px;
color: #6b7280;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.cancel-button {
padding: 8px 16px;
background-color: #ccc;
color: #333;
width: auto;
}
.save-button {
padding: 8px 16px;
background-color: var(--primary-color);
width: auto;
}
.save-button:hover {
background-color: var(--primary-dark);
}
/* Стили для пагинации */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 10px 0;
flex-wrap: wrap;
gap: 10px;
}
.pagination-info {
color: #6b7280;
font-size: 14px;
}
.pagination-controls {
display: flex;
gap: 5px;
align-items: center;
}
.pagination-button {
min-width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border: 1px solid var(--border-color);
background-color: white;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.pagination-button:hover:not(:disabled) {
background-color: #f3f4f6;
border-color: #d1d5db;
}
.pagination-button.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-ellipsis {
padding: 0 8px;
color: #6b7280;
}
.pagination-per-page {
display: flex;
align-items: center;
font-size: 14px;
color: #6b7280;
}
.pagination-per-page select {
margin-left: 8px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: white;
}
/* Поиск */
.users-controls {
margin-bottom: 16px;
}
.search-container {
max-width: 500px;
width: 100%;
}
.search-input-group {
display: flex;
width: 100%;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px 0 0 4px;
font-size: 14px;
}
.search-input:focus {
border-color: var(--primary-color);
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
.search-button {
padding: 8px 16px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
transition: background-color 0.2s;
font-size: 14px;
}
.search-button:hover {
background-color: var(--primary-dark);
}
/* Адаптивные стили */
@media (max-width: 768px) {
.pagination {
flex-direction: column;
align-items: start;
}
.actions {
flex-direction: column;
}
.users-list {
font-size: 14px;
}
th, td {
padding: 8px 5px;
}
.pagination-per-page {
margin-top: 10px;
}
.header-container {
flex-direction: column;
gap: 10px;
}
}