mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { tokenStorage, isTokenExpired, tokenRefreshManager } from '../utils/token'
|
||||
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
@@ -38,7 +38,7 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
|
||||
} else {
|
||||
// Refresh не удался - редирект на логин
|
||||
tokenStorage.clearTokens()
|
||||
window.location.href = '/login'
|
||||
safeRedirectToLogin()
|
||||
return config
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ apiClient.interceptors.response.use(
|
||||
} else {
|
||||
// Refresh не удался
|
||||
tokenStorage.clearTokens()
|
||||
window.location.href = '/login'
|
||||
safeRedirectToLogin()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Currency exchange rate API
|
||||
// Uses free exchangerate.host API
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
interface ExchangeRates {
|
||||
USD: number
|
||||
CNY: number
|
||||
@@ -33,39 +35,38 @@ export const currencyApi = {
|
||||
|
||||
try {
|
||||
// Try primary API (exchangerate.host) - get RUB based rates
|
||||
const response = await fetch(
|
||||
'https://api.exchangerate.host/latest?base=RUB&symbols=USD,CNY,IRR'
|
||||
)
|
||||
const response = await axios.get<{
|
||||
success?: boolean
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
||||
}>('https://api.exchangerate.host/latest', {
|
||||
params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.success && data.rates) {
|
||||
// API returns how much of each currency equals 1 RUB
|
||||
// We need the inverse: how many RUB equals 1 of each currency
|
||||
const rates: ExchangeRates = {
|
||||
USD: data.rates.USD ? 1 / data.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: data.rates.CNY ? 1 / data.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: data.rates.IRR ? 1 / data.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
if (response.data.success && response.data.rates) {
|
||||
// API returns how much of each currency equals 1 RUB
|
||||
// We need the inverse: how many RUB equals 1 of each currency
|
||||
const rates: ExchangeRates = {
|
||||
USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
}
|
||||
|
||||
// Try backup API (open.er-api.com)
|
||||
const backupResponse = await fetch('https://open.er-api.com/v6/latest/RUB')
|
||||
const backupResponse = await axios.get<{
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
||||
}>('https://open.er-api.com/v6/latest/RUB')
|
||||
|
||||
if (backupResponse.ok) {
|
||||
const backupData = await backupResponse.json()
|
||||
if (backupData.rates) {
|
||||
const rates: ExchangeRates = {
|
||||
USD: backupData.rates.USD ? 1 / backupData.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: backupData.rates.CNY ? 1 / backupData.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: backupData.rates.IRR ? 1 / backupData.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
if (backupResponse.data.rates) {
|
||||
const rates: ExchangeRates = {
|
||||
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
}
|
||||
|
||||
// Return fallback rates if both APIs fail
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export interface MiniappCreatePaymentPayload {
|
||||
import axios, { AxiosError } from 'axios'
|
||||
|
||||
export interface MiniappCreatePaymentPayload {
|
||||
initData: string
|
||||
method: string
|
||||
amountKopeks?: number | null
|
||||
@@ -16,21 +18,26 @@ export const miniappApi = {
|
||||
createPayment: async (
|
||||
payload: MiniappCreatePaymentPayload
|
||||
): Promise<MiniappCreatePaymentResponse> => {
|
||||
const res = await fetch('/miniapp/payments/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
initData: payload.initData || '',
|
||||
method: payload.method,
|
||||
amountKopeks: payload.amountKopeks ?? null,
|
||||
option: payload.option ?? null,
|
||||
}),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message = (data && (data.detail || data.message)) || 'Failed to create payment'
|
||||
throw new Error(String(message))
|
||||
try {
|
||||
const response = await axios.post<MiniappCreatePaymentResponse>(
|
||||
'/miniapp/payments/create',
|
||||
{
|
||||
initData: payload.initData || '',
|
||||
method: payload.method,
|
||||
amountKopeks: payload.amountKopeks ?? null,
|
||||
option: payload.option ?? null,
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ detail?: string; message?: string }>
|
||||
const message = axiosError.response?.data?.detail
|
||||
|| axiosError.response?.data?.message
|
||||
|| 'Failed to create payment'
|
||||
throw new Error(message)
|
||||
}
|
||||
return data as MiniappCreatePaymentResponse
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ticketsApi } from '../api/tickets'
|
||||
import { infoApi } from '../api/info'
|
||||
import { logger } from '../utils/logger'
|
||||
import type { TicketDetail, TicketMessage } from '../types'
|
||||
|
||||
const log = logger.createLogger('Support')
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
@@ -120,7 +123,7 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
|
||||
}
|
||||
|
||||
export default function Support() {
|
||||
console.log('[Support] Component loaded - VERSION 2024-01-16-v3')
|
||||
log.debug('Component loaded')
|
||||
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -271,20 +274,20 @@ export default function Support() {
|
||||
|
||||
// If tickets are disabled, show redirect message
|
||||
if (supportConfig && !supportConfig.tickets_enabled) {
|
||||
console.log('[Support] Tickets disabled, config:', supportConfig)
|
||||
log.debug('Tickets disabled, config:', supportConfig)
|
||||
|
||||
const getSupportMessage = () => {
|
||||
console.log('[Support] Getting support message for type:', supportConfig.support_type)
|
||||
log.debug('Getting support message for type:', supportConfig.support_type)
|
||||
|
||||
if (supportConfig.support_type === 'profile') {
|
||||
const supportUsername = supportConfig.support_username || '@support'
|
||||
console.log('[Support] Opening profile:', supportUsername)
|
||||
log.debug('Opening profile:', supportUsername)
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
console.log('[Support] Button clicked, opening:', supportUsername)
|
||||
log.debug('Button clicked, opening:', supportUsername)
|
||||
const webApp = window.Telegram?.WebApp
|
||||
|
||||
// Extract username without @
|
||||
@@ -292,41 +295,36 @@ export default function Support() {
|
||||
? supportUsername.slice(1)
|
||||
: supportUsername
|
||||
|
||||
// Try different URL formats
|
||||
const telegramUrl = `tg://resolve?domain=${username}`
|
||||
const webUrl = `https://t.me/${username}`
|
||||
|
||||
console.log('[Support] Telegram URL:', telegramUrl)
|
||||
console.log('[Support] Web URL:', webUrl)
|
||||
console.log('[Support] WebApp methods:', {
|
||||
log.debug('Web URL:', webUrl, 'WebApp methods:', {
|
||||
openTelegramLink: !!webApp?.openTelegramLink,
|
||||
openLink: !!webApp?.openLink,
|
||||
})
|
||||
|
||||
// Try openTelegramLink first (for tg:// links)
|
||||
if (webApp?.openTelegramLink) {
|
||||
console.log('[Support] Using openTelegramLink with web URL')
|
||||
log.debug('Using openTelegramLink with web URL')
|
||||
try {
|
||||
webApp.openTelegramLink(webUrl)
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('[Support] openTelegramLink failed:', e)
|
||||
log.error('openTelegramLink failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to openLink
|
||||
if (webApp?.openLink) {
|
||||
console.log('[Support] Using openLink')
|
||||
log.debug('Using openLink')
|
||||
try {
|
||||
webApp.openLink(webUrl)
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('[Support] openLink failed:', e)
|
||||
log.error('openLink failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort - window.open
|
||||
console.log('[Support] Using window.open')
|
||||
log.debug('Using window.open')
|
||||
window.open(webUrl, '_blank')
|
||||
},
|
||||
}
|
||||
@@ -350,13 +348,13 @@ export default function Support() {
|
||||
|
||||
// Fallback: contact support (should not normally happen if config is correct)
|
||||
const supportUsername = supportConfig.support_username || '@support'
|
||||
console.log('[Support] Fallback: Opening profile:', supportUsername)
|
||||
log.debug('Fallback: Opening profile:', supportUsername)
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
console.log('[Support] Fallback button clicked, opening:', supportUsername)
|
||||
log.debug('Fallback button clicked, opening:', supportUsername)
|
||||
const webApp = window.Telegram?.WebApp
|
||||
|
||||
// Extract username without @
|
||||
@@ -365,16 +363,16 @@ export default function Support() {
|
||||
: supportUsername
|
||||
|
||||
const webUrl = `https://t.me/${username}`
|
||||
console.log('[Support] Fallback opening URL:', webUrl)
|
||||
log.debug('Fallback opening URL:', webUrl)
|
||||
|
||||
if (webApp?.openTelegramLink) {
|
||||
console.log('[Support] Fallback using openTelegramLink')
|
||||
log.debug('Fallback using openTelegramLink')
|
||||
webApp.openTelegramLink(webUrl)
|
||||
} else if (webApp?.openLink) {
|
||||
console.log('[Support] Fallback using openLink')
|
||||
log.debug('Fallback using openLink')
|
||||
webApp.openLink(webUrl)
|
||||
} else {
|
||||
console.log('[Support] Fallback using window.open')
|
||||
log.debug('Fallback using window.open')
|
||||
window.open(webUrl, '_blank')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '../types'
|
||||
import { authApi } from '../api/auth'
|
||||
import { apiClient } from '../api/client'
|
||||
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token'
|
||||
|
||||
export interface TelegramWidgetData {
|
||||
@@ -89,15 +90,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ isAdmin: false })
|
||||
return
|
||||
}
|
||||
const response = await fetch('/api/cabinet/auth/me/is-admin', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
set({ isAdmin: data.is_admin })
|
||||
}
|
||||
// Используем apiClient для единообразной обработки ошибок
|
||||
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin')
|
||||
set({ isAdmin: response.data.is_admin })
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to check admin status:', error)
|
||||
set({ isAdmin: false })
|
||||
|
||||
57
src/utils/logger.ts
Normal file
57
src/utils/logger.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Утилита для логирования только в development режиме
|
||||
* В production логи не выводятся
|
||||
*/
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
export const logger = {
|
||||
log: (...args: unknown[]): void => {
|
||||
if (isDev) {
|
||||
console.log(...args)
|
||||
}
|
||||
},
|
||||
|
||||
warn: (...args: unknown[]): void => {
|
||||
if (isDev) {
|
||||
console.warn(...args)
|
||||
}
|
||||
},
|
||||
|
||||
error: (...args: unknown[]): void => {
|
||||
// Ошибки логируем всегда (важно для отладки в production)
|
||||
console.error(...args)
|
||||
},
|
||||
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (isDev) {
|
||||
console.debug(...args)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Создаёт логгер с префиксом для конкретного модуля
|
||||
*/
|
||||
createLogger: (prefix: string) => ({
|
||||
log: (...args: unknown[]): void => {
|
||||
if (isDev) {
|
||||
console.log(`[${prefix}]`, ...args)
|
||||
}
|
||||
},
|
||||
warn: (...args: unknown[]): void => {
|
||||
if (isDev) {
|
||||
console.warn(`[${prefix}]`, ...args)
|
||||
}
|
||||
},
|
||||
error: (...args: unknown[]): void => {
|
||||
console.error(`[${prefix}]`, ...args)
|
||||
},
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (isDev) {
|
||||
console.debug(`[${prefix}]`, ...args)
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export default logger
|
||||
@@ -2,6 +2,8 @@
|
||||
* Утилиты для безопасной работы с JWT токенами
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const TOKEN_KEYS = {
|
||||
ACCESS: 'access_token',
|
||||
REFRESH: 'refresh_token',
|
||||
@@ -194,18 +196,14 @@ class TokenRefreshManager {
|
||||
|
||||
private async doRefresh(refreshToken: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(this.refreshEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
// Используем чистый axios (не apiClient) чтобы избежать циклической зависимости
|
||||
const response = await axios.post<{ access_token?: string }>(
|
||||
this.refreshEndpoint,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Refresh failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const newAccessToken = data.access_token
|
||||
const newAccessToken = response.data.access_token
|
||||
|
||||
if (newAccessToken) {
|
||||
tokenStorage.setAccessToken(newAccessToken)
|
||||
@@ -253,3 +251,40 @@ class TokenRefreshManager {
|
||||
}
|
||||
|
||||
export const tokenRefreshManager = new TokenRefreshManager()
|
||||
|
||||
/**
|
||||
* Безопасный редирект на страницу логина
|
||||
* Валидирует URL чтобы предотвратить open redirect уязвимость
|
||||
*/
|
||||
export function safeRedirectToLogin(): void {
|
||||
// Разрешённые пути для редиректа (относительные пути нашего приложения)
|
||||
const loginPath = '/login'
|
||||
|
||||
// Проверяем, что мы на том же origin
|
||||
if (typeof window !== 'undefined') {
|
||||
// Используем только относительный путь для безопасности
|
||||
window.location.href = loginPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует URL для редиректа
|
||||
* Возвращает true только для безопасных URL (относительные пути или тот же origin)
|
||||
*/
|
||||
export function isValidRedirectUrl(url: string): boolean {
|
||||
// Пустой URL - небезопасен
|
||||
if (!url) return false
|
||||
|
||||
// Относительные пути всегда безопасны
|
||||
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
// Разрешаем только тот же origin
|
||||
return parsed.origin === window.location.origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user