diff --git a/mnemo_cards_admin/src/App.tsx b/mnemo_cards_admin/src/App.tsx index 2c0d0e0..6e3a86e 100644 --- a/mnemo_cards_admin/src/App.tsx +++ b/mnemo_cards_admin/src/App.tsx @@ -7,37 +7,40 @@ import PacksPage from '@/pages/PacksPage' import UsersPage from '@/pages/UsersPage' import TestsPage from '@/pages/TestsPage' import Layout from '@/components/layout/Layout' +import { TokenRefreshProvider } from '@/components/TokenRefreshProvider' function App() { const { isAuthenticated } = useAuthStore() return ( -
- - : } - /> - - - } /> - } /> - } /> - } /> - } /> - - - ) : ( - - ) - } - /> - -
+ +
+ + : } + /> + + + } /> + } /> + } /> + } /> + } /> + + + ) : ( + + ) + } + /> + +
+
) } diff --git a/mnemo_cards_admin/src/api/auth.ts b/mnemo_cards_admin/src/api/auth.ts index d2c664b..77d5aa6 100644 --- a/mnemo_cards_admin/src/api/auth.ts +++ b/mnemo_cards_admin/src/api/auth.ts @@ -1,5 +1,5 @@ import { adminApiClient } from './client' -import type { AuthResponse, RequestCodeResponse, CodeStatusResponse } from '@/types/models' +import type { AuthResponse, RequestCodeResponse, CodeStatusResponse, RefreshTokenResponse } from '@/types/models' import type { AxiosError } from 'axios' export const authApi = { @@ -46,4 +46,12 @@ export const authApi = { const response = await adminApiClient.get('/api/v2/admin/auth/me') return response.data }, + + // Refresh access token using refresh token + refreshToken: async (refreshToken: string): Promise => { + const response = await adminApiClient.post('/api/v2/admin/auth/refresh', { + refreshToken, + }) + return response.data + }, } diff --git a/mnemo_cards_admin/src/api/client.ts b/mnemo_cards_admin/src/api/client.ts index 695c722..716be78 100644 --- a/mnemo_cards_admin/src/api/client.ts +++ b/mnemo_cards_admin/src/api/client.ts @@ -1,5 +1,6 @@ import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios' import { useAuthStore } from '@/stores/authStore' +import { authApi } from './auth' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api.mnemo-cards.online' @@ -59,22 +60,122 @@ const addAuthToken = (config: InternalAxiosRequestConfig) => { // Flag to prevent multiple logout calls let isLoggingOut = false +// Flag to prevent multiple refresh calls +let isRefreshing = false +let refreshPromise: Promise | null = null + +// Function to refresh token +const refreshAccessToken = async (): Promise => { + if (refreshPromise) { + return refreshPromise + } + + refreshPromise = (async () => { + try { + const refreshToken = localStorage.getItem('admin_refresh_token') + if (!refreshToken) { + throw new Error('No refresh token available') + } + + const response = await authApi.refreshToken(refreshToken) + if (!response.success || !response.token) { + throw new Error('Failed to refresh token') + } + + // Update store with new tokens + useAuthStore.getState().updateToken( + response.token, + response.refreshToken, + response.expiresIn + ) + + return response.token + } catch (error) { + console.error('[API Client] Token refresh failed:', error) + // If refresh fails, logout + useAuthStore.getState().logout() + throw error + } finally { + refreshPromise = null + } + })() + + return refreshPromise +} + +// Request interceptor to check and refresh token if needed +const checkAndRefreshToken = async (config: InternalAxiosRequestConfig) => { + const isPublicAuthEndpoint = + config.url?.includes('/admin/auth/request-code') || + config.url?.includes('/admin/auth/verify-code') || + config.url?.includes('/admin/auth/code-status') || + config.url?.includes('/admin/auth/refresh') + + // Skip token refresh for public endpoints + if (isPublicAuthEndpoint) { + return config + } + + // Check if token needs refresh + if (useAuthStore.getState().shouldRefreshToken() && !isRefreshing) { + isRefreshing = true + try { + const newToken = await refreshAccessToken() + // Update token in config + if (config.headers) { + config.headers['Authorization'] = `Bearer ${newToken}` + } + } catch (error) { + console.error('[API Client] Failed to refresh token before request:', error) + // Will be handled by response interceptor + } finally { + isRefreshing = false + } + } + + return config +} // Response interceptor for error handling -const handleAuthError = (error: unknown) => { +const handleAuthError = async (error: unknown) => { const axiosError = error as AxiosError + const originalRequest = axiosError.config as InternalAxiosRequestConfig & { _retry?: boolean } + if (axiosError.response?.status === 401) { // Check if we're already on login page const currentPath = window.location.pathname const isPublicAuthEndpoint = - axiosError.config?.url?.includes('/admin/auth/request-code') || - axiosError.config?.url?.includes('/admin/auth/verify-code') || - axiosError.config?.url?.includes('/admin/auth/code-status') + originalRequest?.url?.includes('/admin/auth/request-code') || + originalRequest?.url?.includes('/admin/auth/verify-code') || + originalRequest?.url?.includes('/admin/auth/code-status') || + originalRequest?.url?.includes('/admin/auth/refresh') // Don't logout for public auth endpoints or if already on login page if (currentPath === '/login' || isPublicAuthEndpoint) { return Promise.reject(error) } + + // Try to refresh token if we haven't retried yet + if (!originalRequest._retry) { + originalRequest._retry = true + + const refreshToken = localStorage.getItem('admin_refresh_token') + if (refreshToken) { + try { + const newToken = await refreshAccessToken() + + // Retry original request with new token + if (originalRequest.headers) { + originalRequest.headers['Authorization'] = `Bearer ${newToken}` + } + + return adminApiClient(originalRequest) + } catch (refreshError) { + console.error('[API Client] Token refresh failed, logging out:', refreshError) + // Refresh failed, proceed to logout + } + } + } // Prevent multiple logout calls if (isLoggingOut) { @@ -86,6 +187,7 @@ const handleAuthError = (error: unknown) => { // Remove token and update auth store // React Router will automatically redirect to /login when isAuthenticated becomes false localStorage.removeItem('admin_token') + localStorage.removeItem('admin_refresh_token') useAuthStore.getState().logout() // Reset flag after a short delay to allow React Router to handle redirect @@ -97,8 +199,10 @@ const handleAuthError = (error: unknown) => { } // Apply interceptors to both clients +apiClient.interceptors.request.use(checkAndRefreshToken) apiClient.interceptors.request.use(addAuthToken) apiClient.interceptors.response.use((response) => response, handleAuthError) +adminApiClient.interceptors.request.use(checkAndRefreshToken) adminApiClient.interceptors.request.use(addAuthToken) adminApiClient.interceptors.response.use((response) => response, handleAuthError) diff --git a/mnemo_cards_admin/src/components/JSONQuestionEditor.tsx b/mnemo_cards_admin/src/components/JSONQuestionEditor.tsx new file mode 100644 index 0000000..b886bae --- /dev/null +++ b/mnemo_cards_admin/src/components/JSONQuestionEditor.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect } from 'react' +import type { Question } from '@/types/questions' +import { questionToJson, questionFromJson } from '@/types/questions' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Button } from '@/components/ui/button' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertCircle } from 'lucide-react' + +interface JSONQuestionEditorProps { + question: Partial | null + onChange: (question: Partial | null, isValid: boolean) => void +} + +export function JSONQuestionEditor({ + question, + onChange, +}: JSONQuestionEditorProps) { + const [jsonText, setJsonText] = useState('') + const [error, setError] = useState(null) + const [isValid, setIsValid] = useState(false) + + // Инициализация JSON из вопроса + useEffect(() => { + if (question) { + try { + const json = questionToJson(question as Question) + setJsonText(JSON.stringify(json, null, 2)) + setError(null) + setIsValid(true) + } catch (e) { + setError(`Failed to serialize question: ${e}`) + setIsValid(false) + } + } else { + setJsonText('') + setError(null) + setIsValid(false) + } + }, [question]) + + // Валидация и парсинг JSON + const handleJsonChange = (value: string) => { + setJsonText(value) + + if (!value.trim()) { + setError(null) + setIsValid(false) + onChange(null, false) + return + } + + try { + const parsed = JSON.parse(value) + + // Базовая валидация структуры + if (!parsed.questionType) { + throw new Error('Missing required field: questionType') + } + if (!parsed.word) { + throw new Error('Missing required field: word') + } + if (!parsed.answer) { + throw new Error('Missing required field: answer') + } + if (!parsed.buttons || !Array.isArray(parsed.buttons)) { + throw new Error('Missing or invalid field: buttons (must be an array)') + } + + // Проверка что answer существует в buttons + const buttonIds = parsed.buttons.map((b: any) => b.id) + if (!buttonIds.includes(parsed.answer)) { + throw new Error( + `Answer "${parsed.answer}" not found in buttons. Available IDs: ${buttonIds.join(', ')}`, + ) + } + + // Проверка для input_buttons + if (parsed.questionType === 'input_buttons' && !parsed.template) { + throw new Error('Missing required field for input_buttons: template') + } + + // Парсинг в Question объект + const questionObj = questionFromJson(parsed) + setError(null) + setIsValid(true) + onChange(questionObj, true) + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Invalid JSON' + setError(errorMessage) + setIsValid(false) + onChange(null, false) + } + } + + // Форматирование JSON + const formatJson = () => { + try { + const parsed = JSON.parse(jsonText) + setJsonText(JSON.stringify(parsed, null, 2)) + handleJsonChange(JSON.stringify(parsed, null, 2)) + } catch (e) { + // Если невалидный JSON, просто показываем ошибку + } + } + + return ( +
+
+ + +
+ +