wip
This commit is contained in:
183
panel/admin.tsx
183
panel/admin.tsx
@@ -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)
|
||||
|
131
panel/auth.ts
131
panel/auth.ts
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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')
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user