mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -6,6 +6,32 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
|||||||
// Настраиваем endpoint для refresh
|
// Настраиваем endpoint для refresh
|
||||||
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`)
|
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`)
|
||||||
|
|
||||||
|
// CSRF token management
|
||||||
|
const CSRF_COOKIE_NAME = 'csrf_token'
|
||||||
|
const CSRF_HEADER_NAME = 'X-CSRF-Token'
|
||||||
|
|
||||||
|
function getCsrfToken(): string | null {
|
||||||
|
if (typeof document === 'undefined') return null
|
||||||
|
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`))
|
||||||
|
return match ? match[2] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCsrfToken(): string {
|
||||||
|
const array = new Uint8Array(32)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCsrfToken(): string {
|
||||||
|
let token = getCsrfToken()
|
||||||
|
if (!token) {
|
||||||
|
token = generateCsrfToken()
|
||||||
|
// Set cookie with SameSite=Strict for CSRF protection
|
||||||
|
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
const getTelegramInitData = (): string | null => {
|
const getTelegramInitData = (): string | null => {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
|
|
||||||
@@ -51,6 +77,13 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
|
|||||||
if (telegramInitData && config.headers) {
|
if (telegramInitData && config.headers) {
|
||||||
config.headers['X-Telegram-Init-Data'] = telegramInitData
|
config.headers['X-Telegram-Init-Data'] = telegramInitData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add CSRF token for state-changing methods
|
||||||
|
const method = config.method?.toUpperCase()
|
||||||
|
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
|
||||||
|
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken()
|
||||||
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -75,28 +75,20 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
|||||||
const starsPaymentMutation = useMutation({
|
const starsPaymentMutation = useMutation({
|
||||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log('[Stars] API response:', data)
|
|
||||||
console.log('[Stars] invoice_url:', data.invoice_url)
|
|
||||||
const webApp = window.Telegram?.WebApp
|
const webApp = window.Telegram?.WebApp
|
||||||
console.log('[Stars] webApp:', webApp)
|
|
||||||
console.log('[Stars] openInvoice available:', !!webApp?.openInvoice)
|
|
||||||
|
|
||||||
if (!data.invoice_url) {
|
if (!data.invoice_url) {
|
||||||
console.error('[Stars] No invoice_url in response!')
|
|
||||||
setError('Сервер не вернул ссылку на оплату')
|
setError('Сервер не вернул ссылку на оплату')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webApp?.openInvoice) {
|
if (!webApp?.openInvoice) {
|
||||||
console.error('[Stars] openInvoice not available - not in Telegram Mini App?')
|
|
||||||
setError('Оплата Stars доступна только в Telegram Mini App')
|
setError('Оплата Stars доступна только в Telegram Mini App')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Stars] Calling openInvoice with:', data.invoice_url)
|
|
||||||
try {
|
try {
|
||||||
webApp.openInvoice(data.invoice_url, (status) => {
|
webApp.openInvoice(data.invoice_url, (status) => {
|
||||||
console.log('[Stars] Invoice callback status:', status)
|
|
||||||
if (status === 'paid') {
|
if (status === 'paid') {
|
||||||
setError(null)
|
setError(null)
|
||||||
onClose()
|
onClose()
|
||||||
@@ -107,12 +99,10 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Stars] openInvoice error:', e)
|
|
||||||
setError('Ошибка открытия окна оплаты: ' + String(e))
|
setError('Ошибка открытия окна оплаты: ' + String(e))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
console.error('[Stars] API error:', error)
|
|
||||||
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
|
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
|
||||||
const detail = axiosError?.response?.data?.detail
|
const detail = axiosError?.response?.data?.detail
|
||||||
const status = axiosError?.response?.status
|
const status = axiosError?.response?.status
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ const appSchemes = [
|
|||||||
|
|
||||||
const COUNTDOWN_SECONDS = 5
|
const COUNTDOWN_SECONDS = 5
|
||||||
|
|
||||||
|
// Validate deep link to prevent javascript: and other dangerous schemes
|
||||||
|
const isValidDeepLink = (url: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
const lowerUrl = url.toLowerCase().trim()
|
||||||
|
// Block dangerous schemes
|
||||||
|
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
|
||||||
|
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Only allow known app schemes
|
||||||
|
return appSchemes.some(app => lowerUrl.startsWith(app.scheme))
|
||||||
|
}
|
||||||
|
|
||||||
export default function DeepLinkRedirect() {
|
export default function DeepLinkRedirect() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -104,13 +117,13 @@ export default function DeepLinkRedirect() {
|
|||||||
|
|
||||||
// Open deep link - same as miniapp, just window.location.href
|
// Open deep link - same as miniapp, just window.location.href
|
||||||
const openDeepLink = useCallback(() => {
|
const openDeepLink = useCallback(() => {
|
||||||
if (!deepLink) return
|
if (!deepLink || !isValidDeepLink(deepLink)) return
|
||||||
window.location.href = deepLink
|
window.location.href = deepLink
|
||||||
}, [deepLink])
|
}, [deepLink])
|
||||||
|
|
||||||
// Countdown timer effect
|
// Countdown timer effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deepLink) {
|
if (!deepLink || !isValidDeepLink(deepLink)) {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { infoApi, FaqPage } from '../api/info'
|
import { infoApi, FaqPage } from '../api/info'
|
||||||
|
|
||||||
const InfoIcon = () => (
|
const InfoIcon = () => (
|
||||||
@@ -41,6 +42,15 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
|||||||
|
|
||||||
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
|
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
|
||||||
|
|
||||||
|
// Sanitize HTML content to prevent XSS
|
||||||
|
const sanitizeHtml = (html: string): string => {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'span', 'div'],
|
||||||
|
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Convert plain text to HTML with proper formatting
|
// Convert plain text to HTML with proper formatting
|
||||||
const formatContent = (content: string): string => {
|
const formatContent = (content: string): string => {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
@@ -49,11 +59,11 @@ const formatContent = (content: string): string => {
|
|||||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
|
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
|
||||||
|
|
||||||
if (hasHtmlTags) {
|
if (hasHtmlTags) {
|
||||||
return content
|
return sanitizeHtml(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert plain text to formatted HTML
|
// Convert plain text to formatted HTML
|
||||||
return content
|
const result = content
|
||||||
.split(/\n\n+/) // Split by double newlines (paragraphs)
|
.split(/\n\n+/) // Split by double newlines (paragraphs)
|
||||||
.map(paragraph => {
|
.map(paragraph => {
|
||||||
// Check if it's a header (starts with # or numeric like "1.")
|
// Check if it's a header (starts with # or numeric like "1.")
|
||||||
@@ -83,6 +93,8 @@ const formatContent = (content: string): string => {
|
|||||||
return `<p>${formattedParagraph}</p>`
|
return `<p>${formattedParagraph}</p>`
|
||||||
})
|
})
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
|
return sanitizeHtml(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Info() {
|
export default function Info() {
|
||||||
|
|||||||
@@ -301,18 +301,20 @@ export default function Subscription() {
|
|||||||
// Auto-scroll to switch tariff modal when it appears
|
// Auto-scroll to switch tariff modal when it appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (switchTariffId && switchModalRef.current) {
|
if (switchTariffId && switchModalRef.current) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 100)
|
}, 100)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [switchTariffId])
|
}, [switchTariffId])
|
||||||
|
|
||||||
// Auto-scroll to tariff purchase form when it appears
|
// Auto-scroll to tariff purchase form when it appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showTariffPurchase && tariffPurchaseRef.current) {
|
if (showTariffPurchase && tariffPurchaseRef.current) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 100)
|
}, 100)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [showTariffPurchase])
|
}, [showTariffPurchase])
|
||||||
|
|
||||||
@@ -320,11 +322,12 @@ export default function Subscription() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { scrollToExtend?: boolean } | null
|
const state = location.state as { scrollToExtend?: boolean } | null
|
||||||
if (state?.scrollToExtend && tariffsCardRef.current) {
|
if (state?.scrollToExtend && tariffsCardRef.current) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 300)
|
}, 300)
|
||||||
// Clear the state to prevent re-scrolling on subsequent renders
|
// Clear the state to prevent re-scrolling on subsequent renders
|
||||||
window.history.replaceState({}, document.title)
|
window.history.replaceState({}, document.title)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
|
|||||||
@@ -32,19 +32,27 @@ export default function TelegramCallback() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse and validate numeric fields
|
||||||
|
const parsedId = parseInt(id, 10)
|
||||||
|
const parsedAuthDate = parseInt(authDate, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) {
|
||||||
|
setError(t('auth.telegramRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loginWithTelegramWidget({
|
await loginWithTelegramWidget({
|
||||||
id: parseInt(id, 10),
|
id: parsedId,
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
last_name: lastName || undefined,
|
last_name: lastName || undefined,
|
||||||
username: username || undefined,
|
username: username || undefined,
|
||||||
photo_url: photoUrl || undefined,
|
photo_url: photoUrl || undefined,
|
||||||
auth_date: parseInt(authDate, 10),
|
auth_date: parsedAuthDate,
|
||||||
hash: hash,
|
hash: hash,
|
||||||
})
|
})
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Telegram auth error:', err)
|
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
setError(error.response?.data?.detail || t('common.error'))
|
setError(error.response?.data?.detail || t('common.error'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,29 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth'
|
||||||
import { brandingApi } from '../api/branding'
|
import { brandingApi } from '../api/branding'
|
||||||
|
|
||||||
|
// Validate redirect URL to prevent open redirect attacks
|
||||||
|
const getSafeRedirectUrl = (url: string | null): string => {
|
||||||
|
if (!url) return '/'
|
||||||
|
// Only allow relative paths starting with /
|
||||||
|
// Block protocol-relative URLs (//evil.com) and absolute URLs
|
||||||
|
if (!url.startsWith('/') || url.startsWith('//')) {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
// Additional check for encoded characters that could bypass validation
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(url)
|
||||||
|
if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3
|
||||||
|
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count'
|
||||||
|
|
||||||
export default function TelegramRedirect() {
|
export default function TelegramRedirect() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -12,6 +35,10 @@ export default function TelegramRedirect() {
|
|||||||
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
|
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
|
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [retryCount, setRetryCount] = useState(() => {
|
||||||
|
const stored = sessionStorage.getItem(RETRY_COUNT_KEY)
|
||||||
|
return stored ? parseInt(stored, 10) : 0
|
||||||
|
})
|
||||||
|
|
||||||
// Get branding for nice display
|
// Get branding for nice display
|
||||||
const { data: branding } = useQuery({
|
const { data: branding } = useQuery({
|
||||||
@@ -24,8 +51,8 @@ export default function TelegramRedirect() {
|
|||||||
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
|
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
|
||||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
|
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
|
||||||
|
|
||||||
// Get redirect target from URL params
|
// Get redirect target from URL params (validated)
|
||||||
const redirectTo = searchParams.get('redirect') || '/'
|
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If already authenticated, redirect immediately
|
// If already authenticated, redirect immediately
|
||||||
@@ -76,13 +103,28 @@ export default function TelegramRedirect() {
|
|||||||
setTimeout(initTelegram, 300)
|
setTimeout(initTelegram, 300)
|
||||||
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
|
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
|
||||||
|
|
||||||
// Handle retry
|
// Handle retry with limit to prevent infinite loops
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
|
if (retryCount >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
setErrorMessage('Превышено количество попыток. Попробуйте позже.')
|
||||||
|
sessionStorage.removeItem(RETRY_COUNT_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newCount = retryCount + 1
|
||||||
|
setRetryCount(newCount)
|
||||||
|
sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount))
|
||||||
setStatus('loading')
|
setStatus('loading')
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear retry count on successful auth
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'success') {
|
||||||
|
sessionStorage.removeItem(RETRY_COUNT_KEY)
|
||||||
|
}
|
||||||
|
}, [status])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ interface AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Блокировка для предотвращения race condition при инициализации
|
// Блокировка для предотвращения race condition при инициализации
|
||||||
let initializePromise: Promise<void> | null = null
|
// Используем объект для атомарности операций
|
||||||
let isInitializing = false
|
const initState = {
|
||||||
|
promise: null as Promise<void> | null,
|
||||||
|
isInitializing: false,
|
||||||
|
isInitialized: false,
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
@@ -69,8 +73,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
logout: () => {
|
logout: () => {
|
||||||
const { refreshToken } = get()
|
const { refreshToken } = get()
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
authApi.logout(refreshToken).catch((error) => {
|
authApi.logout(refreshToken).catch(() => {
|
||||||
console.error('[Auth] Logout API call failed:', error)
|
// Logout API call failed - ignore silently
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
tokenStorage.clearTokens()
|
tokenStorage.clearTokens()
|
||||||
@@ -93,8 +97,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
// Используем apiClient для единообразной обработки ошибок
|
// Используем apiClient для единообразной обработки ошибок
|
||||||
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin')
|
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin')
|
||||||
set({ isAdmin: response.data.is_admin })
|
set({ isAdmin: response.data.is_admin })
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('[Auth] Failed to check admin status:', error)
|
|
||||||
set({ isAdmin: false })
|
set({ isAdmin: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -103,19 +106,24 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
try {
|
try {
|
||||||
const user = await authApi.getMe()
|
const user = await authApi.getMe()
|
||||||
set({ user })
|
set({ user })
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('[Auth] Failed to refresh user:', error)
|
// Failed to refresh user - ignore silently
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
// Защита от race condition - если уже идёт инициализация, ждём её завершения
|
// Защита от race condition - если уже инициализировано, выходим
|
||||||
if (isInitializing && initializePromise) {
|
if (initState.isInitialized) {
|
||||||
return initializePromise
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isInitializing = true
|
// Если уже идёт инициализация, ждём её завершения
|
||||||
initializePromise = (async () => {
|
if (initState.isInitializing && initState.promise) {
|
||||||
|
return initState.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
initState.isInitializing = true
|
||||||
|
initState.promise = (async () => {
|
||||||
try {
|
try {
|
||||||
set({ isLoading: true })
|
set({ isLoading: true })
|
||||||
|
|
||||||
@@ -167,8 +175,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
})
|
})
|
||||||
get().checkAdminStatus()
|
get().checkAdminStatus()
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('[Auth] getMe failed, trying refresh:', error)
|
|
||||||
// Token might be invalid on server, try to refresh
|
// Token might be invalid on server, try to refresh
|
||||||
const newToken = await tokenRefreshManager.refreshAccessToken()
|
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
@@ -182,8 +189,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
})
|
})
|
||||||
get().checkAdminStatus()
|
get().checkAdminStatus()
|
||||||
} catch (userError) {
|
} catch {
|
||||||
console.error('[Auth] getMe failed after refresh:', userError)
|
|
||||||
tokenStorage.clearTokens()
|
tokenStorage.clearTokens()
|
||||||
set({
|
set({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
@@ -206,12 +212,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isInitializing = false
|
initState.isInitializing = false
|
||||||
initializePromise = null
|
initState.isInitialized = true
|
||||||
|
initState.promise = null
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return initializePromise
|
return initState.promise
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegram: async (initData) => {
|
loginWithTelegram: async (initData) => {
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ class TokenRefreshManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('[TokenRefreshManager] Refresh failed:', error)
|
// Token refresh failed - don't log sensitive error details
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user