This commit is contained in:
2025-05-19 11:25:41 +03:00
parent 11e46f7352
commit dc5ad46df9
20 changed files with 952 additions and 509 deletions

View File

@@ -51,6 +51,26 @@ interface AdminGetRolesResponse {
adminGetRoles: Role[]
}
/**
* Интерфейс для ответа изменения статуса пользователя
*/
interface AdminSetUserStatusResponse {
adminSetUserStatus: {
success: boolean
error?: string
}
}
/**
* Интерфейс для ответа изменения статуса блокировки чата
*/
interface AdminMuteUserResponse {
adminMuteUser: {
success: boolean
error?: string
}
}
// Интерфейс для пропсов AdminPage
interface AdminPageProps {
onLogout?: () => void
@@ -199,42 +219,41 @@ const AdminPage: Component<AdminPageProps> = (props) => {
* @param page - Номер страницы
*/
function handlePageChange(page: number) {
if (page < 1 || page > pagination().totalPages) return
setPagination((prev) => ({ ...prev, page }))
setPagination({ ...pagination(), page })
loadUsers()
}
/**
* Обработчик изменения количества записей на странице
* @param limit - Количество записей на странице
* Обработчик изменения количества элементов на странице
* @param limit - Количество элементов
*/
function handlePerPageChange(limit: number) {
setPagination((prev) => ({ ...prev, page: 1, limit }))
setPagination({ ...pagination(), page: 1, limit })
loadUsers()
}
/**
* Обработчик изменения поискового запроса
* @param e - Событие изменения ввода
*/
function handleSearchChange(e: Event) {
const target = e.target as HTMLInputElement
setSearchQuery(target.value)
const input = e.target as HTMLInputElement
setSearchQuery(input.value)
}
/**
* Выполняет поиск при нажатии Enter или кнопки поиска
* Выполняет поиск
*/
function handleSearch() {
setPagination((prev) => ({ ...prev, page: 1 })) // Сбрасываем на первую страницу при поиске
setPagination({ ...pagination(), page: 1 })
loadUsers()
}
/**
* Обработчик нажатия клавиши в поле поиска
* @param e - Событие нажатия клавиши
* Обработчик нажатия клавиш в поле поиска
* @param e - Событие клавиатуры
*/
function handleSearchKeyDown(e: KeyboardEvent) {
// Если нажат Enter, выполняем поиск
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
@@ -242,101 +261,105 @@ const AdminPage: Component<AdminPageProps> = (props) => {
}
/**
* Блокировка/разблокировка пользователя
* Блокирует/разблокирует пользователя
* @param userId - ID пользователя
* @param isActive - Текущий статус активности
*/
async function toggleUserBlock(userId: number, isActive: boolean) {
// Запрашиваем подтверждение
const action = isActive ? 'заблокировать' : 'разблокировать'
if (!confirm(`Вы действительно хотите ${action} этого пользователя?`)) {
return
}
try {
await query(
setError(null)
// Устанавливаем новый статус (противоположный текущему)
const newStatus = !isActive
// Выполняем мутацию
const result = await query<AdminSetUserStatusResponse>(
`${location.origin}/graphql`,
`
mutation AdminToggleUserBlock($userId: Int!) {
adminToggleUserBlock(userId: $userId) {
success
error
mutation AdminSetUserStatus($userId: Int!, $isActive: Boolean!) {
adminSetUserStatus(userId: $userId, isActive: $isActive) {
success
error
}
}
}
`,
{ userId }
`,
{ userId, isActive: newStatus }
)
// Обновляем статус пользователя
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, is_active: !isActive }
}
return user
})
)
// Показываем сообщение об успехе
setSuccessMessage(`Пользователь успешно ${isActive ? 'заблокирован' : 'разблокирован'}`)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
// Проверяем результат
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 : 'Ошибка изменения статуса блокировки')
console.error('Ошибка при изменении статуса пользователя:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
}
}
/**
* Включение/отключение режима "mute" для пользователя
* Включает/отключает режим блокировки чата для пользователя
* @param userId - ID пользователя
* @param isMuted - Текущий статус mute
* @param isMuted - Текущий статус блокировки чата
*/
async function toggleUserMute(userId: number, isMuted: boolean) {
// Запрашиваем подтверждение
const action = isMuted ? 'включить звук' : 'отключить звук'
if (!confirm(`Вы действительно хотите ${action} для этого пользователя?`)) {
return
}
try {
await query(
setError(null)
// Устанавливаем новый статус (противоположный текущему)
const newMuteStatus = !isMuted
// Выполняем мутацию
const result = await query<AdminMuteUserResponse>(
`${location.origin}/graphql`,
`
mutation AdminToggleUserMute($userId: Int!) {
adminToggleUserMute(userId: $userId) {
success
error
mutation AdminMuteUser($userId: Int!, $muted: Boolean!) {
adminMuteUser(userId: $userId, muted: $muted) {
success
error
}
}
}
`,
{ userId }
`,
{ userId, muted: newMuteStatus }
)
// Обновляем статус пользователя
setUsers((prev) =>
prev.map((user) => {
if (user.id === userId) {
return { ...user, muted: !isMuted }
}
return user
})
)
// Показываем сообщение об успехе
setSuccessMessage(`Звук для пользователя успешно ${isMuted ? 'включен' : 'отключен'}`)
// Скрываем сообщение через 3 секунды
setTimeout(() => setSuccessMessage(null), 3000)
// Проверяем результат
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('Ошибка изменения статуса mute:', err)
setError(err instanceof Error ? err.message : 'Ошибка изменения статуса mute')
console.error('Ошибка при изменении статуса блокировки чата:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
}
}
/**
* Закрывает модальное окно управления ролями
* Закрывает модальное окно ролей
*/
function closeRolesModal() {
setShowRolesModal(false)

View File

@@ -3,29 +3,9 @@
* @module auth
*/
import { query } from './graphql'
// Константа для имени ключа токена в localStorage
const AUTH_COOKIE_NAME = 'auth_token'
// Константа для имени ключа токена в cookie
// Экспортируем константы для использования в других модулях
export const AUTH_TOKEN_KEY = 'auth_token'
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
export const 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 ''
}
export const CSRF_TOKEN_KEY = 'csrf_token'
/**
* Интерфейс для учетных данных
@@ -51,6 +31,36 @@ interface LoginResponse {
login: LoginResult
}
/**
* Получает токен авторизации из cookie
* @returns Токен или пустую строку, если токен не найден
*/
export function getAuthTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === AUTH_TOKEN_KEY) {
return value
}
}
return ''
}
/**
* Получает CSRF-токен из cookie
* @returns CSRF-токен или пустую строку, если токен не найден
*/
export function getCsrfTokenFromCookie(): string {
const cookieItems = document.cookie.split(';')
for (const item of cookieItems) {
const [name, value] = item.trim().split('=')
if (name === CSRF_TOKEN_KEY) {
return value
}
}
return ''
}
/**
* Проверяет, авторизован ли пользователь
* @returns Статус авторизации
@@ -77,13 +87,17 @@ export function logout(callback?: () => void): void {
localStorage.removeItem(AUTH_TOKEN_KEY)
// Для удаления cookie устанавливаем ей истекшее время жизни
document.cookie = `${AUTH_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
document.cookie = `${AUTH_TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`
// Дополнительно пытаемся сделать запрос на сервер для удаления серверных сессий
try {
fetch('/logout', {
method: 'GET',
credentials: 'include'
fetch('/auth/logout', {
method: 'POST', // Используем POST вместо GET для операций изменения состояния
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
}
}).catch((e) => {
console.error('Ошибка при запросе на выход:', e)
})
@@ -96,47 +110,68 @@ export function logout(callback?: () => void): void {
}
/**
* Выполняет вход в систему
* Выполняет вход в систему используя GraphQL-запрос
* @param credentials - Учетные данные
* @returns Результат авторизации
*/
export async function login(credentials: Credentials): Promise<boolean> {
try {
// Используем query из graphql.ts для выполнения запроса
const data = await query<LoginResponse>(
`${location.origin}/graphql`,
`
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
success
token
error
console.log('Отправка запроса авторизации через GraphQL')
const response = await fetch(`${location.origin}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': getCsrfTokenFromCookie() // Добавляем CSRF токен если он есть
},
credentials: 'include', // Важно для обработки cookies
body: JSON.stringify({
query: `
mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) {
success
token
error
}
}
`,
variables: {
email: credentials.email,
password: credentials.password
}
}
`,
{
email: credentials.email,
password: credentials.password
}
)
})
})
if (data?.login?.success) {
if (!response.ok) {
const errorText = await response.text()
console.error('Ошибка HTTP:', response.status, errorText)
throw new Error(`HTTP error: ${response.status} ${response.statusText}`)
}
const result = await response.json()
console.log('Результат авторизации:', result)
if (result?.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)
if (!hasCookie && result.data.login.token) {
localStorage.setItem(AUTH_TOKEN_KEY, result.data.login.token)
}
return true
}
throw new Error(data?.login?.error || 'Ошибка авторизации')
if (result.errors && result.errors.length > 0) {
throw new Error(result.errors[0].message || 'Ошибка авторизации')
}
throw new Error(result?.data?.login?.error || 'Неизвестная ошибка авторизации')
} catch (error) {
console.error('Ошибка при входе:', error)
throw error
}
}

View File

@@ -3,7 +3,7 @@
* @module api
*/
import { AUTH_TOKEN_KEY, getAuthTokenFromCookie } from "./auth"
import { AUTH_TOKEN_KEY, CSRF_TOKEN_KEY, getAuthTokenFromCookie, getCsrfTokenFromCookie } from './auth'
/**
* Тип для произвольных данных GraphQL
@@ -61,6 +61,55 @@ function hasAuthErrors(errors: Array<{ message?: string; extensions?: { code?: s
)
}
/**
* Подготавливает URL для GraphQL запроса
* @param url - URL или путь для запроса
* @returns Полный URL для запроса
*/
function prepareUrl(url: string): string {
// Если это относительный путь, добавляем к нему origin
if (url.startsWith('/')) {
return `${location.origin}${url}`
}
// Если это уже полный URL, используем как есть
return url
}
/**
* Возвращает заголовки для GraphQL запроса с учетом авторизации и CSRF
* @returns Объект с заголовками
*/
function getRequestHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': '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) {
headers['Authorization'] = `Bearer ${token}`
console.debug('Отправка запроса с токеном авторизации')
}
// Добавляем CSRF-токен, если он есть
const csrfToken = getCsrfTokenFromCookie()
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken
console.debug('Добавлен CSRF-токен в запрос')
}
return headers
}
/**
* Выполняет GraphQL запрос
* @param url - URL для запроса
@@ -74,28 +123,14 @@ export async function query<T = GraphQLData>(
variables: Record<string, unknown> = {}
): Promise<T> {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
// Получаем все необходимые заголовки для запроса
const headers = getRequestHeaders()
// Проверяем наличие токена в localStorage
const localToken = localStorage.getItem(AUTH_TOKEN_KEY)
// Подготавливаем полный URL
const fullUrl = prepareUrl(url)
console.debug('Отправка GraphQL запроса на:', fullUrl)
// Проверяем наличие токена в 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(url, {
const response = await fetch(fullUrl, {
method: 'POST',
headers,
// Важно: credentials: 'include' - для передачи cookies с запросом
@@ -115,8 +150,8 @@ export async function query<T = GraphQLData>(
error: errorMessage
})
// Если получен 401 Unauthorized, перенаправляем на страницу входа
if (response.status === 401) {
// Если получен 401 Unauthorized или 403 Forbidden, перенаправляем на страницу входа
if (response.status === 401 || response.status === 403) {
localStorage.removeItem(AUTH_TOKEN_KEY)
window.location.href = '/'
throw new Error('Unauthorized')

View File

@@ -18,6 +18,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
const [password, setPassword] = createSignal('')
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [formSubmitting, setFormSubmitting] = createSignal(false)
/**
* Обработчик отправки формы входа
@@ -26,6 +27,9 @@ const LoginPage: Component<LoginPageProps> = (props) => {
const handleSubmit = async (e: Event) => {
e.preventDefault()
// Предотвращаем повторную отправку формы
if (formSubmitting()) return
// Очищаем пробелы в email
const cleanEmail = email().trim()
@@ -34,6 +38,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
return
}
setFormSubmitting(true)
setIsLoading(true)
setError(null)
@@ -56,6 +61,8 @@ const LoginPage: Component<LoginPageProps> = (props) => {
console.error('Ошибка при входе:', err)
setError(err instanceof Error ? err.message : 'Неизвестная ошибка')
setIsLoading(false)
} finally {
setFormSubmitting(false)
}
}
@@ -66,12 +73,13 @@ const LoginPage: Component<LoginPageProps> = (props) => {
{error() && <div class="error-message">{error()}</div>}
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} method="post">
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
disabled={isLoading()}
@@ -85,6 +93,7 @@ const LoginPage: Component<LoginPageProps> = (props) => {
<input
type="password"
id="password"
name="password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
disabled={isLoading()}
@@ -93,8 +102,15 @@ const LoginPage: Component<LoginPageProps> = (props) => {
/>
</div>
<button type="submit" disabled={isLoading()}>
{isLoading() ? 'Вход...' : 'Войти'}
<button type="submit" disabled={isLoading() || formSubmitting()}>
{isLoading() ? (
<>
<span class="spinner"></span>
Вход...
</>
) : (
'Войти'
)}
</button>
</form>
</div>