From b2eaaebd7f650f3790c1875a8c9d0bf08a030425 Mon Sep 17 00:00:00 2001 From: Primakov Alexandr Alexandrovich Date: Tue, 4 Nov 2025 11:06:34 +0300 Subject: [PATCH] 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. --- package.json | 1 - src/__data__/api/api.ts | 16 -- src/__data__/kc.ts | 7 - src/__data__/slices/user.ts | 10 -- src/__data__/types.ts | 102 ----------- src/components/ChainSelector.tsx | 117 +++++++++++++ src/components/Header.tsx | 29 +++ src/components/LoginForm.tsx | 99 +++++++++++ src/components/admin/ABTestPanel.tsx | 200 --------------------- src/dashboard.tsx | 12 +- src/index.tsx | 38 +--- src/pages/admin/AdminDashboard.tsx | 252 --------------------------- src/pages/admin/index.ts | 4 - src/pages/index.ts | 3 +- src/pages/main/main.tsx | 151 ++++++++-------- src/utils/authLoopGuard.ts | 59 ------- stubs/api/index.js | 21 +-- 17 files changed, 329 insertions(+), 792 deletions(-) delete mode 100644 src/__data__/kc.ts delete mode 100644 src/__data__/slices/user.ts create mode 100644 src/components/ChainSelector.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/LoginForm.tsx delete mode 100644 src/components/admin/ABTestPanel.tsx delete mode 100644 src/pages/admin/AdminDashboard.tsx delete mode 100644 src/pages/admin/index.ts delete mode 100644 src/utils/authLoopGuard.ts 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')) })