diff --git a/package.json b/package.json index 3371ed1..abbe0ee 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "eslint-plugin-react": "^7.36.1", "express": "^4.19.2", "globals": "^15.9.0", - "keycloak-js": "^26.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-redux": "^9.2.0", diff --git a/src/__data__/api/api.ts b/src/__data__/api/api.ts index 9e90217..41b0821 100644 --- a/src/__data__/api/api.ts +++ b/src/__data__/api/api.ts @@ -12,7 +12,6 @@ import type { SystemStats, UserStats, } from '../types' -import { keycloak } from '../kc' const normalizeBaseUrl = (url: string) => (url.endsWith('/') ? url.slice(0, -1) : url) const backendBaseUrl = normalizeBaseUrl(getConfigValue('challenge.api')) @@ -22,23 +21,8 @@ export const api = createApi({ reducerPath: 'challengeApi', baseQuery: fetchBaseQuery({ baseUrl: challengeBaseUrl, - fetchFn: async ( - input: RequestInfo | URL, - init?: RequestInit, - ) => { - const response = await fetch(input, init) - - if (response.status === 403) keycloak.login() - - return response - }, prepareHeaders: (headers) => { headers.set('Content-Type', 'application/json;charset=utf-8') - - if (keycloak?.token) { - headers.set('Authorization', `Bearer ${keycloak.token}`) - } - return headers }, }), diff --git a/src/__data__/kc.ts b/src/__data__/kc.ts deleted file mode 100644 index 1f66bda..0000000 --- a/src/__data__/kc.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Keycloak from 'keycloak-js' - -export const keycloak = new Keycloak({ - url: KC_URL, - realm: KC_REALM, - clientId: KC_CLIENT_ID, -}) diff --git a/src/__data__/slices/user.ts b/src/__data__/slices/user.ts deleted file mode 100644 index 120c53f..0000000 --- a/src/__data__/slices/user.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit' - -import { UserData } from '../types' - -export const userSlice = createSlice({ - name: 'user', - initialState: null as UserData, - reducers: { - } -}) diff --git a/src/__data__/types.ts b/src/__data__/types.ts index 80a822c..879cc15 100644 --- a/src/__data__/types.ts +++ b/src/__data__/types.ts @@ -295,105 +295,3 @@ export interface ABTestMetrics { satisfactionScore?: number } -/** - * Данные токена аутентификации - */ -interface TokenData { - /** Время истечения токена */ - exp: number; - /** Время выдачи токена */ - iat: number; - /** Время аутентификации */ - auth_time: number; - /** Уникальный идентификатор токена */ - jti: string; - /** Издатель токена */ - iss: string; - /** Аудитория токена */ - aud: string[]; - /** Идентификатор пользователя */ - sub: string; - /** Тип токена */ - typ: string; - /** Идентификатор клиента */ - azp: string; - /** Одноразовое значение */ - nonce: string; - /** Состояние сессии */ - session_state: string; - /** Уровень аутентификации */ - acr: string; - /** Разрешенные источники */ - "allowed-origins": string[]; - /** Доступ к области */ - realm_access: Realmaccess; - /** Доступ к ресурсам */ - resource_access: Resourceaccess; - /** Область действия токена */ - scope: string; - /** Идентификатор сессии */ - sid: string; - /** Подтвержден ли email */ - email_verified: boolean; - /** Полное имя пользователя */ - name: string; - /** Предпочитаемое имя пользователя */ - preferred_username: string; - /** Имя пользователя */ - given_name: string; - /** Фамилия пользователя */ - family_name: string; - /** Email пользователя */ - email: string; -} - -/** - * Доступ к ресурсам - */ -interface Resourceaccess { - /** Доступ к журналу */ - journal: Realmaccess; -} - -/** - * Доступ к области - */ -interface Realmaccess { - /** Роли пользователя */ - roles: (string | "teacher")[]; -} - -/** - * Расширенные данные пользователя - */ -export interface UserData extends TokenData { - /** Идентификатор пользователя */ - sub: string; - /** URL аватара пользователя */ - gravatar: string; - /** Подтвержден ли email */ - email_verified: boolean; - /** Дополнительные атрибуты пользователя */ - attributes: Record; - /** Полное имя пользователя */ - name: string; - /** Предпочитаемое имя пользователя */ - preferred_username: string; - /** Имя пользователя */ - given_name: string; - /** Фамилия пользователя */ - family_name: string; - /** Email пользователя */ - email: string; -} - -/** - * Базовый ответ API - */ -export type BaseResponse = { - /** Успешность операции */ - success: boolean; - /** Данные ответа */ - body: Data; -}; - diff --git a/src/components/ChainSelector.tsx b/src/components/ChainSelector.tsx new file mode 100644 index 0000000..bc862dc --- /dev/null +++ b/src/components/ChainSelector.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { + Box, + Button, + Heading, + SimpleGrid, + Text, + VStack, +} from '@chakra-ui/react' + +import type { ChallengeChain } from '../__data__/types' +import { useChallenge } from '../context/ChallengeContext' + +interface ChainSelectorProps { + onSelectChain: (chain: ChallengeChain) => void +} + +export const ChainSelector = ({ onSelectChain }: ChainSelectorProps) => { + const { chains, personalDashboard } = useChallenge() + + if (chains.length === 0) { + return ( + + + + Нет доступных цепочек + + + Свяжитесь с преподавателем для получения доступа к заданиям. + + + + ) + } + + return ( + + + + + Выберите цепочку заданий + + + Выберите цепочку заданий для начала работы. Вы можете проходить несколько цепочек параллельно. + + + + + {chains.map((chain) => { + const chainStats = personalDashboard?.activeChains.find( + (stat) => stat.chainId === chain.id + ) + const completedTasks = chainStats?.completedTasks ?? 0 + const progress = chainStats?.progress ?? 0 + + return ( + + + + + {chain.name} + + + Заданий в цепочке: {chain.tasks.length} + + + {completedTasks > 0 && ( + + + Прогресс: {completedTasks} / {chain.tasks.length} + + + + + + )} + + + + + + ) + })} + + + + ) +} + diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..2976f08 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { Box, Button, Flex, Heading, Text } from '@chakra-ui/react' + +import { useChallenge } from '../context/ChallengeContext' + +export const Header = () => { + const { nickname, logout } = useChallenge() + + if (!nickname) return null + + return ( + + + + + Challenge Platform + + + {nickname} + + + + + + ) +} + diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx new file mode 100644 index 0000000..548c3e0 --- /dev/null +++ b/src/components/LoginForm.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react' +import { + Box, + Button, + Heading, + Input, + Text, + VStack, +} from '@chakra-ui/react' + +import { useChallenge } from '../context/ChallengeContext' + +export const LoginForm = () => { + const [fullName, setFullName] = useState('') + const [error, setError] = useState('') + const { login, isAuthLoading } = useChallenge() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + const trimmedName = fullName.trim() + if (!trimmedName) { + setError('Пожалуйста, введите ваше ФИО') + return + } + + if (trimmedName.length < 3) { + setError('ФИО должно содержать минимум 3 символа') + return + } + + try { + setError('') + await login(trimmedName) + } catch (err) { + setError('Произошла ошибка при входе. Попробуйте снова.') + console.error('Login error:', err) + } + } + + return ( + + + + + + Challenge Platform + + + Добро пожаловать! Введите ваше ФИО для начала работы + + + +
+ + + + Ваше ФИО + + setFullName(e.target.value)} + placeholder="Иванов Иван Иванович" + size="lg" + autoFocus + isInvalid={!!error} + /> + {error && ( + + {error} + + )} + + + + +
+
+
+
+ ) +} + diff --git a/src/components/admin/ABTestPanel.tsx b/src/components/admin/ABTestPanel.tsx deleted file mode 100644 index 33e7295..0000000 --- a/src/components/admin/ABTestPanel.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, { useMemo, useState } from 'react' -import { - Box, - Button, - Grid, - GridItem, - Heading, - NumberInput, - NumberInputInput, - Stat, - StatHelpText, - StatLabel, - StatValueText, - Text, - VStack, -} from '@chakra-ui/react' - -import type { ABTestMetrics } from '../../__data__/types' -import { compareVariants } from '../../utils/analytics' - -interface VariantFormState { - submissionRate: number - completionRate: number - retryRate: number - timeToFirstSubmission: number - sessionDuration: number - satisfactionScore?: number -} - -const createVariantState = (): VariantFormState => ({ - submissionRate: 0, - completionRate: 0, - retryRate: 0, - timeToFirstSubmission: 0, - sessionDuration: 0, - satisfactionScore: undefined, -}) - -const buildMetrics = (variant: 'A' | 'B', state: VariantFormState): ABTestMetrics => ({ - variant, - submissionRate: state.submissionRate, - completionRate: state.completionRate, - retryRate: state.retryRate, - timeToFirstSubmission: state.timeToFirstSubmission, - sessionDuration: state.sessionDuration, - satisfactionScore: state.satisfactionScore, -}) - -const MetricInput = ({ - label, - value, - onChange, - suffix, -}: { - label: string - value: number - onChange: (value: number) => void - suffix?: string -}) => ( - - - {label} - - onChange(Number.isNaN(val) ? 0 : val)}> - - - {suffix && ( - - {suffix} - - )} - -) - -export const ABTestPanel = () => { - const [variantA, setVariantA] = useState(createVariantState) - const [variantB, setVariantB] = useState(createVariantState) - const [comparison, setComparison] = useState | null>(null) - - const handleCompare = () => { - const metricsA = buildMetrics('A', variantA) - const metricsB = buildMetrics('B', variantB) - setComparison(compareVariants(metricsA, metricsB)) - } - - const hasData = useMemo( - () => - Object.values(variantA).some((value) => value !== 0) || - Object.values(variantB).some((value) => value !== 0), - [variantA, variantB], - ) - - return ( - - - A/B тест: сравнение вариантов - - - - - - Вариант A - - - setVariantA((prev) => ({ ...prev, submissionRate: value }))} - suffix="Процент пользователей, отправивших хотя бы одно решение" - /> - setVariantA((prev) => ({ ...prev, completionRate: value }))} - /> - setVariantA((prev) => ({ ...prev, retryRate: value }))} - /> - setVariantA((prev) => ({ ...prev, timeToFirstSubmission: value }))} - /> - setVariantA((prev) => ({ ...prev, sessionDuration: value }))} - /> - - - - - - Вариант B - - - setVariantB((prev) => ({ ...prev, submissionRate: value }))} - /> - setVariantB((prev) => ({ ...prev, completionRate: value }))} - /> - setVariantB((prev) => ({ ...prev, retryRate: value }))} - /> - setVariantB((prev) => ({ ...prev, timeToFirstSubmission: value }))} - /> - setVariantB((prev) => ({ ...prev, sessionDuration: value }))} - /> - - - - - - - {comparison && ( - - - Результат сравнения - - - - Δ Submission Rate - {comparison.submissionRateDiff.toFixed(1)}% - Положительное значение — рост у варианта B - - - Δ Completion Rate - {comparison.completionRateDiff.toFixed(1)}% - Положительное значение — рост у варианта B - - - - - Победитель - Вариант {comparison.winner} - Основано на сравнении коэффициента завершения - - - )} - - ) -} - diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 8c2f069..a691263 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -2,7 +2,7 @@ import React, { Suspense } from 'react' import { Route, Routes } from 'react-router-dom' import { URLs } from './__data__/urls' -import { AdminPage, MainPage } from './pages' +import { MainPage } from './pages' const PageWrapper = ({ children }: React.PropsWithChildren) => ( Loading...}>{children} @@ -19,16 +19,6 @@ export const Dashboard = () => { } /> - {URLs.admin.isOn && ( - - - - } - /> - )} ) } diff --git a/src/index.tsx b/src/index.tsx index 3a75868..6be37a1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,9 +5,7 @@ import i18next from 'i18next' import { i18nextReactInitConfig } from '@brojs/cli' import App from './app' -import { keycloak } from "./__data__/kc" import { createStore } from "./__data__/store" -import { isAuthLoopBlocked, recordAuthAttempt, clearAuthAttempts } from './utils/authLoopGuard' i18next.t = i18next.t.bind(i18next) const i18nextPromise = i18nextReactInitConfig(i18next) @@ -17,41 +15,7 @@ export default (props) => let rootElement: ReactDOM.Root export const mount = async (Component, element = document.getElementById('app')) => { - let user = null - try { - if (isAuthLoopBlocked()) { - await i18nextPromise - rootElement = ReactDOM.createRoot(element) - rootElement.render() - - return - } - - recordAuthAttempt() - await keycloak.init({ - onLoad: 'login-required' - // onLoad: 'check-sso' - }) - - const userInfo = await keycloak.loadUserInfo() - - if (userInfo && keycloak.tokenParsed) { - user = { ...userInfo, ...keycloak.tokenParsed } - } else { - console.error('No userInfo or tokenParsed', userInfo, keycloak.tokenParsed) - } - - clearAuthAttempts() - } catch (error) { - console.error('Failed to initialize adapter:', error) - // keycloak.login() - } - const store = createStore({ user }) + const store = createStore() await i18nextPromise rootElement = ReactDOM.createRoot(element) diff --git a/src/pages/admin/AdminDashboard.tsx b/src/pages/admin/AdminDashboard.tsx deleted file mode 100644 index 87eac1a..0000000 --- a/src/pages/admin/AdminDashboard.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import React, { useMemo, useState } from 'react' -import { - Badge, - Box, - HStack, - Heading, - SimpleGrid, - Table, - TableBody, - TableCell, - TableColumnHeader, - TableContainer, - TableHeader, - TableRow, - Text, - VStack, - Select, -} from '@chakra-ui/react' - -import { - useGetAllSubmissionsQuery, - useGetChainsQuery, - useGetSystemStatsQuery, -} from '../../__data__/api/api' -import type { ChallengeChain } from '../../__data__/types' -import { StatCard } from '../../components/personal' -import { ABTestPanel } from '../../components/admin/ABTestPanel' -import { mapTaskMetrics, detectIssues, msToMinutes } from '../../utils/analytics' -import { keycloak } from '../../__data__/kc' - -const formatNumber = (value: number | undefined) => { - if (!value && value !== 0) return '—' - return Intl.NumberFormat('ru-RU').format(value) -} - -const hasTeacherRole = () => { - try { - return keycloak?.hasResourceRole?.('teacher', 'journal') ?? false - } catch (error) { - return false - } -} - -export const AdminDashboard = () => { - const isTeacher = hasTeacherRole() - const { data: systemStats, isLoading } = useGetSystemStatsQuery() - const { data: chains = [] } = useGetChainsQuery(undefined, { skip: !isTeacher }) - const { data: submissions = [] } = useGetAllSubmissionsQuery(undefined, { skip: !isTeacher }) - - const issues = useMemo(() => (systemStats ? detectIssues(systemStats) : []), [systemStats]) - const taskMetrics = useMemo(() => mapTaskMetrics(submissions), [submissions]) - const [difficultyFilter, setDifficultyFilter] = useState<'all' | 'easy' | 'medium' | 'hard'>('all') - - const filteredTaskMetrics = useMemo(() => { - if (difficultyFilter === 'all') return taskMetrics - return taskMetrics.filter((metric) => metric.difficulty === difficultyFilter) - }, [difficultyFilter, taskMetrics]) - - if (!isTeacher) { - return ( - - - - Требуется роль преподавателя - - - У вас нет доступа к панели администратора. Обратитесь к администратору Keycloak для назначения роли - - teacher - - . - - - - ) - } - - if (isLoading || !systemStats) { - return ( - - Загружаем системные метрики... - - ) - } - - return ( - - - Панель преподавателя - - - - - - - - - - - - - Статус очереди - - - - - Всего в очереди - - - {formatNumber(systemStats.queue.queueLength)} - - - - - В ожидании - - - {formatNumber(systemStats.queue.waiting)} - - - - - В обработке - - - {formatNumber(systemStats.queue.currentlyProcessing)} / {formatNumber(systemStats.queue.maxConcurrency)} - - - - - - - - Проблемные области - - - {issues.length === 0 ? ( - Критических проблем не обнаружено. - ) : ( - - {issues.map((issue) => ( - - {issue.message} - - Сущность: {issue.affectedEntity} · Важность: {issue.severity} - - - ))} - - )} - - - - - Метрики по заданиям - - - - {filteredTaskMetrics.length === 0 ? ( - Недостаточно данных о проверках для построения аналитики. - ) : ( - - - - - Задание - Попыток - Успешность - Сред. попыток - Сред. время (мин) - Сложность - - - - {filteredTaskMetrics.map((metric) => ( - - - {metric.title} - - ID: {metric.taskId} - - - {formatNumber(metric.attemptsCount)} - {formatNumber(Math.round(metric.successRate))}% - {formatNumber(Math.round(metric.avgAttempts * 10) / 10)} - {formatNumber(Math.round(msToMinutes(metric.avgTimeToComplete * 1000)))} - - - {metric.difficulty} - - - - ))} - -
-
- )} -
- - - - Цепочки заданий - - - {chains.length === 0 ? ( - Цепочки не найдены. Создайте первое задание, чтобы начать. - ) : ( - - {chains.map((chain: ChallengeChain) => ( - - {chain.name} - - Заданий: {chain.tasks.length} · Последнее обновление:{' '} - {new Date(chain.updatedAt).toLocaleString()} - - - ))} - - )} - - - -
-
- ) -} - diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts deleted file mode 100644 index 02a1170..0000000 --- a/src/pages/admin/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { AdminDashboard } from './AdminDashboard' - -export default AdminDashboard - diff --git a/src/pages/index.ts b/src/pages/index.ts index d359903..0bc85fb 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,4 +1,3 @@ import { lazy } from 'react' -export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main')) -export const AdminPage = lazy(() => import(/* webpackChunkName: 'admin' */'./admin')) \ No newline at end of file +export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main')) \ No newline at end of file diff --git a/src/pages/main/main.tsx b/src/pages/main/main.tsx index d3bf270..2d32790 100644 --- a/src/pages/main/main.tsx +++ b/src/pages/main/main.tsx @@ -1,21 +1,22 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { - Alert, - AlertIndicator, Box, + Button, Flex, Heading, - SimpleGrid, Text, - VStack, } from '@chakra-ui/react' +import { Alert } from '@chakra-ui/react/alert' import type { ChallengeChain, ChallengeTask } from '../../__data__/types' import { useChallenge } from '../../context/ChallengeContext' -import { MobileDashboard, PersonalDashboard, TaskWorkspace } from '../../components/personal' +import { TaskWorkspace } from '../../components/personal' +import { Header } from '../../components/Header' +import { LoginForm } from '../../components/LoginForm' +import { ChainSelector } from '../../components/ChainSelector' export const MainPage = () => { - const { nickname, personalDashboard, chains, eventEmitter } = useChallenge() + const { nickname, eventEmitter } = useChallenge() const [selectedChain, setSelectedChain] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [isOffline, setIsOffline] = useState(() => @@ -24,18 +25,9 @@ export const MainPage = () => { const [notification, setNotification] = useState<{ status: 'success' | 'warning'; title: string; description?: string } | null>(null) const notificationTimeoutRef = useRef(null) - const isTaskSelected = Boolean(selectedChain && selectedTask) - - const pageTitle = useMemo(() => { - if (nickname) { - return `Привет, ${nickname}!` - } - return 'Challenge Platform' - }, [nickname]) - - const handleSelectTask = (task: ChallengeTask, chain: ChallengeChain) => { + const handleSelectChain = (chain: ChallengeChain) => { setSelectedChain(chain) - setSelectedTask(task) + setSelectedTask(chain.tasks[0]) } const handleTaskComplete = () => { @@ -46,21 +38,20 @@ export const MainPage = () => { if (nextTask) { setSelectedTask(nextTask) } else { + // Цепочка завершена, возвращаемся к выбору setSelectedChain(null) setSelectedTask(null) } } - const fallbackTask = useMemo(() => { - if (selectedTask) return selectedTask - if (selectedChain) return selectedChain.tasks[0] - if (chains.length) return chains[0].tasks[0] - return null - }, [chains, selectedChain, selectedTask]) + const handleBackToChains = () => { + setSelectedChain(null) + setSelectedTask(null) + } useEffect(() => { const unsubscribe = eventEmitter.on('submission_completed', (event) => { - const submission = (event.data as any)?.submission + const submission = (event.data as { submission?: { status: string; attemptNumber: number } })?.submission const accepted = submission?.status === 'accepted' const title = accepted ? 'Задание принято' : 'Задание требует доработки' const description = submission ? `Попытка №${submission.attemptNumber}` : undefined @@ -94,65 +85,63 @@ export const MainPage = () => { } }, []) + // Если пользователь не авторизован, показываем форму входа + if (!nickname) { + return + } + + // Если цепочка не выбрана, показываем селектор цепочек + if (!selectedChain) { + return ( + <> +
+ + + ) + } + + // Показываем выбранную цепочку и задания return ( - - - {notification && ( - - - - {notification.title} - {notification.description && {notification.description}} + <> +
+ + + {notification && ( + + + + {notification.title} + {notification.description && {notification.description}} + + + )} + + {isOffline && ( + + + Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи. + + )} + + + + + {selectedChain.name} + + + Задание {selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1} из {selectedChain.tasks.length} + - - )} + + - {isOffline && ( - - - Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи. - - )} - - - - {pageTitle} - - Следите за прогрессом и отправляйте решения в одном месте. + {selectedTask && ( + + )} - - - - - - - - - Рабочее пространство - - - {personalDashboard && isTaskSelected && selectedTask && selectedChain ? ( - - ) : fallbackTask ? ( - - ) : ( - - - Нет доступных заданий. Попросите преподавателя открыть цепочку или попробуйте позже. - - - )} - - - - + + ) } diff --git a/src/utils/authLoopGuard.ts b/src/utils/authLoopGuard.ts deleted file mode 100644 index f4f5ba6..0000000 --- a/src/utils/authLoopGuard.ts +++ /dev/null @@ -1,59 +0,0 @@ -const STORAGE_KEY = 'auth.loop.attempts' -const DEFAULT_WINDOW_MS = 2_000 -const DEFAULT_THRESHOLD = 2 - -const readAttempts = (): number[] => { - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return [] - const parsed = JSON.parse(raw) - if (Array.isArray(parsed)) return parsed.filter((n) => typeof n === 'number') - return [] - } catch { - return [] - } -} - -const writeAttempts = (attempts: number[]) => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(attempts)) - } catch { - // ignore - } -} - -export const recordAuthAttempt = () => { - const now = Date.now() - const attempts = readAttempts() - const updated = [...attempts, now].slice(-10) - writeAttempts(updated) -} - -export const clearAuthAttempts = () => { - try { - localStorage.removeItem(STORAGE_KEY) - } catch { - // ignore - } -} - -export const getRecentAttempts = (windowMs: number = DEFAULT_WINDOW_MS): number[] => { - const now = Date.now() - return readAttempts().filter((ts) => now - ts <= windowMs) -} - -export const isAuthLoopBlocked = ( - windowMs: number = DEFAULT_WINDOW_MS, - threshold: number = DEFAULT_THRESHOLD, -): boolean => { - return getRecentAttempts(windowMs).length >= threshold -} - -export const AUTH_LOOP_GUARD = { - recordAuthAttempt, - clearAuthAttempts, - getRecentAttempts, - isAuthLoopBlocked, -} - - diff --git a/stubs/api/index.js b/stubs/api/index.js index 8766930..48fb8f8 100644 --- a/stubs/api/index.js +++ b/stubs/api/index.js @@ -22,15 +22,16 @@ router.use((req, res, next) => { next() }) -router.post('/auth', (req, res) => { +// Challenge API endpoints +router.post('/challenge/auth', (req, res) => { res.json(readJson('auth.json')) }) -router.get('/chains', (req, res) => { +router.get('/challenge/chains', (req, res) => { res.json(readJson('chains.json')) }) -router.get('/chain/:id', (req, res) => { +router.get('/challenge/chain/:id', (req, res) => { const chains = readJson('chains.json') const chain = chains.find((item) => item.id === req.params.id || item._id === req.params.id) if (!chain) { @@ -39,7 +40,7 @@ router.get('/chain/:id', (req, res) => { return res.json(chain) }) -router.get('/task/:id', (req, res) => { +router.get('/challenge/task/:id', (req, res) => { const chains = readJson('chains.json') const task = chains .flatMap((chain) => chain.tasks || []) @@ -52,12 +53,12 @@ router.get('/task/:id', (req, res) => { return res.json(task) }) -router.post('/submit', (req, res) => { +router.post('/challenge/submit', (req, res) => { const response = readJson('submit.json') res.json(response) }) -router.get('/check-status/:queueId', (req, res) => { +router.get('/challenge/check-status/:queueId', (req, res) => { const statuses = readJson('queue-status.json') const status = statuses[req.params.queueId] @@ -68,7 +69,7 @@ router.get('/check-status/:queueId', (req, res) => { return res.json(status) }) -router.get('/user/:userId/stats', (req, res) => { +router.get('/challenge/user/:userId/stats', (req, res) => { const statsMap = readJson('user-stats.json') const stats = statsMap[req.params.userId] @@ -79,17 +80,17 @@ router.get('/user/:userId/stats', (req, res) => { return res.json(stats) }) -router.get('/user/:userId/submissions', (req, res) => { +router.get('/challenge/user/:userId/submissions', (req, res) => { const submissionsMap = readJson('user-submissions.json') const submissions = submissionsMap[req.params.userId] || [] return res.json(submissions) }) -router.get('/stats', (req, res) => { +router.get('/challenge/stats', (req, res) => { res.json(readJson('system-stats.json')) }) -router.get('/submissions', (req, res) => { +router.get('/challenge/submissions', (req, res) => { res.json(readJson('submissions.json')) })