Рефакторинг: улучшение системы аутентификации и UI компонентов
- Удалены устаревшие файлы (mock.js, old-schedule.txt, loading-overlay.tsx) - Переработана система аутентификации (login, logout, check-auth) - Добавлен компонент toast для уведомлений - Улучшен контекст загрузки (loading-context) - Обновлен парсер расписания (schedule.ts) - Улучшена админ-панель - Обновлена документация (README.md) - Старые файлы перемещены в директорию old/
This commit is contained in:
@@ -114,7 +114,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext<{ gr
|
||||
const settings = loadSettings()
|
||||
const group = context.params?.group
|
||||
const wkParam = context.query.wk
|
||||
const wk = wkParam ? Number(wkParam) : undefined
|
||||
// Валидация wk параметра: проверка на валидное число (не NaN, не Infinity)
|
||||
const wk = wkParam && !isNaN(Number(wkParam)) && isFinite(Number(wkParam)) && Number.isInteger(Number(wkParam)) && Number(wkParam) > 0
|
||||
? Number(wkParam)
|
||||
: undefined
|
||||
|
||||
if (group && Object.hasOwn(groups, group) && group in groups) {
|
||||
let scheduleResult: ScheduleResult
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import '@/shared/styles/globals.css'
|
||||
import type { AppProps } from 'next/app'
|
||||
import { ThemeProvider } from '@/shared/providers/theme-provider'
|
||||
import { LoadingContextProvider, LoadingContext } from '@/shared/context/loading-context'
|
||||
import { LoadingOverlay } from '@/shared/ui/loading-overlay'
|
||||
import { LoadingContextProvider, LoadingContext, LoadingOverlay } from '@/shared/context/loading-context'
|
||||
import Head from 'next/head'
|
||||
import React from 'react'
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { loadGroups, GroupsData } from '@/shared/data/groups-loader'
|
||||
import { loadSettings, AppSettings } from '@/shared/data/settings-loader'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select'
|
||||
import { ToastContainer, Toast } from '@/shared/ui/toast'
|
||||
import Head from 'next/head'
|
||||
|
||||
type AdminPageProps = {
|
||||
@@ -34,6 +35,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
const [showEditDialog, setShowEditDialog] = React.useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = React.useState<string | null>(null)
|
||||
const [toasts, setToasts] = React.useState<Toast[]>([])
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||
const id = Date.now().toString()
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||
}
|
||||
|
||||
// Форма добавления/редактирования
|
||||
const [formData, setFormData] = React.useState({
|
||||
@@ -131,15 +142,20 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
if (res.ok && data.success) {
|
||||
// Обновляем состояние из ответа сервера (для синхронизации)
|
||||
setSettings(data.settings)
|
||||
showToast('Настройки успешно обновлены', 'success')
|
||||
} else {
|
||||
// Откатываем изменения при ошибке
|
||||
setSettings(previousSettings)
|
||||
setError(data.error || 'Ошибка при обновлении настроек')
|
||||
const errorMessage = data.error || 'Ошибка при обновлении настроек'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
// Откатываем изменения при ошибке
|
||||
setSettings(previousSettings)
|
||||
setError('Ошибка соединения с сервером')
|
||||
const errorMessage = 'Ошибка соединения с сервером'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,11 +182,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
setGroups(data.groups)
|
||||
setShowAddDialog(false)
|
||||
setFormData({ id: '', parseId: '', name: '', course: '1' })
|
||||
showToast('Группа успешно добавлена', 'success')
|
||||
} else {
|
||||
setError(data.error || 'Ошибка при добавлении группы')
|
||||
const errorMessage = data.error || 'Ошибка при добавлении группы'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка соединения с сервером')
|
||||
const errorMessage = 'Ошибка соединения с сервером'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -201,11 +222,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
setShowEditDialog(false)
|
||||
setEditingGroup(null)
|
||||
setFormData({ id: '', parseId: '', name: '', course: '1' })
|
||||
showToast('Группа успешно обновлена', 'success')
|
||||
} else {
|
||||
setError(data.error || 'Ошибка при редактировании группы')
|
||||
const errorMessage = data.error || 'Ошибка при редактировании группы'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка соединения с сервером')
|
||||
const errorMessage = 'Ошибка соединения с сервером'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -228,11 +254,16 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
setGroups(data.groups)
|
||||
setShowDeleteDialog(false)
|
||||
setGroupToDelete(null)
|
||||
showToast('Группа успешно удалена', 'success')
|
||||
} else {
|
||||
setError(data.error || 'Ошибка при удалении группы')
|
||||
const errorMessage = data.error || 'Ошибка при удалении группы'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка соединения с сервером')
|
||||
const errorMessage = 'Ошибка соединения с сервером'
|
||||
setError(errorMessage)
|
||||
showToast(errorMessage, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -586,6 +617,9 @@ export default function AdminPage({ groups: initialGroups, settings: initialSett
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Toast уведомления */}
|
||||
<ToastContainer toasts={toasts} onClose={removeToast} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,3 +21,4 @@ export default function handler(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,79 @@ type ResponseData = {
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Rate limiting: 5 попыток в 15 минут
|
||||
const MAX_ATTEMPTS = 5
|
||||
const WINDOW_MS = 15 * 60 * 1000 // 15 минут
|
||||
|
||||
interface RateLimitEntry {
|
||||
attempts: number
|
||||
resetTime: number
|
||||
}
|
||||
|
||||
const rateLimitMap = new Map<string, RateLimitEntry>()
|
||||
|
||||
function getClientIP(req: NextApiRequest): string {
|
||||
// Получаем IP адрес клиента
|
||||
const forwarded = req.headers['x-forwarded-for']
|
||||
const realIP = req.headers['x-real-ip']
|
||||
const remoteAddress = req.socket.remoteAddress
|
||||
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0].trim()
|
||||
}
|
||||
if (typeof realIP === 'string') {
|
||||
return realIP
|
||||
}
|
||||
if (remoteAddress) {
|
||||
return remoteAddress
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitMap.get(ip)
|
||||
|
||||
// Очищаем старые записи
|
||||
if (entry && now > entry.resetTime) {
|
||||
rateLimitMap.delete(ip)
|
||||
}
|
||||
|
||||
const currentEntry = rateLimitMap.get(ip)
|
||||
|
||||
if (!currentEntry) {
|
||||
// Первая попытка
|
||||
rateLimitMap.set(ip, {
|
||||
attempts: 1,
|
||||
resetTime: now + WINDOW_MS
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (currentEntry.attempts >= MAX_ATTEMPTS) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик попыток
|
||||
currentEntry.attempts++
|
||||
return true
|
||||
}
|
||||
|
||||
function recordFailedAttempt(ip: string): void {
|
||||
const entry = rateLimitMap.get(ip)
|
||||
if (entry) {
|
||||
// Попытка уже засчитана в checkRateLimit
|
||||
return
|
||||
}
|
||||
|
||||
// Если записи нет, создаем новую
|
||||
const now = Date.now()
|
||||
rateLimitMap.set(ip, {
|
||||
attempts: 1,
|
||||
resetTime: now + WINDOW_MS
|
||||
})
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<ResponseData>
|
||||
@@ -15,6 +88,19 @@ export default function handler(
|
||||
return
|
||||
}
|
||||
|
||||
const clientIP = getClientIP(req)
|
||||
|
||||
// Проверяем rate limit
|
||||
if (!checkRateLimit(clientIP)) {
|
||||
const entry = rateLimitMap.get(clientIP)
|
||||
const retryAfter = entry ? Math.ceil((entry.resetTime - Date.now()) / 1000) : WINDOW_MS / 1000
|
||||
res.status(429).json({
|
||||
error: 'Too many login attempts. Please try again later.',
|
||||
retryAfter
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { password } = req.body
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
@@ -23,9 +109,13 @@ export default function handler(
|
||||
}
|
||||
|
||||
if (verifyPassword(password)) {
|
||||
// Успешный вход - сбрасываем rate limit
|
||||
rateLimitMap.delete(clientIP)
|
||||
setSessionCookie(res)
|
||||
res.status(200).json({ success: true })
|
||||
} else {
|
||||
// Неудачная попытка - записываем
|
||||
recordFailedAttempt(clientIP)
|
||||
res.status(401).json({ error: 'Invalid password' })
|
||||
}
|
||||
}
|
||||
@@ -33,3 +123,4 @@ export default function handler(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,3 +21,4 @@ export default function handler(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user