Remove Keycloak integration and related authentication logic. Update dashboard to exclude admin page and simplify user management. Introduce new components for chain selection, header, and login form. Refactor main page to handle user authentication and task selection more effectively. Update API routes for challenge-related endpoints.
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
Some checks failed
platform/bro-js/challenge-pl/pipeline/head There was a failure building this commit
This commit is contained in:
parent
9b511a8e1e
commit
b2eaaebd7f
@ -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",
|
||||
|
||||
@ -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
|
||||
},
|
||||
}),
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
import Keycloak from 'keycloak-js'
|
||||
|
||||
export const keycloak = new Keycloak({
|
||||
url: KC_URL,
|
||||
realm: KC_REALM,
|
||||
clientId: KC_CLIENT_ID,
|
||||
})
|
||||
@ -1,10 +0,0 @@
|
||||
import { createSlice } from '@reduxjs/toolkit'
|
||||
|
||||
import { UserData } from '../types'
|
||||
|
||||
export const userSlice = createSlice({
|
||||
name: 'user',
|
||||
initialState: null as UserData,
|
||||
reducers: {
|
||||
}
|
||||
})
|
||||
@ -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<string, string[]>;
|
||||
/** Полное имя пользователя */
|
||||
name: string;
|
||||
/** Предпочитаемое имя пользователя */
|
||||
preferred_username: string;
|
||||
/** Имя пользователя */
|
||||
given_name: string;
|
||||
/** Фамилия пользователя */
|
||||
family_name: string;
|
||||
/** Email пользователя */
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Базовый ответ API
|
||||
*/
|
||||
export type BaseResponse<Data> = {
|
||||
/** Успешность операции */
|
||||
success: boolean;
|
||||
/** Данные ответа */
|
||||
body: Data;
|
||||
};
|
||||
|
||||
|
||||
117
src/components/ChainSelector.tsx
Normal file
117
src/components/ChainSelector.tsx
Normal file
@ -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 (
|
||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||
<Box
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
borderColor="gray.200"
|
||||
p={8}
|
||||
maxW="600px"
|
||||
textAlign="center"
|
||||
>
|
||||
<Heading size="md" mb={4}>
|
||||
Нет доступных цепочек
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
Свяжитесь с преподавателем для получения доступа к заданиям.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>
|
||||
Выберите цепочку заданий
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
Выберите цепочку заданий для начала работы. Вы можете проходить несколько цепочек параллельно.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
{chains.map((chain) => {
|
||||
const chainStats = personalDashboard?.activeChains.find(
|
||||
(stat) => stat.chainId === chain.id
|
||||
)
|
||||
const completedTasks = chainStats?.completedTasks ?? 0
|
||||
const progress = chainStats?.progress ?? 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={chain.id}
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
borderColor="gray.200"
|
||||
p={6}
|
||||
transition="all 0.2s"
|
||||
_hover={{ shadow: 'md', borderColor: 'teal.300' }}
|
||||
>
|
||||
<VStack align="stretch" spacing={4} height="full">
|
||||
<Box flex="1">
|
||||
<Heading size="md" mb={3}>
|
||||
{chain.name}
|
||||
</Heading>
|
||||
<Text color="gray.600" fontSize="sm" mb={3}>
|
||||
Заданий в цепочке: {chain.tasks.length}
|
||||
</Text>
|
||||
|
||||
{completedTasks > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.500" mb={1}>
|
||||
Прогресс: {completedTasks} / {chain.tasks.length}
|
||||
</Text>
|
||||
<Box bg="gray.100" borderRadius="full" height="8px" overflow="hidden">
|
||||
<Box
|
||||
bg="teal.500"
|
||||
height="full"
|
||||
width={`${progress}%`}
|
||||
transition="width 0.3s"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
onClick={() => onSelectChain(chain)}
|
||||
colorScheme="teal"
|
||||
width="full"
|
||||
>
|
||||
{completedTasks > 0 ? 'Продолжить' : 'Начать'}
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
29
src/components/Header.tsx
Normal file
29
src/components/Header.tsx
Normal file
@ -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 (
|
||||
<Box bg="white" borderBottomWidth="1px" borderColor="gray.200" py={4} px={{ base: 4, md: 8 }}>
|
||||
<Flex maxW="1200px" mx="auto" justify="space-between" align="center">
|
||||
<Box>
|
||||
<Heading size="md" color="teal.600">
|
||||
Challenge Platform
|
||||
</Heading>
|
||||
<Text fontSize="sm" color="gray.600" mt={1}>
|
||||
{nickname}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button onClick={logout} variant="ghost" size="sm">
|
||||
Выйти
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
99
src/components/LoginForm.tsx
Normal file
99
src/components/LoginForm.tsx
Normal file
@ -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 (
|
||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center" px={4}>
|
||||
<Box
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
borderColor="gray.200"
|
||||
p={8}
|
||||
maxW="480px"
|
||||
w="full"
|
||||
>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" color="teal.600" mb={2}>
|
||||
Challenge Platform
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
Добро пожаловать! Введите ваше ФИО для начала работы
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack spacing={4} align="stretch">
|
||||
<Box>
|
||||
<Text fontWeight="medium" mb={2}>
|
||||
Ваше ФИО
|
||||
</Text>
|
||||
<Input
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder="Иванов Иван Иванович"
|
||||
size="lg"
|
||||
autoFocus
|
||||
isInvalid={!!error}
|
||||
/>
|
||||
{error && (
|
||||
<Text color="red.500" fontSize="sm" mt={2}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="teal"
|
||||
size="lg"
|
||||
isLoading={isAuthLoading}
|
||||
isDisabled={!fullName.trim()}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}) => (
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<NumberInput value={value} min={0} onChange={(_, val) => onChange(Number.isNaN(val) ? 0 : val)}>
|
||||
<NumberInputInput />
|
||||
</NumberInput>
|
||||
{suffix && (
|
||||
<StatHelpText fontSize="xs" color="gray.500">
|
||||
{suffix}
|
||||
</StatHelpText>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const ABTestPanel = () => {
|
||||
const [variantA, setVariantA] = useState<VariantFormState>(createVariantState)
|
||||
const [variantB, setVariantB] = useState<VariantFormState>(createVariantState)
|
||||
const [comparison, setComparison] = useState<ReturnType<typeof compareVariants> | 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 (
|
||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||
<Heading size="sm" mb={4}>
|
||||
A/B тест: сравнение вариантов
|
||||
</Heading>
|
||||
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4} mb={4}>
|
||||
<GridItem>
|
||||
<Heading size="xs" mb={2}>
|
||||
Вариант A
|
||||
</Heading>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<MetricInput
|
||||
label="Submission Rate (%)"
|
||||
value={variantA.submissionRate}
|
||||
onChange={(value) => setVariantA((prev) => ({ ...prev, submissionRate: value }))}
|
||||
suffix="Процент пользователей, отправивших хотя бы одно решение"
|
||||
/>
|
||||
<MetricInput
|
||||
label="Completion Rate (%)"
|
||||
value={variantA.completionRate}
|
||||
onChange={(value) => setVariantA((prev) => ({ ...prev, completionRate: value }))}
|
||||
/>
|
||||
<MetricInput
|
||||
label="Retry Rate (%)"
|
||||
value={variantA.retryRate}
|
||||
onChange={(value) => setVariantA((prev) => ({ ...prev, retryRate: value }))}
|
||||
/>
|
||||
<MetricInput
|
||||
label="Time to First Submission (мин)"
|
||||
value={variantA.timeToFirstSubmission}
|
||||
onChange={(value) => setVariantA((prev) => ({ ...prev, timeToFirstSubmission: value }))}
|
||||
/>
|
||||
<MetricInput
|
||||
label="Session Duration (мин)"
|
||||
value={variantA.sessionDuration}
|
||||
onChange={(value) => setVariantA((prev) => ({ ...prev, sessionDuration: value }))}
|
||||
/>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<Heading size="xs" mb={2}>
|
||||
Вариант B
|
||||
</Heading>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<MetricInput
|
||||
label="Submission Rate (%)"
|
||||
value={variantB.submissionRate}
|
||||
onChange={(value) => setVariantB((prev) => ({ ...prev, submissionRate: value }))}
|
||||
/>
|
||||
<MetricInput
|
||||
label="Completion Rate (%)"
|
||||
value={variantB.completionRate}
|
||||
onChange={(value) => setVariantB((prev) => ({ ...prev, completionRate: value }))}
|
||||
/>
|
||||
<MetricInput
|
||||
label="Retry Rate (%)"
|
||||
value={variantB.retryRate}
|
||||
onChange={(value) => setVariantB((prev) => ({ ...prev, retryRate: value }))}
|
||||
/>
|
||||
<MetricInput
|
||||
label="Time to First Submission (мин)"
|
||||
value={variantB.timeToFirstSubmission}
|
||||
onChange={(value) => setVariantB((prev) => ({ ...prev, timeToFirstSubmission: value }))}
|
||||
/>
|
||||
<MetricInput
|
||||
label="Session Duration (мин)"
|
||||
value={variantB.sessionDuration}
|
||||
onChange={(value) => setVariantB((prev) => ({ ...prev, sessionDuration: value }))}
|
||||
/>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
<Button onClick={handleCompare} colorScheme="teal" isDisabled={!hasData}>
|
||||
Сравнить варианты
|
||||
</Button>
|
||||
|
||||
{comparison && (
|
||||
<Box mt={4} borderWidth="1px" borderRadius="md" borderColor="teal.200" bg="teal.50" p={4}>
|
||||
<Heading size="xs" mb={2}>
|
||||
Результат сравнения
|
||||
</Heading>
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
<Stat>
|
||||
<StatLabel>Δ Submission Rate</StatLabel>
|
||||
<StatValueText>{comparison.submissionRateDiff.toFixed(1)}%</StatValueText>
|
||||
<StatHelpText>Положительное значение — рост у варианта B</StatHelpText>
|
||||
</Stat>
|
||||
<Stat>
|
||||
<StatLabel>Δ Completion Rate</StatLabel>
|
||||
<StatValueText>{comparison.completionRateDiff.toFixed(1)}%</StatValueText>
|
||||
<StatHelpText>Положительное значение — рост у варианта B</StatHelpText>
|
||||
</Stat>
|
||||
</Grid>
|
||||
|
||||
<Stat mt={4}>
|
||||
<StatLabel>Победитель</StatLabel>
|
||||
<StatValueText>Вариант {comparison.winner}</StatValueText>
|
||||
<StatHelpText>Основано на сравнении коэффициента завершения</StatHelpText>
|
||||
</Stat>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) => (
|
||||
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
|
||||
@ -19,16 +19,6 @@ export const Dashboard = () => {
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
{URLs.admin.isOn && (
|
||||
<Route
|
||||
path={URLs.admin.url}
|
||||
element={
|
||||
<PageWrapper>
|
||||
<AdminPage />
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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) => <App {...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(<button onClick={() => keycloak.login()} style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'red',
|
||||
margin: 'auto'
|
||||
}}>Login</button>)
|
||||
|
||||
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)
|
||||
|
||||
@ -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 (
|
||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={8} maxW="480px" textAlign="center">
|
||||
<Heading size="md" mb={4}>
|
||||
Требуется роль преподавателя
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
У вас нет доступа к панели администратора. Обратитесь к администратору Keycloak для назначения роли
|
||||
<Badge ml={2} colorScheme="purple">
|
||||
teacher
|
||||
</Badge>
|
||||
.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading || !systemStats) {
|
||||
return (
|
||||
<Box bg="gray.50" minH="100vh" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text color="gray.500">Загружаем системные метрики...</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
||||
<Heading size="lg">Панель преподавателя</Heading>
|
||||
|
||||
<SimpleGrid minChildWidth="200px" spacing={4}>
|
||||
<StatCard title="Пользователей" value={formatNumber(systemStats.users)} icon="👥" />
|
||||
<StatCard title="Заданий" value={formatNumber(systemStats.tasks)} icon="🧩" />
|
||||
<StatCard title="Цепочек" value={formatNumber(systemStats.chains)} icon="🔗" />
|
||||
<StatCard title="Всего проверок" value={formatNumber(systemStats.submissions.total)} icon="✅" />
|
||||
<StatCard
|
||||
title="В ожидании"
|
||||
value={formatNumber(systemStats.queue.waiting)}
|
||||
icon="⏳"
|
||||
/>
|
||||
<StatCard
|
||||
title="Среднее время проверки"
|
||||
value={`${msToMinutes(systemStats.averageCheckTimeMs)} мин`}
|
||||
icon="⏱️"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||
<Heading size="sm" mb={3}>
|
||||
Статус очереди
|
||||
</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={4}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
Всего в очереди
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{formatNumber(systemStats.queue.queueLength)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
В ожидании
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{formatNumber(systemStats.queue.waiting)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
В обработке
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{formatNumber(systemStats.queue.currentlyProcessing)} / {formatNumber(systemStats.queue.maxConcurrency)}
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||
<Heading size="sm" mb={3}>
|
||||
Проблемные области
|
||||
</Heading>
|
||||
|
||||
{issues.length === 0 ? (
|
||||
<Text color="green.500">Критических проблем не обнаружено.</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{issues.map((issue) => (
|
||||
<Box
|
||||
key={`${issue.type}-${issue.message}`}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
borderColor={issue.severity === 'high' ? 'red.200' : 'yellow.200'}
|
||||
bg={issue.severity === 'high' ? 'red.50' : 'yellow.50'}
|
||||
p={3}
|
||||
>
|
||||
<Text fontWeight="medium">{issue.message}</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Сущность: {issue.affectedEntity} · Важность: {issue.severity}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||
<HStack justify="space-between" mb={4} align="center">
|
||||
<Heading size="sm">Метрики по заданиям</Heading>
|
||||
<Select
|
||||
size="sm"
|
||||
width="200px"
|
||||
value={difficultyFilter}
|
||||
onChange={(event) => setDifficultyFilter(event.target.value as typeof difficultyFilter)}
|
||||
>
|
||||
<option value="all">Все сложности</option>
|
||||
<option value="easy">Лёгкие</option>
|
||||
<option value="medium">Средние</option>
|
||||
<option value="hard">Сложные</option>
|
||||
</Select>
|
||||
</HStack>
|
||||
|
||||
{filteredTaskMetrics.length === 0 ? (
|
||||
<Text color="gray.500">Недостаточно данных о проверках для построения аналитики.</Text>
|
||||
) : (
|
||||
<Box overflowX="auto">
|
||||
<Table size="sm" variant="simple">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableColumnHeader>Задание</TableColumnHeader>
|
||||
<TableColumnHeader textAlign="right">Попыток</TableColumnHeader>
|
||||
<TableColumnHeader textAlign="right">Успешность</TableColumnHeader>
|
||||
<TableColumnHeader textAlign="right">Сред. попыток</TableColumnHeader>
|
||||
<TableColumnHeader textAlign="right">Сред. время (мин)</TableColumnHeader>
|
||||
<TableColumnHeader>Сложность</TableColumnHeader>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredTaskMetrics.map((metric) => (
|
||||
<TableRow key={metric.taskId}>
|
||||
<TableCell>
|
||||
<Text fontWeight="medium">{metric.title}</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
ID: {metric.taskId}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">{formatNumber(metric.attemptsCount)}</TableCell>
|
||||
<TableCell textAlign="right">{formatNumber(Math.round(metric.successRate))}%</TableCell>
|
||||
<TableCell textAlign="right">{formatNumber(Math.round(metric.avgAttempts * 10) / 10)}</TableCell>
|
||||
<TableCell textAlign="right">{formatNumber(Math.round(msToMinutes(metric.avgTimeToComplete * 1000)))}</TableCell>
|
||||
<TableCell>
|
||||
<Badge colorScheme={metric.difficulty === 'hard' ? 'red' : metric.difficulty === 'medium' ? 'yellow' : 'green'}>
|
||||
{metric.difficulty}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box borderWidth="1px" borderRadius="lg" borderColor="gray.200" bg="white" p={4}>
|
||||
<Heading size="sm" mb={3}>
|
||||
Цепочки заданий
|
||||
</Heading>
|
||||
|
||||
{chains.length === 0 ? (
|
||||
<Text color="gray.500">Цепочки не найдены. Создайте первое задание, чтобы начать.</Text>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{chains.map((chain: ChallengeChain) => (
|
||||
<Box key={chain.id} borderWidth="1px" borderRadius="md" borderColor="gray.200" p={3}>
|
||||
<Text fontWeight="medium">{chain.name}</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Заданий: {chain.tasks.length} · Последнее обновление:{' '}
|
||||
{new Date(chain.updatedAt).toLocaleString()}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ABTestPanel />
|
||||
</VStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
import { AdminDashboard } from './AdminDashboard'
|
||||
|
||||
export default AdminDashboard
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { lazy } from 'react'
|
||||
|
||||
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||
export const AdminPage = lazy(() => import(/* webpackChunkName: 'admin' */'./admin'))
|
||||
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||
@ -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<ChallengeChain | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<ChallengeTask | null>(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<number | null>(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 <LoginForm />
|
||||
}
|
||||
|
||||
// Если цепочка не выбрана, показываем селектор цепочек
|
||||
if (!selectedChain) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ChainSelector onSelectChain={handleSelectChain} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Показываем выбранную цепочку и задания
|
||||
return (
|
||||
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||
<VStack align="stretch" spacing={8} maxW="1200px" mx="auto">
|
||||
{notification && (
|
||||
<Alert status={notification.status} borderRadius="md">
|
||||
<AlertIndicator />
|
||||
<Box ml={3}>
|
||||
<Text fontWeight="semibold">{notification.title}</Text>
|
||||
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
|
||||
<>
|
||||
<Header />
|
||||
<Box bg="gray.50" minH="100vh" py={8} px={{ base: 4, md: 8 }}>
|
||||
<Box maxW="1200px" mx="auto">
|
||||
{notification && (
|
||||
<Alert.Root status={notification.status} borderRadius="md" mb={4}>
|
||||
<Alert.Indicator />
|
||||
<Box ml={3}>
|
||||
<Text fontWeight="semibold">{notification.title}</Text>
|
||||
{notification.description && <Text fontSize="sm">{notification.description}</Text>}
|
||||
</Box>
|
||||
</Alert.Root>
|
||||
)}
|
||||
|
||||
{isOffline && (
|
||||
<Alert.Root status="warning" borderRadius="md" mb={4}>
|
||||
<Alert.Indicator />
|
||||
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
|
||||
</Alert.Root>
|
||||
)}
|
||||
|
||||
<Flex justify="space-between" align="center" mb={8}>
|
||||
<Box>
|
||||
<Heading size="lg" mb={1}>
|
||||
{selectedChain.name}
|
||||
</Heading>
|
||||
<Text color="gray.600">
|
||||
Задание {selectedChain.tasks.findIndex(t => t.id === selectedTask?.id) + 1} из {selectedChain.tasks.length}
|
||||
</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
<Button onClick={handleBackToChains} variant="outline">
|
||||
Вернуться к цепочкам
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{isOffline && (
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<AlertIndicator />
|
||||
Вы находитесь офлайн. Черновики сохраняются локально и будут отправлены после восстановления связи.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Heading size="lg" mb={1}>
|
||||
{pageTitle}
|
||||
</Heading>
|
||||
<Text color="gray.600">Следите за прогрессом и отправляйте решения в одном месте.</Text>
|
||||
{selectedTask && (
|
||||
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<MobileDashboard />
|
||||
|
||||
<SimpleGrid columns={{ base: 1, xl: 2 }} spacing={6} alignItems="start">
|
||||
<PersonalDashboard onSelectTask={handleSelectTask} />
|
||||
|
||||
<Box position="sticky" top={8} height="fit-content">
|
||||
<Heading size="md" mb={4}>
|
||||
Рабочее пространство
|
||||
</Heading>
|
||||
|
||||
{personalDashboard && isTaskSelected && selectedTask && selectedChain ? (
|
||||
<TaskWorkspace task={selectedTask} onTaskComplete={handleTaskComplete} />
|
||||
) : fallbackTask ? (
|
||||
<TaskWorkspace task={fallbackTask} onTaskComplete={handleTaskComplete} />
|
||||
) : (
|
||||
<Flex
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
borderColor="gray.200"
|
||||
bg="white"
|
||||
height="260px"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text color="gray.500" textAlign="center" px={6}>
|
||||
Нет доступных заданий. Попросите преподавателя открыть цепочку или попробуйте позже.
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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'))
|
||||
})
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user