fix: resolve telegram auth token expiration and clean up codebase

- Fix stale initData comparison in clearStaleSessionIfNeeded that destroyed
  valid refresh tokens on mobile WebView reopens
- Restrict X-Telegram-Init-Data header to auth endpoints only
- Close Mini App on auth retry to force fresh initData from Telegram
- Merge Connection page error/not-configured states for better UX
- Remove unnecessary comments across 40+ files (section dividers,
  restating comments, noise catch block comments)
- Configure ESLint allowEmptyCatch to properly handle intentional
  empty catch blocks (62 warnings resolved)
This commit is contained in:
c0mrade
2026-03-13 17:50:49 +03:00
parent 682b6b70dc
commit 2dab25c5a0
43 changed files with 72 additions and 647 deletions

View File

@@ -31,7 +31,7 @@ export default tseslint.config(
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-empty': 'warn', 'no-empty': ['warn', { allowEmptyCatch: true }],
'no-eval': 'error', 'no-eval': 'error',
'no-implied-eval': 'error', 'no-implied-eval': 'error',
'no-new-func': 'error', 'no-new-func': 'error',

View File

@@ -39,9 +39,7 @@ function TelegramBackButton() {
} else { } else {
showBackButton(); showBackButton();
} }
} catch { } catch {}
// Back button not mounted
}
}, [location]); }, [location]);
// Stable handler — ref prevents re-subscription on every render // Stable handler — ref prevents re-subscription on every render
@@ -52,15 +50,11 @@ function TelegramBackButton() {
useEffect(() => { useEffect(() => {
try { try {
onBackButtonClick(handler); onBackButtonClick(handler);
} catch { } catch {}
// Back button not mounted
}
return () => { return () => {
try { try {
offBackButtonClick(handler); offBackButtonClick(handler);
} catch { } catch {}
// Back button not mounted
}
}; };
}, [handler]); }, [handler]);

View File

@@ -147,8 +147,6 @@ export const adminApi = {
}, },
}; };
// ============ Dashboard Stats Types ============
export interface NodeStatus { export interface NodeStatus {
uuid: string; uuid: string;
name: string; name: string;
@@ -239,8 +237,6 @@ export interface DashboardStats {
tariff_stats?: TariffStats; tariff_stats?: TariffStats;
} }
// ============ Extended Stats Types ============
export interface TopReferrerItem { export interface TopReferrerItem {
user_id: number; user_id: number;
telegram_id: number | null; telegram_id: number | null;
@@ -318,8 +314,6 @@ export interface SystemInfo {
subscriptions_active: number; subscriptions_active: number;
} }
// ============ Dashboard Stats API ============
export const statsApi = { export const statsApi = {
// Get system info // Get system info
getSystemInfo: async (): Promise<SystemInfo> => { getSystemInfo: async (): Promise<SystemInfo> => {

View File

@@ -1,7 +1,5 @@
import { apiClient } from './client'; import { apiClient } from './client';
// ============ Types ============
// Status & Connection // Status & Connection
export interface ConnectionStatus { export interface ConnectionStatus {
status: string; status: string;
@@ -224,8 +222,6 @@ export interface SyncResponse {
data?: Record<string, unknown>; data?: Record<string, unknown>;
} }
// ============ API ============
export const adminRemnawaveApi = { export const adminRemnawaveApi = {
// Status & Connection // Status & Connection
getStatus: async (): Promise<RemnaWaveStatusResponse> => { getStatus: async (): Promise<RemnaWaveStatusResponse> => {

View File

@@ -1,7 +1,5 @@
import apiClient from './client'; import apiClient from './client';
// ============ Types ============
export interface ReleaseItem { export interface ReleaseItem {
tag_name: string; tag_name: string;
name: string; name: string;
@@ -22,8 +20,6 @@ export interface ReleasesResponse {
cabinet: ProjectReleasesInfo; cabinet: ProjectReleasesInfo;
} }
// ============ API ============
export const adminUpdatesApi = { export const adminUpdatesApi = {
getReleases: async (): Promise<ReleasesResponse> => { getReleases: async (): Promise<ReleasesResponse> => {
const response = await apiClient.get('/cabinet/admin/updates/releases'); const response = await apiClient.get('/cabinet/admin/updates/releases');

View File

@@ -1,7 +1,5 @@
import apiClient from './client'; import apiClient from './client';
// ============ Types ============
export interface TrafficPurchaseInfo { export interface TrafficPurchaseInfo {
id: number; id: number;
traffic_gb: number; traffic_gb: number;
@@ -381,8 +379,6 @@ export interface AdminUserGiftsResponse {
received_total: number; received_total: number;
} }
// ============ API ============
export const adminUsersApi = { export const adminUsersApi = {
// List users // List users
getUsers: async ( getUsers: async (

View File

@@ -13,7 +13,6 @@ import type {
} from '../types'; } from '../types';
export const authApi = { export const authApi = {
// Telegram WebApp authentication
loginTelegram: async ( loginTelegram: async (
initData: string, initData: string,
campaignSlug?: string | null, campaignSlug?: string | null,
@@ -27,7 +26,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Telegram Login Widget authentication
loginTelegramWidget: async ( loginTelegramWidget: async (
data: { data: {
id: number; id: number;
@@ -49,7 +47,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Telegram OIDC authentication (popup flow with id_token)
loginTelegramOIDC: async ( loginTelegramOIDC: async (
idToken: string, idToken: string,
campaignSlug?: string | null, campaignSlug?: string | null,
@@ -63,7 +60,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Email login
loginEmail: async ( loginEmail: async (
email: string, email: string,
password: string, password: string,
@@ -79,7 +75,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Register email (link to existing Telegram account)
registerEmail: async ( registerEmail: async (
email: string, email: string,
password: string, password: string,
@@ -91,8 +86,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Register standalone email account (no Telegram required)
// Returns message - user must verify email before login
registerEmailStandalone: async (data: { registerEmailStandalone: async (data: {
email: string; email: string;
password: string; password: string;
@@ -107,7 +100,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Verify email and get auth tokens
verifyEmail: async (token: string, campaignSlug?: string | null): Promise<AuthResponse> => { verifyEmail: async (token: string, campaignSlug?: string | null): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', {
token, token,
@@ -116,13 +108,11 @@ export const authApi = {
return response.data; return response.data;
}, },
// Resend verification email
resendVerification: async (): Promise<{ message: string }> => { resendVerification: async (): Promise<{ message: string }> => {
const response = await apiClient.post('/cabinet/auth/email/resend'); const response = await apiClient.post('/cabinet/auth/email/resend');
return response.data; return response.data;
}, },
// Refresh token
refreshToken: async (refreshToken: string): Promise<TokenResponse> => { refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', { const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
refresh_token: refreshToken, refresh_token: refreshToken,
@@ -130,20 +120,17 @@ export const authApi = {
return response.data; return response.data;
}, },
// Logout
logout: async (refreshToken: string): Promise<void> => { logout: async (refreshToken: string): Promise<void> => {
await apiClient.post('/cabinet/auth/logout', { await apiClient.post('/cabinet/auth/logout', {
refresh_token: refreshToken, refresh_token: refreshToken,
}); });
}, },
// Forgot password
forgotPassword: async (email: string): Promise<{ message: string }> => { forgotPassword: async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post('/cabinet/auth/password/forgot', { email }); const response = await apiClient.post('/cabinet/auth/password/forgot', { email });
return response.data; return response.data;
}, },
// Reset password
resetPassword: async (token: string, password: string): Promise<{ message: string }> => { resetPassword: async (token: string, password: string): Promise<{ message: string }> => {
const response = await apiClient.post('/cabinet/auth/password/reset', { const response = await apiClient.post('/cabinet/auth/password/reset', {
token, token,
@@ -152,13 +139,11 @@ export const authApi = {
return response.data; return response.data;
}, },
// Get current user
getMe: async (): Promise<User> => { getMe: async (): Promise<User> => {
const response = await apiClient.get<User>('/cabinet/auth/me'); const response = await apiClient.get<User>('/cabinet/auth/me');
return response.data; return response.data;
}, },
// Request email change - sends verification code to new email
requestEmailChange: async ( requestEmailChange: async (
newEmail: string, newEmail: string,
): Promise<{ message: string; new_email: string; expires_in_minutes: number }> => { ): Promise<{ message: string; new_email: string; expires_in_minutes: number }> => {
@@ -168,7 +153,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Verify email change with code
verifyEmailChange: async (code: string): Promise<{ message: string; email: string }> => { verifyEmailChange: async (code: string): Promise<{ message: string; email: string }> => {
const response = await apiClient.post('/cabinet/auth/email/change/verify', { const response = await apiClient.post('/cabinet/auth/email/change/verify', {
code, code,
@@ -176,7 +160,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// OAuth: get enabled providers
getOAuthProviders: async (): Promise<{ providers: OAuthProvider[] }> => { getOAuthProviders: async (): Promise<{ providers: OAuthProvider[] }> => {
const response = await apiClient.get<{ providers: OAuthProvider[] }>( const response = await apiClient.get<{ providers: OAuthProvider[] }>(
'/cabinet/auth/oauth/providers', '/cabinet/auth/oauth/providers',
@@ -184,7 +167,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// OAuth: get authorization URL
getOAuthAuthorizeUrl: async ( getOAuthAuthorizeUrl: async (
provider: string, provider: string,
): Promise<{ authorize_url: string; state: string }> => { ): Promise<{ authorize_url: string; state: string }> => {
@@ -194,7 +176,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// OAuth: callback (exchange code for tokens)
oauthCallback: async ( oauthCallback: async (
provider: string, provider: string,
code: string, code: string,
@@ -216,7 +197,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Account linking
getLinkedProviders: async (): Promise<LinkedProvidersResponse> => { getLinkedProviders: async (): Promise<LinkedProvidersResponse> => {
const response = await apiClient.get<LinkedProvidersResponse>( const response = await apiClient.get<LinkedProvidersResponse>(
'/cabinet/auth/account/linked-providers', '/cabinet/auth/account/linked-providers',
@@ -248,7 +228,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Link Telegram account (Mini App initData, OIDC id_token, or Login Widget data)
linkTelegram: async ( linkTelegram: async (
data: data:
| { init_data: string } | { init_data: string }
@@ -270,8 +249,6 @@ export const authApi = {
return response.data; return response.data;
}, },
// Server-side OAuth linking completion (no JWT needed — auth via state token)
// Used when OAuth opens in external browser from Telegram Mini App
linkServerComplete: async ( linkServerComplete: async (
code: string, code: string,
state: string, state: string,
@@ -295,13 +272,11 @@ export const authApi = {
return response.data; return response.data;
}, },
// Auto-login from guest purchase success page
autoLogin: async (token: string): Promise<AuthResponse> => { autoLogin: async (token: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/login/auto', { token }); const response = await apiClient.post<AuthResponse>('/cabinet/auth/login/auto', { token });
return response.data; return response.data;
}, },
// Account merge (no JWT required)
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => { getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
const response = await apiClient.get<MergePreviewResponse>( const response = await apiClient.get<MergePreviewResponse>(
`/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`, `/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`,

View File

@@ -88,9 +88,7 @@ export const getCachedBranding = (): BrandingInfo | null => {
export const setCachedBranding = (branding: BrandingInfo) => { export const setCachedBranding = (branding: BrandingInfo) => {
try { try {
sessionStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding)); sessionStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding));
} catch { } catch {}
// sessionStorage not available
}
}; };
// Preload logo image as blob to hide backend URL // Preload logo image as blob to hide backend URL

View File

@@ -11,10 +11,8 @@ import { API } from '../config/constants';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
// Настраиваем 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_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token'; const CSRF_HEADER_NAME = 'X-CSRF-Token';
@@ -34,7 +32,6 @@ function ensureCsrfToken(): string {
let token = getCsrfToken(); let token = getCsrfToken();
if (!token) { if (!token) {
token = generateCsrfToken(); token = generateCsrfToken();
// Set cookie with SameSite=Strict for CSRF protection
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`; document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`;
} }
return token; return token;
@@ -49,9 +46,7 @@ const getTelegramInitData = (): string | null => {
tokenStorage.setTelegramInitData(raw); tokenStorage.setTelegramInitData(raw);
return raw; return raw;
} }
} catch { } catch {}
// Not in Telegram or SDK not initialized
}
return tokenStorage.getTelegramInitData(); return tokenStorage.getTelegramInitData();
}; };
@@ -64,7 +59,6 @@ export const apiClient = axios.create({
}, },
}); });
// Auth endpoints that don't need Bearer token or token refresh
const AUTH_ENDPOINTS = [ const AUTH_ENDPOINTS = [
'/cabinet/auth/telegram', '/cabinet/auth/telegram',
'/cabinet/auth/telegram/widget', '/cabinet/auth/telegram/widget',
@@ -85,15 +79,11 @@ function isAuthEndpoint(url: string | undefined): boolean {
return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint)); return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint));
} }
// Request interceptor - add auth token with expiration check
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
// Skip token refresh and Bearer header for auth endpoints
// These endpoints authenticate via init_data/credentials, not Bearer tokens
if (!isAuthEndpoint(config.url)) { if (!isAuthEndpoint(config.url)) {
let token = tokenStorage.getAccessToken(); let token = tokenStorage.getAccessToken();
if (token && isTokenExpired(token)) { if (token && isTokenExpired(token)) {
// Access token expired — try refresh
const newToken = await tokenRefreshManager.refreshAccessToken(); const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
token = newToken; token = newToken;
@@ -103,7 +93,6 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
return config; return config;
} }
} else if (!token && tokenStorage.getRefreshToken()) { } else if (!token && tokenStorage.getRefreshToken()) {
// No access token (e.g. tab reopen) but refresh token exists — restore session
const newToken = await tokenRefreshManager.refreshAccessToken(); const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
token = newToken; token = newToken;
@@ -115,12 +104,16 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
} }
} }
const isTelegramAuthEndpoint =
config.url?.startsWith('/cabinet/auth/telegram') ||
config.url?.startsWith('/cabinet/auth/account/link/telegram');
if (isTelegramAuthEndpoint) {
const telegramInitData = getTelegramInitData(); const telegramInitData = getTelegramInitData();
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(); const method = config.method?.toUpperCase();
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) { if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken(); config.headers[CSRF_HEADER_NAME] = ensureCsrfToken();
@@ -129,7 +122,6 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
return config; return config;
}); });
// Custom error types for special handling
export interface MaintenanceError { export interface MaintenanceError {
code: 'maintenance'; code: 'maintenance';
message: string; message: string;
@@ -180,13 +172,11 @@ export function isBlacklistedError(
return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted'; return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted';
} }
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle maintenance mode (503)
if (isMaintenanceError(error)) { if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail; const detail = (error.response?.data as { detail: MaintenanceError }).detail;
useBlockingStore.getState().setMaintenance({ useBlockingStore.getState().setMaintenance({
@@ -196,7 +186,6 @@ apiClient.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
// Handle channel subscription required (403)
if (isChannelSubscriptionError(error)) { if (isChannelSubscriptionError(error)) {
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail; const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
useBlockingStore.getState().setChannelSubscription({ useBlockingStore.getState().setChannelSubscription({
@@ -207,7 +196,6 @@ apiClient.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
// Handle blacklisted user (403)
if (isBlacklistedError(error)) { if (isBlacklistedError(error)) {
const detail = (error.response?.data as { detail: BlacklistedError }).detail; const detail = (error.response?.data as { detail: BlacklistedError }).detail;
useBlockingStore.getState().setBlacklisted({ useBlockingStore.getState().setBlacklisted({
@@ -216,14 +204,10 @@ apiClient.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента
const requestUrl = originalRequest.url || ''; const requestUrl = originalRequest.url || '';
const isLoginEndpoint = isAuthEndpoint(requestUrl);
if (isLoginEndpoint) { if (isAuthEndpoint(requestUrl)) {
// Пробрасываем ошибку в компонент для показа сообщения пользователю
return Promise.reject(error); return Promise.reject(error);
} }
@@ -236,7 +220,6 @@ apiClient.interceptors.response.use(
} }
return apiClient(originalRequest); return apiClient(originalRequest);
} else { } else {
// Refresh не удался
tokenStorage.clearTokens(); tokenStorage.clearTokens();
safeRedirectToLogin(); safeRedirectToLogin();
} }

View File

@@ -1,10 +1,6 @@
import apiClient from './client'; import apiClient from './client';
import type { AnimationConfig } from '@/components/ui/backgrounds/types'; import type { AnimationConfig } from '@/components/ui/backgrounds/types';
// ============================================================
// Public types
// ============================================================
export interface LandingFeature { export interface LandingFeature {
icon: string; icon: string;
title: string; title: string;
@@ -130,10 +126,6 @@ export interface PurchaseStatus {
bot_link: string | null; bot_link: string | null;
} }
// ============================================================
// Locale helpers
// ============================================================
/** Locale dict for multi-language text fields (admin API) */ /** Locale dict for multi-language text fields (admin API) */
export type LocaleDict = Record<string, string>; export type LocaleDict = Record<string, string>;
@@ -148,10 +140,6 @@ export const LOCALE_META: Record<SupportedLocale, { flag: string; name: string;
fa: { flag: '\u{1F1EE}\u{1F1F7}', name: 'FA', rtl: true }, fa: { flag: '\u{1F1EE}\u{1F1F7}', name: 'FA', rtl: true },
}; };
// ============================================================
// Admin types
// ============================================================
/** Admin feature type with localized title/description */ /** Admin feature type with localized title/description */
export interface AdminLandingFeature { export interface AdminLandingFeature {
icon: string; icon: string;
@@ -232,12 +220,7 @@ export interface LandingCreateRequest {
export type LandingUpdateRequest = Partial<LandingCreateRequest>; export type LandingUpdateRequest = Partial<LandingCreateRequest>;
/** /** Extract best display string from a LocaleDict: ru -> en -> first available -> '' */
* Normalize a value that might be a plain string (old API) or a LocaleDict.
* If it's a string, wraps it as `{ ru: value }`.
* If null/undefined, returns the fallback.
*/
/** Extract best display string from a LocaleDict: ru → en → first available → '' */
export function resolveLocaleDisplay(dict: LocaleDict | string | null | undefined): string { export function resolveLocaleDisplay(dict: LocaleDict | string | null | undefined): string {
if (!dict) return ''; if (!dict) return '';
if (typeof dict === 'string') return dict; if (typeof dict === 'string') return dict;
@@ -253,10 +236,6 @@ export function toLocaleDict(
return value; return value;
} }
// ============================================================
// Public API
// ============================================================
export const landingApi = { export const landingApi = {
getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => { getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
const params = lang ? `?lang=${lang}` : ''; const params = lang ? `?lang=${lang}` : '';
@@ -280,10 +259,6 @@ export const landingApi = {
}, },
}; };
// ============================================================
// Admin stats types
// ============================================================
export interface LandingDailyStat { export interface LandingDailyStat {
date: string; date: string;
purchases: number; purchases: number;
@@ -311,10 +286,6 @@ export interface LandingStatsResponse {
tariff_stats: LandingTariffStat[]; tariff_stats: LandingTariffStat[];
} }
// ============================================================
// Admin purchase list types
// ============================================================
export type PurchaseItemStatus = export type PurchaseItemStatus =
| 'pending' | 'pending'
| 'paid' | 'paid'
@@ -346,10 +317,6 @@ export interface LandingPurchaseListResponse {
total: number; total: number;
} }
// ============================================================
// Admin API
// ============================================================
export const adminLandingsApi = { export const adminLandingsApi = {
list: async (): Promise<LandingListItem[]> => { list: async (): Promise<LandingListItem[]> => {
const response = await apiClient.get('/cabinet/admin/landings'); const response = await apiClient.get('/cabinet/admin/landings');

View File

@@ -1,7 +1,5 @@
import apiClient from './client'; import apiClient from './client';
// === Types ===
export interface AdminRole { export interface AdminRole {
id: number; id: number;
name: string; name: string;
@@ -144,8 +142,6 @@ export interface PermissionSection {
actions: string[]; actions: string[];
} }
// === API ===
const BASE = '/cabinet/admin/rbac'; const BASE = '/cabinet/admin/rbac';
export const rbacApi = { export const rbacApi = {

View File

@@ -12,19 +12,16 @@ import type {
} from '../types'; } from '../types';
export const subscriptionApi = { export const subscriptionApi = {
// Get current subscription status
getSubscription: async (): Promise<SubscriptionStatusResponse> => { getSubscription: async (): Promise<SubscriptionStatusResponse> => {
const response = await apiClient.get<SubscriptionStatusResponse>('/cabinet/subscription'); const response = await apiClient.get<SubscriptionStatusResponse>('/cabinet/subscription');
return response.data; return response.data;
}, },
// Get renewal options
getRenewalOptions: async (): Promise<RenewalOption[]> => { getRenewalOptions: async (): Promise<RenewalOption[]> => {
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options'); const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
return response.data; return response.data;
}, },
// Renew subscription
renewSubscription: async ( renewSubscription: async (
periodDays: number, periodDays: number,
): Promise<{ ): Promise<{
@@ -38,7 +35,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Get traffic packages
getTrafficPackages: async (): Promise<TrafficPackage[]> => { getTrafficPackages: async (): Promise<TrafficPackage[]> => {
const response = await apiClient.get<TrafficPackage[]>( const response = await apiClient.get<TrafficPackage[]>(
'/cabinet/subscription/traffic-packages', '/cabinet/subscription/traffic-packages',
@@ -46,7 +42,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Purchase traffic
purchaseTraffic: async ( purchaseTraffic: async (
gb: number, gb: number,
): Promise<{ ): Promise<{
@@ -58,7 +53,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Purchase devices
purchaseDevices: async ( purchaseDevices: async (
devices: number, devices: number,
): Promise<{ ): Promise<{
@@ -75,7 +69,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Get device purchase price
getDevicePrice: async ( getDevicePrice: async (
devices: number = 1, devices: number = 1,
): Promise<{ ): Promise<{
@@ -103,12 +96,10 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Save devices cart for later purchase after top-up
saveDevicesCart: async (devices: number): Promise<void> => { saveDevicesCart: async (devices: number): Promise<void> => {
await apiClient.post('/cabinet/subscription/devices/save-cart', { devices }); await apiClient.post('/cabinet/subscription/devices/save-cart', { devices });
}, },
// Get device reduction info (for reducing device limit)
getDeviceReductionInfo: async (): Promise<{ getDeviceReductionInfo: async (): Promise<{
available: boolean; available: boolean;
reason?: string; reason?: string;
@@ -121,7 +112,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Reduce device limit
reduceDevices: async ( reduceDevices: async (
newDeviceLimit: number, newDeviceLimit: number,
): Promise<{ ): Promise<{
@@ -136,12 +126,10 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Save traffic cart for later purchase after top-up
saveTrafficCart: async (trafficGb: number): Promise<void> => { saveTrafficCart: async (trafficGb: number): Promise<void> => {
await apiClient.post('/cabinet/subscription/traffic/save-cart', { gb: trafficGb }); await apiClient.post('/cabinet/subscription/traffic/save-cart', { gb: trafficGb });
}, },
// Update autopay settings
updateAutopay: async ( updateAutopay: async (
enabled: boolean, enabled: boolean,
daysBefore?: number, daysBefore?: number,
@@ -157,25 +145,21 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Get trial info
getTrialInfo: async (): Promise<TrialInfo> => { getTrialInfo: async (): Promise<TrialInfo> => {
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial'); const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
return response.data; return response.data;
}, },
// Activate trial
activateTrial: async (): Promise<Subscription> => { activateTrial: async (): Promise<Subscription> => {
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial'); const response = await apiClient.post<Subscription>('/cabinet/subscription/trial');
return response.data; return response.data;
}, },
// Get purchase options (periods, servers, traffic, devices)
getPurchaseOptions: async (): Promise<PurchaseOptions> => { getPurchaseOptions: async (): Promise<PurchaseOptions> => {
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options'); const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
return response.data; return response.data;
}, },
// Preview purchase price
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => { previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
const response = await apiClient.post<PurchasePreview>( const response = await apiClient.post<PurchasePreview>(
'/cabinet/subscription/purchase-preview', '/cabinet/subscription/purchase-preview',
@@ -186,7 +170,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Submit purchase
submitPurchase: async ( submitPurchase: async (
selection: PurchaseSelection, selection: PurchaseSelection,
): Promise<{ ): Promise<{
@@ -201,7 +184,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Purchase tariff (for tariffs mode)
purchaseTariff: async ( purchaseTariff: async (
tariffId: number, tariffId: number,
periodDays: number, periodDays: number,
@@ -223,13 +205,11 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Get app config for connection
getAppConfig: async (): Promise<AppConfig> => { getAppConfig: async (): Promise<AppConfig> => {
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config'); const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config');
return response.data; return response.data;
}, },
// Get available countries/servers
getCountries: async (): Promise<{ getCountries: async (): Promise<{
countries: Array<{ countries: Array<{
uuid: string; uuid: string;
@@ -253,7 +233,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Update countries/servers
updateCountries: async ( updateCountries: async (
countries: string[], countries: string[],
): Promise<{ ): Promise<{
@@ -267,7 +246,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Get connection link and instructions
getConnectionLink: async (): Promise<{ getConnectionLink: async (): Promise<{
subscription_url: string | null; subscription_url: string | null;
display_link: string | null; display_link: string | null;
@@ -283,7 +261,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Get hApp download links
getHappDownloads: async (): Promise<{ getHappDownloads: async (): Promise<{
platforms: Record< platforms: Record<
string, string,
@@ -299,9 +276,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ============ Device Management ============
// Get connected devices
getDevices: async (): Promise<{ getDevices: async (): Promise<{
devices: Array<{ devices: Array<{
hwid: string; hwid: string;
@@ -316,7 +290,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Delete a specific device
deleteDevice: async ( deleteDevice: async (
hwid: string, hwid: string,
): Promise<{ ): Promise<{
@@ -330,7 +303,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Delete all devices
deleteAllDevices: async (): Promise<{ deleteAllDevices: async (): Promise<{
success: boolean; success: boolean;
message: string; message: string;
@@ -340,9 +312,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ============ Tariff Switch ============
// Preview tariff switch cost
previewTariffSwitch: async ( previewTariffSwitch: async (
tariffId: number, tariffId: number,
): Promise<{ ): Promise<{
@@ -372,7 +341,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// Switch to a different tariff
switchTariff: async ( switchTariff: async (
tariffId: number, tariffId: number,
): Promise<{ ): Promise<{
@@ -393,9 +361,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ============ Subscription Pause (Daily Tariffs) ============
// Toggle pause/resume for daily subscription
togglePause: async (): Promise<{ togglePause: async (): Promise<{
success: boolean; success: boolean;
message: string; message: string;
@@ -407,9 +372,6 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ============ Traffic Switch ============
// Switch to a different traffic package
switchTraffic: async ( switchTraffic: async (
gb: number, gb: number,
): Promise<{ ): Promise<{

View File

@@ -1,7 +1,5 @@
import apiClient from './client'; import apiClient from './client';
// ==================== TYPES ====================
export interface WheelPrize { export interface WheelPrize {
id: number; id: number;
display_name: string; display_name: string;
@@ -79,7 +77,6 @@ export interface StarsInvoiceResponse {
stars_amount: number; stars_amount: number;
} }
// Admin types
export interface WheelPrizeAdmin { export interface WheelPrizeAdmin {
id: number; id: number;
config_id: number; config_id: number;
@@ -99,7 +96,6 @@ export interface WheelPrizeAdmin {
updated_at: string | null; updated_at: string | null;
} }
// Type for creating a new prize (excludes id, config_id which are auto-generated)
export interface CreateWheelPrizeData { export interface CreateWheelPrizeData {
prize_type: string; prize_type: string;
prize_value: number; prize_value: number;
@@ -180,22 +176,17 @@ export interface AdminSpinsResponse {
pages: number; pages: number;
} }
// ==================== USER API ====================
export const wheelApi = { export const wheelApi = {
// Get wheel config
getConfig: async (): Promise<WheelConfig> => { getConfig: async (): Promise<WheelConfig> => {
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config'); const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config');
return response.data; return response.data;
}, },
// Check spin availability
checkAvailability: async (): Promise<SpinAvailability> => { checkAvailability: async (): Promise<SpinAvailability> => {
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability'); const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability');
return response.data; return response.data;
}, },
// Spin the wheel
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => { spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', { const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
payment_type: paymentType, payment_type: paymentType,
@@ -203,7 +194,6 @@ export const wheelApi = {
return response.data; return response.data;
}, },
// Get spin history
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => { getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', { const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
params: { page, per_page: perPage }, params: { page, per_page: perPage },
@@ -211,41 +201,33 @@ export const wheelApi = {
return response.data; return response.data;
}, },
// Create Stars invoice for Mini App payment
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => { createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice'); const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice');
return response.data; return response.data;
}, },
}; };
// ==================== ADMIN API ====================
export const adminWheelApi = { export const adminWheelApi = {
// Get full config
getConfig: async (): Promise<AdminWheelConfig> => { getConfig: async (): Promise<AdminWheelConfig> => {
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config'); const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config');
return response.data; return response.data;
}, },
// Update config
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => { updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data); const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data);
return response.data; return response.data;
}, },
// Get prizes
getPrizes: async (): Promise<WheelPrizeAdmin[]> => { getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes'); const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes');
return response.data; return response.data;
}, },
// Create prize
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => { createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data); const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data);
return response.data; return response.data;
}, },
// Update prize
updatePrize: async ( updatePrize: async (
prizeId: number, prizeId: number,
data: Partial<WheelPrizeAdmin>, data: Partial<WheelPrizeAdmin>,
@@ -257,17 +239,14 @@ export const adminWheelApi = {
return response.data; return response.data;
}, },
// Delete prize
deletePrize: async (prizeId: number): Promise<void> => { deletePrize: async (prizeId: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`); await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`);
}, },
// Reorder prizes
reorderPrizes: async (prizeIds: number[]): Promise<void> => { reorderPrizes: async (prizeIds: number[]): Promise<void> => {
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds }); await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds });
}, },
// Get statistics
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => { getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', { const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', {
params: { date_from: dateFrom, date_to: dateTo }, params: { date_from: dateFrom, date_to: dateTo },
@@ -275,7 +254,6 @@ export const adminWheelApi = {
return response.data; return response.data;
}, },
// Get all spins
getSpins: async (params?: { getSpins: async (params?: {
user_id?: number; user_id?: number;
date_from?: string; date_from?: string;

View File

@@ -80,8 +80,6 @@ const XCircleIcon = () => (
</svg> </svg>
); );
// ------- Main Component -------
interface PromoOffersSectionProps { interface PromoOffersSectionProps {
className?: string; className?: string;
} }

View File

@@ -108,7 +108,6 @@ export default function SuccessNotificationModal() {
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]); }, [isOpen, handleClose]);
// Haptic feedback on open
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
haptic.notification('success'); haptic.notification('success');

View File

@@ -30,8 +30,6 @@ import {
import { Toggle } from './Toggle'; import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify'; import { useNotify } from '../../platform/hooks/useNotify';
// ============ Icons ============
const GripIcon = () => ( const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path <path
@@ -110,8 +108,6 @@ const ArrowDownIcon = () => (
</svg> </svg>
); );
// ============ Helpers ============
function generateId(): string { function generateId(): string {
return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
} }
@@ -126,8 +122,6 @@ function configsEqual(a: MenuConfig, b: MenuConfig): boolean {
const DEFAULT_CONFIG: MenuConfig = { rows: [] }; const DEFAULT_CONFIG: MenuConfig = { rows: [] };
// ============ MaxPerRowSelector ============
interface MaxPerRowSelectorProps { interface MaxPerRowSelectorProps {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
@@ -153,8 +147,6 @@ function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) {
); );
} }
// ============ ButtonChip ============
interface ButtonChipProps { interface ButtonChipProps {
button: MenuButtonConfig; button: MenuButtonConfig;
isExpanded: boolean; isExpanded: boolean;
@@ -358,8 +350,6 @@ function ButtonChip({
); );
} }
// ============ SortableRow ============
interface SortableRowProps { interface SortableRowProps {
row: MenuRowConfig; row: MenuRowConfig;
rowIndex: number; rowIndex: number;
@@ -471,8 +461,6 @@ function SortableRow({
); );
} }
// ============ InlineAddPanel ============
interface InlineAddPanelProps { interface InlineAddPanelProps {
rowId: string; rowId: string;
usedBuiltinIds: Set<string>; usedBuiltinIds: Set<string>;
@@ -540,8 +528,6 @@ function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: In
); );
} }
// ============ MenuEditorTab ============
export function MenuEditorTab() { export function MenuEditorTab() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -66,9 +66,7 @@ if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none')
function setCachedConfig(config: AnimationConfig) { function setCachedConfig(config: AnimationConfig) {
try { try {
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config)); localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
} catch { } catch {}
// localStorage not available
}
} }
export function setCachedAnimationConfig(config: AnimationConfig) { export function setCachedAnimationConfig(config: AnimationConfig) {

View File

@@ -62,8 +62,6 @@ export default function InstallationGuide({
const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null); const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null); const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null);
// --- Helpers ---
const getLocalizedText = useCallback( const getLocalizedText = useCallback(
(text: LocalizedText | undefined): string => { (text: LocalizedText | undefined): string => {
if (!text) return ''; if (!text) return '';
@@ -96,8 +94,6 @@ export default function InstallationGuide({
[appConfig.svgLibrary], [appConfig.svgLibrary],
); );
// --- Available platforms ---
const availablePlatforms = useMemo(() => { const availablePlatforms = useMemo(() => {
if (!appConfig.platforms) return []; if (!appConfig.platforms) return [];
const available = platformOrder.filter((key) => { const available = platformOrder.filter((key) => {
@@ -110,8 +106,6 @@ export default function InstallationGuide({
return available; return available;
}, [appConfig.platforms, detectedPlatform]); }, [appConfig.platforms, detectedPlatform]);
// --- Auto-select platform & app ---
useEffect(() => { useEffect(() => {
if (selectedApp || !availablePlatforms.length) return; if (selectedApp || !availablePlatforms.length) return;
const platform = availablePlatforms[0]; const platform = availablePlatforms[0];
@@ -124,8 +118,6 @@ export default function InstallationGuide({
} }
}, [appConfig.platforms, availablePlatforms, selectedApp]); }, [appConfig.platforms, availablePlatforms, selectedApp]);
// --- Button renderer (delegates to BlockButtons component) ---
const renderBlockButtons = useCallback( const renderBlockButtons = useCallback(
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => ( (buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => (
<BlockButtons <BlockButtons
@@ -153,8 +145,6 @@ export default function InstallationGuide({
], ],
); );
// --- Current platform data ---
const currentPlatformKey = activePlatformKey || availablePlatforms[0]; const currentPlatformKey = activePlatformKey || availablePlatforms[0];
const currentPlatformData = currentPlatformKey const currentPlatformData = currentPlatformKey
? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined) ? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined)

View File

@@ -1,4 +1,3 @@
// Storage keys
export const STORAGE_KEYS = { export const STORAGE_KEYS = {
THEME: 'cabinet-theme', THEME: 'cabinet-theme',
ENABLED_THEMES: 'cabinet-enabled-themes', ENABLED_THEMES: 'cabinet-enabled-themes',

View File

@@ -18,9 +18,6 @@ import {
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'; const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
/**
* Get cached fullscreen setting
*/
export const getCachedFullscreenEnabled = (): boolean => { export const getCachedFullscreenEnabled = (): boolean => {
try { try {
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'; return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
@@ -29,18 +26,12 @@ export const getCachedFullscreenEnabled = (): boolean => {
} }
}; };
/**
* Set cached fullscreen setting
*/
export const setCachedFullscreenEnabled = (enabled: boolean) => { export const setCachedFullscreenEnabled = (enabled: boolean) => {
try { try {
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled)); localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
} catch { } catch {}
// localStorage not available
}
}; };
// Cached detection result (evaluated once at module load)
let _isInTelegram: boolean | null = null; let _isInTelegram: boolean | null = null;
function detectTelegram(): boolean { function detectTelegram(): boolean {
if (_isInTelegram === null) { if (_isInTelegram === null) {
@@ -54,16 +45,10 @@ function detectTelegram(): boolean {
return _isInTelegram; return _isInTelegram;
} }
/**
* Check if we're actually running inside Telegram Mini App
*/
export function isInTelegramWebApp(): boolean { export function isInTelegramWebApp(): boolean {
return detectTelegram(); return detectTelegram();
} }
/**
* Check if running on mobile Telegram client (iOS/Android)
*/
export function isTelegramMobile(): boolean { export function isTelegramMobile(): boolean {
try { try {
const { tgWebAppPlatform } = retrieveLaunchParams(); const { tgWebAppPlatform } = retrieveLaunchParams();
@@ -73,9 +58,6 @@ export function isTelegramMobile(): boolean {
} }
} }
/**
* Get Telegram init data for authentication
*/
export function getTelegramInitData(): string | null { export function getTelegramInitData(): string | null {
try { try {
return retrieveRawInitData() || null; return retrieveRawInitData() || null;
@@ -84,9 +66,6 @@ export function getTelegramInitData(): string | null {
} }
} }
/**
* Type for platform values
*/
export type TelegramPlatform = export type TelegramPlatform =
| 'android' | 'android'
| 'ios' | 'ios'
@@ -98,13 +77,8 @@ export type TelegramPlatform =
| 'unknown' | 'unknown'
| undefined; | undefined;
// Default values
const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 }; const defaultInsets = { top: 0, bottom: 0, left: 0, right: 0 };
/**
* Hook for Telegram WebApp integration
* Uses @telegram-apps/sdk-react v3 signals
*/
export function useTelegramSDK() { export function useTelegramSDK() {
const inTelegram = detectTelegram(); const inTelegram = detectTelegram();
@@ -156,18 +130,14 @@ export function useTelegramSDK() {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
sdkRequestFullscreen(); sdkRequestFullscreen();
} catch { } catch {}
// Not supported
}
}, [inTelegram]); }, [inTelegram]);
const exitFullscreen = useCallback(() => { const exitFullscreen = useCallback(() => {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
sdkExitFullscreen(); sdkExitFullscreen();
} catch { } catch {}
// Not supported
}
}, [inTelegram]); }, [inTelegram]);
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
@@ -182,27 +152,21 @@ export function useTelegramSDK() {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
expandViewport(); expandViewport();
} catch { } catch {}
// Not supported
}
}, [inTelegram]); }, [inTelegram]);
const disableVerticalSwipes = useCallback(() => { const disableVerticalSwipes = useCallback(() => {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
sdkDisableVerticalSwipes(); sdkDisableVerticalSwipes();
} catch { } catch {}
// Not supported
}
}, [inTelegram]); }, [inTelegram]);
const enableVerticalSwipes = useCallback(() => { const enableVerticalSwipes = useCallback(() => {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
sdkEnableVerticalSwipes(); sdkEnableVerticalSwipes();
} catch { } catch {}
// Not supported
}
}, [inTelegram]); }, [inTelegram]);
const isFullscreenSupported = inTelegram; const isFullscreenSupported = inTelegram;

View File

@@ -64,7 +64,6 @@ export function applyThemeColors(colors: ThemeColors): void {
const warningPalette = generatePalette(colors.warning); const warningPalette = generatePalette(colors.warning);
const errorPalette = generatePalette(colors.error); const errorPalette = generatePalette(colors.error);
// === DARK THEME PALETTE ===
// Convert hex colors to RGB // Convert hex colors to RGB
const darkBgRgb = hexToRgb(colors.darkBackground); const darkBgRgb = hexToRgb(colors.darkBackground);
const darkSurfaceRgb = hexToRgb(colors.darkSurface); const darkSurfaceRgb = hexToRgb(colors.darkSurface);
@@ -104,7 +103,6 @@ export function applyThemeColors(colors: ThemeColors): void {
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7)); root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b)); root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
// === LIGHT THEME PALETTE ===
const lightBgRgb = hexToRgb(colors.lightBackground); const lightBgRgb = hexToRgb(colors.lightBackground);
const lightSurfaceRgb = hexToRgb(colors.lightSurface); const lightSurfaceRgb = hexToRgb(colors.lightSurface);
const lightTextRgb = hexToRgb(colors.lightText); const lightTextRgb = hexToRgb(colors.lightText);
@@ -149,7 +147,6 @@ export function applyThemeColors(colors: ThemeColors): void {
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b), rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
); );
// === STATUS COLOR PALETTES ===
for (const shade of SHADE_LEVELS) { for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]); root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]); root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);

View File

@@ -38,40 +38,29 @@ if (!alreadyInitialized) {
init(); init();
restoreInitData(); restoreInitData();
// Сбрасываем старые токены если init data изменился (новая сессия Telegram)
clearStaleSessionIfNeeded(retrieveRawInitData() || null); clearStaleSessionIfNeeded(retrieveRawInitData() || null);
// Mount components — each in its own try/catch so one failure doesn't block others // Each mount in its own try/catch so one failure doesn't block others.
// Note: mountMiniApp() internally mounts themeParams in SDK v3, // mountMiniApp() internally mounts themeParams in SDK v3,
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError // so we don't call mountThemeParams() separately to avoid ConcurrentCallError.
try { try {
mountMiniApp(); mountMiniApp();
} catch { } catch {}
/* already mounted */
}
try { try {
bindThemeParamsCssVars(); bindThemeParamsCssVars();
} catch { } catch {}
/* theme params not yet available */
}
try { try {
mountSwipeBehavior(); mountSwipeBehavior();
disableVerticalSwipes(); disableVerticalSwipes();
} catch { } catch {}
/* already mounted */
}
try { try {
mountClosingBehavior(); mountClosingBehavior();
disableClosingConfirmation(); disableClosingConfirmation();
} catch { } catch {}
/* already mounted */
}
try { try {
mountBackButton(); mountBackButton();
} catch { } catch {}
/* already mounted */ // Viewport must be mounted before requesting fullscreen
}
// Viewport — async, fullscreen зависит от смонтированного viewport
mountViewport() mountViewport()
.then(() => { .then(() => {
bindViewportCssVars(); bindViewportCssVars();
@@ -87,12 +76,9 @@ if (!alreadyInitialized) {
.catch(() => {}); .catch(() => {});
miniAppReady(); miniAppReady();
} catch { } catch {}
// Not in Telegram — ok
}
} }
// Preload logo from cache — defer to idle time so it doesn't compete with LCP
if ('requestIdleCallback' in window) { if ('requestIdleCallback' in window) {
requestIdleCallback(() => initLogoPreload()); requestIdleCallback(() => initLogoPreload());
} else { } else {

View File

@@ -23,8 +23,6 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import type { PaymentMethodConfig } from '../types'; import type { PaymentMethodConfig } from '../types';
// ============ Icons ============
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
className="h-5 w-5 text-dark-400" className="h-5 w-5 text-dark-400"
@@ -63,8 +61,6 @@ const SaveIcon = () => (
</svg> </svg>
); );
// ============ Sortable Card ============
interface SortableCardProps { interface SortableCardProps {
config: PaymentMethodConfig; config: PaymentMethodConfig;
onClick: () => void; onClick: () => void;
@@ -184,10 +180,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
); );
} }
// ============ Toast ============
// ============ Main Page ============
export default function AdminPaymentMethods() { export default function AdminPaymentMethods() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -6,8 +6,6 @@ import { rbacApi, AccessPolicy, AdminRole } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate'; import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePlatform } from '@/platform/hooks/usePlatform'; import { usePlatform } from '@/platform/hooks/usePlatform';
// === Icons ===
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
className="h-5 w-5 text-dark-400" className="h-5 w-5 text-dark-400"
@@ -96,8 +94,6 @@ const CalendarIcon = () => (
</svg> </svg>
); );
// === Helpers ===
interface PolicyConditions { interface PolicyConditions {
time_range?: { start: string; end: string }; time_range?: { start: string; end: string };
ip_whitelist?: string[]; ip_whitelist?: string[];
@@ -130,8 +126,6 @@ function parseConditions(raw: Record<string, unknown>): PolicyConditions {
return result; return result;
} }
// === Sub-components ===
interface EffectBadgeProps { interface EffectBadgeProps {
effect: 'allow' | 'deny'; effect: 'allow' | 'deny';
className?: string; className?: string;
@@ -154,8 +148,6 @@ function EffectBadge({ effect, className }: EffectBadgeProps) {
); );
} }
// === Main Page ===
export default function AdminPolicies() { export default function AdminPolicies() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -23,8 +23,6 @@ import {
RemnawaveIcon, RemnawaveIcon,
} from '../components/icons'; } from '../components/icons';
// ============ Icons ============
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
className="h-5 w-5 text-dark-400" className="h-5 w-5 text-dark-400"
@@ -37,8 +35,6 @@ const BackIcon = () => (
</svg> </svg>
); );
// ============ Helpers ============
const formatBytes = (bytes: number): string => { const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
@@ -100,8 +96,6 @@ const getCountryFlag = (code: string | null | undefined): string => {
return codeMap[code.toUpperCase()] || code; return codeMap[code.toUpperCase()] || code;
}; };
// ============ Sub-Components ============
interface StatCardProps { interface StatCardProps {
label: string; label: string;
value: string | number; value: string | number;
@@ -333,8 +327,6 @@ function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncC
); );
} }
// ============ Tab Components ============
interface OverviewTabProps { interface OverviewTabProps {
stats: SystemStatsResponse | undefined; stats: SystemStatsResponse | undefined;
isLoading: boolean; isLoading: boolean;
@@ -873,8 +865,6 @@ function SyncTab({
); );
} }
// ============ Main Component ============
type TabType = 'overview' | 'nodes' | 'squads' | 'sync'; type TabType = 'overview' | 'nodes' | 'squads' | 'sync';
export default function AdminRemnawave() { export default function AdminRemnawave() {

View File

@@ -7,8 +7,6 @@ import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePermissionStore } from '@/store/permissions'; import { usePermissionStore } from '@/store/permissions';
import { usePlatform } from '@/platform/hooks/usePlatform'; import { usePlatform } from '@/platform/hooks/usePlatform';
// === Icons ===
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
className="h-5 w-5 text-dark-400" className="h-5 w-5 text-dark-400"
@@ -57,8 +55,6 @@ const ShieldIcon = () => (
</svg> </svg>
); );
// === Main Page ===
export default function AdminRoles() { export default function AdminRoles() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -433,7 +433,6 @@ export default function AdminUserDetail() {
const data = await adminUsersApi.getReferrals(userId, 0, 50); const data = await adminUsersApi.getReferrals(userId, 0, 50);
setReferrals(data.users); setReferrals(data.users);
} catch { } catch {
// ignore
} finally { } finally {
setReferralsLoading(false); setReferralsLoading(false);
} }
@@ -446,7 +445,6 @@ export default function AdminUserDetail() {
const data = await adminUsersApi.getPanelInfo(userId); const data = await adminUsersApi.getPanelInfo(userId);
setPanelInfo(data); setPanelInfo(data);
} catch { } catch {
// ignore
} finally { } finally {
setPanelInfoLoading(false); setPanelInfoLoading(false);
} }
@@ -457,9 +455,7 @@ export default function AdminUserDetail() {
try { try {
const data = await adminUsersApi.getNodeUsage(userId); const data = await adminUsersApi.getNodeUsage(userId);
setNodeUsage(data); setNodeUsage(data);
} catch { } catch {}
// ignore
}
}, [userId]); }, [userId]);
const loadDevices = useCallback(async () => { const loadDevices = useCallback(async () => {
@@ -471,7 +467,6 @@ export default function AdminUserDetail() {
setDevicesTotal(data.total); setDevicesTotal(data.total);
setDeviceLimit(data.device_limit); setDeviceLimit(data.device_limit);
} catch { } catch {
// ignore
} finally { } finally {
setDevicesLoading(false); setDevicesLoading(false);
} }
@@ -488,7 +483,6 @@ export default function AdminUserDetail() {
const data = await adminUsersApi.getUserGifts(userId); const data = await adminUsersApi.getUserGifts(userId);
setGiftsData(data); setGiftsData(data);
} catch { } catch {
// ignore
} finally { } finally {
setGiftsLoading(false); setGiftsLoading(false);
} }
@@ -498,9 +492,7 @@ export default function AdminUserDetail() {
try { try {
const data = await promocodesApi.getPromoGroups({ limit: 100 }); const data = await promocodesApi.getPromoGroups({ limit: 100 });
setPromoGroups(data.items); setPromoGroups(data.items);
} catch { } catch {}
// ignore
}
}, []); }, []);
const handleTicketReply = async () => { const handleTicketReply = async () => {
@@ -954,9 +946,7 @@ export default function AdminUserDetail() {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
notify.success(t('admin.users.detail.copied')); notify.success(t('admin.users.detail.copied'));
} catch { } catch {}
// ignore
}
}; };
if (loading) { if (loading) {

View File

@@ -5,8 +5,6 @@ import { useCurrency } from '../hooks/useCurrency';
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers'; import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
// ============ Icons ============
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
className="h-5 w-5 text-dark-400" className="h-5 w-5 text-dark-400"
@@ -57,8 +55,6 @@ const TelegramIcon = () => (
</svg> </svg>
); );
// ============ Components ============
interface StatCardProps { interface StatCardProps {
title: string; title: string;
value: string | number; value: string | number;
@@ -101,8 +97,6 @@ function StatusBadge({ status }: { status: string }) {
); );
} }
// ============ User List Component ============
interface UserRowProps { interface UserRowProps {
user: UserListItem; user: UserListItem;
onClick: () => void; onClick: () => void;
@@ -181,8 +175,6 @@ function UserRow({ user, onClick, formatAmount }: UserRowProps) {
); );
} }
// ============ Main Page ============
export default function AdminUsers() { export default function AdminUsers() {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatWithCurrency } = useCurrency(); const { formatWithCurrency } = useCurrency();

View File

@@ -29,8 +29,6 @@ import { ColorPicker } from '@/components/ColorPicker';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { toNumber } from '../utils/inputHelpers'; import { toNumber } from '../utils/inputHelpers';
// Icons
const BackIcon = () => ( const BackIcon = () => (
<svg <svg
className="h-5 w-5 text-dark-400" className="h-5 w-5 text-dark-400"
@@ -181,8 +179,6 @@ const PRIZE_TYPE_KEYS = [
type Tab = 'settings' | 'prizes' | 'statistics'; type Tab = 'settings' | 'prizes' | 'statistics';
// ============ Sortable Prize Card ============
interface SortablePrizeCardProps { interface SortablePrizeCardProps {
prize: WheelPrizeAdmin; prize: WheelPrizeAdmin;
isExpanded: boolean; isExpanded: boolean;

View File

@@ -98,7 +98,6 @@ export default function Connection() {
); );
}, [appConfig?.platforms]); }, [appConfig?.platforms]);
// Loading
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-1 items-center justify-center py-20"> <div className="flex flex-1 items-center justify-center py-20">
@@ -107,20 +106,7 @@ export default function Connection() {
); );
} }
// Error if (error || !appConfig || !hasApps) {
if (error || !appConfig) {
return (
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<p className="mb-4 text-lg text-dark-300">{t('common.error')}</p>
<button onClick={handleGoBack} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div>
);
}
// No apps configured — check before subscription since empty config also has no subscription
if (!hasApps) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center"> <div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">

View File

@@ -21,10 +21,6 @@ import { getApiErrorMessage } from '../utils/api-error';
import { formatPrice } from '../utils/format'; import { formatPrice } from '../utils/format';
import { usePlatform, useHaptic } from '@/platform'; import { usePlatform, useHaptic } from '@/platform';
// ============================================================
// SVG Icons
// ============================================================
function GiftIcon({ className }: { className?: string }) { function GiftIcon({ className }: { className?: string }) {
return ( return (
<svg <svg
@@ -131,10 +127,6 @@ function InboxIcon({ className }: { className?: string }) {
); );
} }
// ============================================================
// Helpers
// ============================================================
function formatPeriodLabel( function formatPeriodLabel(
days: number, days: number,
t: (key: string, options?: Record<string, unknown>) => string, t: (key: string, options?: Record<string, unknown>) => string,
@@ -181,16 +173,8 @@ function formatGiftDate(dateStr: string | null): string {
}); });
} }
// ============================================================
// Tab type
// ============================================================
type TabId = 'buy' | 'activate' | 'myGifts'; type TabId = 'buy' | 'activate' | 'myGifts';
// ============================================================
// Sub-components: Shared
// ============================================================
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="flex min-h-dvh items-center justify-center"> <div className="flex min-h-dvh items-center justify-center">
@@ -263,10 +247,6 @@ function DisabledState() {
); );
} }
// ============================================================
// Sub-components: Buy Tab
// ============================================================
function TariffCard({ function TariffCard({
tariff, tariff,
isSelected, isSelected,
@@ -904,10 +884,6 @@ function BuyTabContent({
); );
} }
// ============================================================
// Sub-components: Activate Tab
// ============================================================
function ActivateTabContent({ initialCode }: { initialCode?: string | null }) { function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -1023,10 +999,6 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
); );
} }
// ============================================================
// Sub-components: My Gifts Tab
// ============================================================
function CopiedToast({ onDismiss }: { onDismiss: () => void }) { function CopiedToast({ onDismiss }: { onDismiss: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -1320,20 +1292,12 @@ function MyGiftsTabContent() {
); );
} }
// ============================================================
// Tab animation variants
// ============================================================
const tabContentVariants = { const tabContentVariants = {
initial: { opacity: 0, x: 20 }, initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 }, animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 }, exit: { opacity: 0, x: -20 },
}; };
// ============================================================
// Main Component
// ============================================================
export default function GiftSubscription() { export default function GiftSubscription() {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();

View File

@@ -17,6 +17,7 @@ import {
} from '../api/branding'; } from '../api/branding';
import { getAndClearReturnUrl } from '../utils/token'; import { getAndClearReturnUrl } from '../utils/token';
import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK'; import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK';
import { closeMiniApp } from '@telegram-apps/sdk-react';
import LanguageSwitcher from '../components/LanguageSwitcher'; import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton'; import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon'; import OAuthProviderIcon from '../components/OAuthProviderIcon';
@@ -213,30 +214,13 @@ export default function Login() {
tryTelegramAuth(); tryTelegramAuth();
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]); }, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
// Manual retry for Telegram Mini App auth const handleRetryTelegramAuth = () => {
const handleRetryTelegramAuth = async () => {
const initData = getTelegramInitData();
if (!initData) {
setError(t('auth.telegramRequired'));
return;
}
setError('');
setIsLoading(true);
try { try {
await loginWithTelegram(initData); sessionStorage.removeItem('tapps/launchParams');
navigate(getReturnUrl(), { replace: true }); sessionStorage.removeItem('telegram_init_data');
} catch (err) { closeMiniApp();
const error = err as { response?: { status?: number; data?: { detail?: string } } }; } catch {
const status = error.response?.status; window.location.reload();
const detail = error.response?.data?.detail;
if (import.meta.env.DEV) console.warn('Telegram auth retry failed:', status, detail);
setError(
detail ||
t('auth.telegramRetryFailed', 'Authorization failed. Close the app and try again.'),
);
} finally {
setIsLoading(false);
} }
}; };

View File

@@ -15,10 +15,6 @@ import { cn } from '../lib/utils';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
// ============================================================
// Sub-components
// ============================================================
function PendingState() { function PendingState() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -600,10 +596,6 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
); );
} }
// ============================================================
// Main Component
// ============================================================
export default function PurchaseSuccess() { export default function PurchaseSuccess() {
const { t } = useTranslation(); const { t } = useTranslation();
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();

View File

@@ -48,10 +48,6 @@ function formatPeriodLabel(
return t('landing.periodLabels.nDays', { count: days }); return t('landing.periodLabels.nDays', { count: days });
} }
// ============================================================
// Sub-components
// ============================================================
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950"> <div className="flex min-h-dvh items-center justify-center bg-dark-950">
@@ -611,10 +607,6 @@ function SummaryCard({
); );
} }
// ============================================================
// Discount Countdown
// ============================================================
function TimeUnit({ value, label }: { value: number; label: string }) { function TimeUnit({ value, label }: { value: number; label: string }) {
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@@ -715,10 +707,6 @@ function DiscountBanner({
); );
} }
// ============================================================
// Main Component
// ============================================================
export default function QuickPurchase() { export default function QuickPurchase() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();

View File

@@ -239,9 +239,7 @@ export default function Referral() {
text: shareText, text: shareText,
url: referralLink, url: referralLink,
}) })
.catch(() => { .catch(() => {});
// ignore cancellation errors
});
return; return;
} }

View File

@@ -425,7 +425,6 @@ export default function Wheel() {
// Use the pending result from polling, or show a fallback // Use the pending result from polling, or show a fallback
if (pendingStarsResultRef.current) { if (pendingStarsResultRef.current) {
setSpinResult(pendingStarsResultRef.current); setSpinResult(pendingStarsResultRef.current);
// Haptic feedback based on result
if (pendingStarsResultRef.current.prize_type === 'nothing') { if (pendingStarsResultRef.current.prize_type === 'nothing') {
haptic.notification('warning'); haptic.notification('warning');
} else { } else {

View File

@@ -73,18 +73,14 @@ function createBackButtonController(): BackButtonController {
if (currentCallback) { if (currentCallback) {
try { try {
offBackButtonClick(currentCallback); offBackButtonClick(currentCallback);
} catch { } catch {}
// ignore
}
} }
currentCallback = onClick; currentCallback = onClick;
try { try {
onBackButtonClick(onClick); onBackButtonClick(onClick);
showBackButton(); showBackButton();
} catch { } catch {}
// Back button not mounted
}
}, },
hide() { hide() {
@@ -93,16 +89,12 @@ function createBackButtonController(): BackButtonController {
if (currentCallback) { if (currentCallback) {
try { try {
offBackButtonClick(currentCallback); offBackButtonClick(currentCallback);
} catch { } catch {}
// ignore
}
currentCallback = null; currentCallback = null;
} }
try { try {
hideBackButton(); hideBackButton();
} catch { } catch {}
// Back button not mounted
}
}, },
}; };
} }
@@ -115,27 +107,21 @@ function createHapticController(): HapticController {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
hapticFeedbackImpactOccurred(style); hapticFeedbackImpactOccurred(style);
} catch { } catch {}
// Haptic not available
}
}, },
notification(type: HapticNotificationType) { notification(type: HapticNotificationType) {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
hapticFeedbackNotificationOccurred(type); hapticFeedbackNotificationOccurred(type);
} catch { } catch {}
// Haptic not available
}
}, },
selection() { selection() {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
hapticFeedbackSelectionChanged(); hapticFeedbackSelectionChanged();
} catch { } catch {}
// Haptic not available
}
}, },
}; };
} }
@@ -183,8 +169,6 @@ function createDialogController(): DialogController {
} }
try { try {
const buttons = options.buttons?.map((btn) => { const buttons = options.buttons?.map((btn) => {
// For 'ok', 'close', 'cancel' types: do NOT include text
// For 'default', 'destructive' types: text is required
if (btn.type === 'ok' || btn.type === 'close' || btn.type === 'cancel') { if (btn.type === 'ok' || btn.type === 'close' || btn.type === 'cancel') {
return { type: btn.type, id: btn.id }; return { type: btn.type, id: btn.id };
} }
@@ -212,18 +196,14 @@ function createThemeController(): ThemeController {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
setMiniAppHeaderColor(color as `#${string}`); setMiniAppHeaderColor(color as `#${string}`);
} catch { } catch {}
// Not supported
}
}, },
setBottomBarColor(color: string) { setBottomBarColor(color: string) {
if (!inTelegram) return; if (!inTelegram) return;
try { try {
setMiniAppBottomBarColor(color as `#${string}`); setMiniAppBottomBarColor(color as `#${string}`);
} catch { } catch {}
// Not supported
}
}, },
getThemeParams() { getThemeParams() {
@@ -231,7 +211,6 @@ function createThemeController(): ThemeController {
try { try {
const params = themeParamsState(); const params = themeParamsState();
if (!params) return null; if (!params) return null;
// SDK v3 uses camelCase — convert to snake_case for our interface
return { return {
bg_color: params.bgColor, bg_color: params.bgColor,
text_color: params.textColor, text_color: params.textColor,
@@ -330,9 +309,7 @@ export function createTelegramAdapter(): PlatformContext {
try { try {
await navigator.share({ text: shareText, url }); await navigator.share({ text: shareText, url });
return true; return true;
} catch { } catch {}
// User cancelled or share failed
}
} }
try { try {
@@ -350,9 +327,7 @@ export function createTelegramAdapter(): PlatformContext {
} else { } else {
disableClosingConfirmation(); disableClosingConfirmation();
} }
} catch { } catch {}
// Not supported
}
}, },
}; };
} }

View File

@@ -12,7 +12,6 @@ import type {
HapticNotificationType, HapticNotificationType,
} from '@/platform/types'; } from '@/platform/types';
// Storage key for local storage fallback
const STORAGE_PREFIX = 'bedolaga_'; const STORAGE_PREFIX = 'bedolaga_';
function createCapabilities(): PlatformCapabilities { function createCapabilities(): PlatformCapabilities {
@@ -154,7 +153,6 @@ function createCloudStorageController(): CloudStorageController {
try { try {
localStorage.setItem(STORAGE_PREFIX + key, value); localStorage.setItem(STORAGE_PREFIX + key, value);
} catch { } catch {
// Storage might be full or disabled
console.warn('Failed to save to localStorage:', key); console.warn('Failed to save to localStorage:', key);
} }
}, },

View File

@@ -53,8 +53,6 @@ interface AuthState {
) => Promise<RegisterResponse>; ) => Promise<RegisterResponse>;
} }
// Блокировка для предотвращения race condition при инициализации
// Используем объект для атомарности операций
const initState = { const initState = {
promise: null as Promise<void> | null, promise: null as Promise<void> | null,
isInitializing: false, isInitializing: false,
@@ -92,12 +90,9 @@ export const useAuthStore = create<AuthState>()(
}, },
logout: () => { logout: () => {
// Get refresh token from secure storage, not zustand state
const refreshToken = tokenStorage.getRefreshToken(); const refreshToken = tokenStorage.getRefreshToken();
if (refreshToken) { if (refreshToken) {
authApi.logout(refreshToken).catch(() => { authApi.logout(refreshToken).catch(() => {});
// Logout API call failed - ignore silently
});
} }
tokenStorage.clearTokens(); tokenStorage.clearTokens();
usePermissionStore.getState().reset(); usePermissionStore.getState().reset();
@@ -118,7 +113,6 @@ export const useAuthStore = create<AuthState>()(
usePermissionStore.getState().reset(); usePermissionStore.getState().reset();
return; return;
} }
// Используем 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 });
if (response.data.is_admin) { if (response.data.is_admin) {
@@ -136,18 +130,14 @@ export const useAuthStore = create<AuthState>()(
try { try {
const user = await authApi.getMe(); const user = await authApi.getMe();
set({ user }); set({ user });
} catch { } catch {}
// Failed to refresh user - ignore silently
}
}, },
initialize: async () => { initialize: async () => {
// Защита от race condition - если уже инициализировано, выходим
if (initState.isInitialized) { if (initState.isInitialized) {
return; return;
} }
// Если уже идёт инициализация, ждём её завершения
if (initState.isInitializing && initState.promise) { if (initState.isInitializing && initState.promise) {
return initState.promise; return initState.promise;
} }
@@ -157,7 +147,6 @@ export const useAuthStore = create<AuthState>()(
try { try {
set({ isLoading: true }); set({ isLoading: true });
// Миграция токенов из localStorage (для обратной совместимости)
tokenStorage.migrateFromLocalStorage(); tokenStorage.migrateFromLocalStorage();
const accessToken = tokenStorage.getAccessToken(); const accessToken = tokenStorage.getAccessToken();
@@ -168,15 +157,10 @@ export const useAuthStore = create<AuthState>()(
return; return;
} }
// No access token or it's expired — try refresh
// This handles Mini App reopens where sessionStorage was cleared
// but refresh token persists in localStorage
if (!isTokenValid(accessToken)) { if (!isTokenValid(accessToken)) {
// Используем централизованный менеджер для refresh
const newToken = await tokenRefreshManager.refreshAccessToken(); const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
const user = await authApi.getMe(); const user = await authApi.getMe();
// Сначала проверяем admin статус, потом снимаем isLoading
await get().checkAdminStatus(); await get().checkAdminStatus();
set({ set({
accessToken: newToken, accessToken: newToken,
@@ -200,7 +184,6 @@ export const useAuthStore = create<AuthState>()(
try { try {
const user = await authApi.getMe(); const user = await authApi.getMe();
// Сначала проверяем admin статус, потом снимаем isLoading
await get().checkAdminStatus(); await get().checkAdminStatus();
set({ set({
accessToken, accessToken,
@@ -210,12 +193,10 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
}); });
} catch { } catch {
// Token might be invalid on server, try to refresh
const newToken = await tokenRefreshManager.refreshAccessToken(); const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
try { try {
const user = await authApi.getMe(); const user = await authApi.getMe();
// Сначала проверяем admin статус, потом снимаем isLoading
await get().checkAdminStatus(); await get().checkAdminStatus();
set({ set({
accessToken: newToken, accessToken: newToken,
@@ -235,7 +216,6 @@ export const useAuthStore = create<AuthState>()(
}); });
} }
} else { } else {
// Refresh failed, logout
tokenStorage.clearTokens(); tokenStorage.clearTokens();
set({ set({
accessToken: null, accessToken: null,
@@ -339,9 +319,6 @@ export const useAuthStore = create<AuthState>()(
}, },
registerWithEmail: async (email, password, firstName, referralCode) => { registerWithEmail: async (email, password, firstName, referralCode) => {
// Registration now returns message, not tokens
// User must verify email before they can login
// Campaign slug stays in localStorage — consumed during verify_email step
const code = referralCode || consumeReferralCode() || undefined; const code = referralCode || consumeReferralCode() || undefined;
const response = await authApi.registerEmailStandalone({ const response = await authApi.registerEmailStandalone({
email, email,
@@ -355,8 +332,6 @@ export const useAuthStore = create<AuthState>()(
}), }),
{ {
name: 'cabinet-auth', name: 'cabinet-auth',
// Only persist user info for UI caching
// Tokens are stored securely in sessionStorage via tokenStorage
partialize: (state) => ({ partialize: (state) => ({
user: state.user, user: state.user,
}), }),
@@ -364,9 +339,7 @@ export const useAuthStore = create<AuthState>()(
), ),
); );
// Capture campaign slug and referral code from URL before auth initialization
captureCampaignFromUrl(); captureCampaignFromUrl();
captureReferralFromUrl(); captureReferralFromUrl();
// Initialize auth on app load
useAuthStore.getState().initialize(); useAuthStore.getState().initialize();

View File

@@ -28,9 +28,7 @@ function clearSlug(): void {
try { try {
localStorage.removeItem(CAMPAIGN_KEY); localStorage.removeItem(CAMPAIGN_KEY);
localStorage.removeItem(CAMPAIGN_TTL_KEY); localStorage.removeItem(CAMPAIGN_TTL_KEY);
} catch { } catch {}
// localStorage unavailable
}
} }
/** /**
@@ -52,9 +50,7 @@ export function captureCampaignFromUrl(): void {
const newUrl = const newUrl =
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash; window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
window.history.replaceState(null, '', newUrl); window.history.replaceState(null, '', newUrl);
} catch { } catch {}
// localStorage or history API unavailable
}
} }
/** /**

View File

@@ -28,9 +28,7 @@ function clearCode(): void {
try { try {
localStorage.removeItem(REFERRAL_KEY); localStorage.removeItem(REFERRAL_KEY);
localStorage.removeItem(REFERRAL_TTL_KEY); localStorage.removeItem(REFERRAL_TTL_KEY);
} catch { } catch {}
// localStorage unavailable
}
} }
/** /**
@@ -52,9 +50,7 @@ export function captureReferralFromUrl(): void {
const newUrl = const newUrl =
window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash; window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
window.history.replaceState(null, '', newUrl); window.history.replaceState(null, '', newUrl);
} catch { } catch {}
// localStorage or history API unavailable
}
} }
/** /**

View File

@@ -1,7 +1,3 @@
/**
* Утилиты для безопасной работы с JWT токенами
*/
import axios from 'axios'; import axios from 'axios';
const TOKEN_KEYS = { const TOKEN_KEYS = {
@@ -18,10 +14,6 @@ interface JWTPayload {
[key: string]: unknown; [key: string]: unknown;
} }
/**
* Декодирует JWT токен без верификации подписи
* Используется только для чтения payload на клиенте
*/
export function decodeJWT(token: string): JWTPayload | null { export function decodeJWT(token: string): JWTPayload | null {
try { try {
const parts = token.split('.'); const parts = token.split('.');
@@ -35,11 +27,6 @@ export function decodeJWT(token: string): JWTPayload | null {
} }
} }
/**
* Проверяет, истёк ли срок действия токена
* @param token JWT токен
* @param bufferSeconds Буфер в секундах до истечения (по умолчанию 30 сек)
*/
export function isTokenExpired(token: string | null, bufferSeconds = 30): boolean { export function isTokenExpired(token: string | null, bufferSeconds = 30): boolean {
if (!token) return true; if (!token) return true;
@@ -50,19 +37,11 @@ export function isTokenExpired(token: string | null, bufferSeconds = 30): boolea
return payload.exp <= now + bufferSeconds; return payload.exp <= now + bufferSeconds;
} }
/**
* Проверяет, валиден ли токен (не истёк и корректный формат)
*/
export function isTokenValid(token: string | null): boolean { export function isTokenValid(token: string | null): boolean {
if (!token) return false; if (!token) return false;
return !isTokenExpired(token); return !isTokenExpired(token);
} }
/**
* Безопасное хранилище токенов
* Access token: sessionStorage (short-lived, cleared on tab close)
* Refresh token: localStorage (persistent, survives Mini App reopens, server-validated)
*/
export const tokenStorage = { export const tokenStorage = {
getAccessToken(): string | null { getAccessToken(): string | null {
try { try {
@@ -74,7 +53,6 @@ export const tokenStorage = {
getRefreshToken(): string | null { getRefreshToken(): string | null {
try { try {
// Refresh token in localStorage for persistence across Mini App reopens
return localStorage.getItem(TOKEN_KEYS.REFRESH) || sessionStorage.getItem(TOKEN_KEYS.REFRESH); return localStorage.getItem(TOKEN_KEYS.REFRESH) || sessionStorage.getItem(TOKEN_KEYS.REFRESH);
} catch { } catch {
return null; return null;
@@ -84,13 +62,9 @@ export const tokenStorage = {
setTokens(accessToken: string, refreshToken: string): void { setTokens(accessToken: string, refreshToken: string): void {
try { try {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken);
// Refresh token in localStorage — survives Mini App tab close/reopen
localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken); localStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken);
// Clean up old sessionStorage refresh token (migration)
sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
} catch { } catch {}
// Storage unavailable
}
}, },
setAccessToken(accessToken: string): void { setAccessToken(accessToken: string): void {
@@ -106,39 +80,26 @@ export const tokenStorage = {
sessionStorage.removeItem(TOKEN_KEYS.ACCESS); sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
sessionStorage.removeItem(TOKEN_KEYS.USER); sessionStorage.removeItem(TOKEN_KEYS.USER);
// Также очищаем localStorage для миграции со старой версии
localStorage.removeItem(TOKEN_KEYS.ACCESS); localStorage.removeItem(TOKEN_KEYS.ACCESS);
localStorage.removeItem(TOKEN_KEYS.REFRESH); localStorage.removeItem(TOKEN_KEYS.REFRESH);
localStorage.removeItem(TOKEN_KEYS.USER); localStorage.removeItem(TOKEN_KEYS.USER);
} catch { } catch {}
// ignore
}
}, },
/**
* Миграция токенов для обратной совместимости.
* Access token: sessionStorage (short-lived, OK to lose on tab close)
* Refresh token: localStorage (persistent, survives Mini App reopens)
*/
migrateFromLocalStorage(): void { migrateFromLocalStorage(): void {
try { try {
const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS); const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS);
// Migrate access token to sessionStorage
if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) { if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) {
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken); sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken);
} }
localStorage.removeItem(TOKEN_KEYS.ACCESS); localStorage.removeItem(TOKEN_KEYS.ACCESS);
// Migrate refresh token from sessionStorage to localStorage
const refreshInSession = sessionStorage.getItem(TOKEN_KEYS.REFRESH); const refreshInSession = sessionStorage.getItem(TOKEN_KEYS.REFRESH);
if (refreshInSession && !localStorage.getItem(TOKEN_KEYS.REFRESH)) { if (refreshInSession && !localStorage.getItem(TOKEN_KEYS.REFRESH)) {
localStorage.setItem(TOKEN_KEYS.REFRESH, refreshInSession); localStorage.setItem(TOKEN_KEYS.REFRESH, refreshInSession);
} }
sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
} catch { } catch {}
// ignore
}
}, },
getTelegramInitData(): string | null { getTelegramInitData(): string | null {
@@ -152,16 +113,10 @@ export const tokenStorage = {
setTelegramInitData(data: string): void { setTelegramInitData(data: string): void {
try { try {
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, data); sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, data);
} catch { } catch {}
// ignore
}
}, },
}; };
/**
* Extract Telegram user ID from raw initData string.
* Does NOT validate cryptographic signature — used only for client-side identity comparison.
*/
function extractTelegramUserId(initData: string): string | null { function extractTelegramUserId(initData: string): string | null {
try { try {
const params = new URLSearchParams(initData); const params = new URLSearchParams(initData);
@@ -176,29 +131,14 @@ function extractTelegramUserId(initData: string): string | null {
const TG_USER_ID_KEY = 'tg_user_id'; const TG_USER_ID_KEY = 'tg_user_id';
/**
* Detect Telegram account switch and clear stale auth data.
*
* Telegram Mini App WebView shares localStorage across accounts on the same device
* (confirmed bug on Desktop, likely on mobile too). This means that when user A logs in,
* then user B opens the same Mini App, user A's refresh_token persists in localStorage.
*
* The old approach stored initData in sessionStorage for comparison, but sessionStorage
* is cleared on tab close — so the comparison never triggers between separate openings.
*
* Fix: store the Telegram user ID in localStorage (survives tab close) and compare it
* with the fresh initData on every app launch.
*/
export function clearStaleSessionIfNeeded(freshInitData: string | null): void { export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
if (!freshInitData) return; if (!freshInitData) return;
try { try {
const currentTgUserId = extractTelegramUserId(freshInitData); const currentTgUserId = extractTelegramUserId(freshInitData);
// PRIMARY CHECK: compare Telegram user ID stored in localStorage (survives tab close)
const storedTgUserId = localStorage.getItem(TG_USER_ID_KEY); const storedTgUserId = localStorage.getItem(TG_USER_ID_KEY);
if (storedTgUserId && currentTgUserId && storedTgUserId !== currentTgUserId) { if (storedTgUserId && currentTgUserId && storedTgUserId !== currentTgUserId) {
// Account switch detected — purge all auth data
sessionStorage.removeItem(TOKEN_KEYS.ACCESS); sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
sessionStorage.removeItem(TOKEN_KEYS.REFRESH); sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
sessionStorage.removeItem(TOKEN_KEYS.USER); sessionStorage.removeItem(TOKEN_KEYS.USER);
@@ -206,32 +146,15 @@ export function clearStaleSessionIfNeeded(freshInitData: string | null): void {
localStorage.removeItem('cabinet-auth'); localStorage.removeItem('cabinet-auth');
} }
// SECONDARY CHECK: same-session switch (initData changed without tab close)
const storedInitData = sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT);
if (storedInitData && storedInitData !== freshInitData) {
sessionStorage.removeItem(TOKEN_KEYS.ACCESS);
sessionStorage.removeItem(TOKEN_KEYS.REFRESH);
sessionStorage.removeItem(TOKEN_KEYS.USER);
localStorage.removeItem(TOKEN_KEYS.REFRESH);
localStorage.removeItem('cabinet-auth');
}
// Persist current Telegram user ID for future comparisons
if (currentTgUserId) { if (currentTgUserId) {
localStorage.setItem(TG_USER_ID_KEY, currentTgUserId); localStorage.setItem(TG_USER_ID_KEY, currentTgUserId);
} }
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData); sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, freshInitData);
localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT); localStorage.removeItem(TOKEN_KEYS.TELEGRAM_INIT);
} catch { } catch {}
// Storage not available
}
} }
/**
* Централизованный менеджер обновления токенов
* Предотвращает множественные параллельные refresh запросы
*/
class TokenRefreshManager { class TokenRefreshManager {
private isRefreshing = false; private isRefreshing = false;
private refreshPromise: Promise<string | null> | null = null; private refreshPromise: Promise<string | null> | null = null;
@@ -242,12 +165,7 @@ class TokenRefreshManager {
this.refreshEndpoint = endpoint; this.refreshEndpoint = endpoint;
} }
/**
* Обновляет access token используя refresh token
* При множественных вызовах возвращает один и тот же Promise
*/
async refreshAccessToken(): Promise<string | null> { async refreshAccessToken(): Promise<string | null> {
// Если уже идёт refresh - возвращаем существующий Promise
if (this.isRefreshing && this.refreshPromise) { if (this.isRefreshing && this.refreshPromise) {
return this.refreshPromise; return this.refreshPromise;
} }
@@ -270,9 +188,9 @@ class TokenRefreshManager {
} }
} }
// Uses plain axios (not apiClient) to avoid circular dependency
private async doRefresh(refreshToken: string): Promise<string | null> { private async doRefresh(refreshToken: string): Promise<string | null> {
try { try {
// Используем чистый axios (не apiClient) чтобы избежать циклической зависимости
const response = await axios.post<{ access_token?: string }>( const response = await axios.post<{ access_token?: string }>(
this.refreshEndpoint, this.refreshEndpoint,
{ refresh_token: refreshToken }, { refresh_token: refreshToken },
@@ -288,14 +206,10 @@ class TokenRefreshManager {
return null; return null;
} catch { } catch {
// Token refresh failed - don't log sensitive error details
return null; return null;
} }
} }
/**
* Подписка на результат refresh (для ожидающих запросов)
*/
subscribe(callback: (token: string | null) => void): () => void { subscribe(callback: (token: string | null) => void): () => void {
this.subscribers.push(callback); this.subscribers.push(callback);
return () => { return () => {
@@ -308,16 +222,10 @@ class TokenRefreshManager {
this.subscribers = []; this.subscribers = [];
} }
/**
* Проверяет, идёт ли сейчас refresh
*/
get isRefreshInProgress(): boolean { get isRefreshInProgress(): boolean {
return this.isRefreshing; return this.isRefreshing;
} }
/**
* Ожидает завершения текущего refresh (если есть)
*/
async waitForRefresh(): Promise<string | null> { async waitForRefresh(): Promise<string | null> {
if (this.refreshPromise) { if (this.refreshPromise) {
return this.refreshPromise; return this.refreshPromise;
@@ -328,27 +236,17 @@ class TokenRefreshManager {
export const tokenRefreshManager = new TokenRefreshManager(); export const tokenRefreshManager = new TokenRefreshManager();
/**
* Ключ для сохранения URL для возврата после логина
*/
const RETURN_URL_KEY = 'auth_return_url'; const RETURN_URL_KEY = 'auth_return_url';
/**
* Сохраняет URL для возврата после авторизации
*/
export function saveReturnUrl(): void { export function saveReturnUrl(): void {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const currentPath = window.location.pathname + window.location.search; const currentPath = window.location.pathname + window.location.search;
// Не сохраняем /login как return URL
if (currentPath && currentPath !== '/login') { if (currentPath && currentPath !== '/login') {
sessionStorage.setItem(RETURN_URL_KEY, currentPath); sessionStorage.setItem(RETURN_URL_KEY, currentPath);
} }
} }
} }
/**
* Получает и очищает сохранённый URL для возврата
*/
export function getAndClearReturnUrl(): string | null { export function getAndClearReturnUrl(): string | null {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const url = sessionStorage.getItem(RETURN_URL_KEY); const url = sessionStorage.getItem(RETURN_URL_KEY);
@@ -358,40 +256,22 @@ export function getAndClearReturnUrl(): string | null {
return null; return null;
} }
/**
* Безопасный редирект на страницу логина
* Валидирует URL чтобы предотвратить open redirect уязвимость
* Сохраняет текущий URL для возврата после авторизации
*/
export function safeRedirectToLogin(): void { export function safeRedirectToLogin(): void {
// Разрешённые пути для редиректа (относительные пути нашего приложения)
const loginPath = '/login';
// Проверяем, что мы на том же origin
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Сохраняем текущий URL для возврата после логина
saveReturnUrl(); saveReturnUrl();
// Используем только относительный путь для безопасности window.location.href = '/login';
window.location.href = loginPath;
} }
} }
/**
* Валидирует URL для редиректа
* Возвращает true только для безопасных URL (относительные пути или тот же origin)
*/
export function isValidRedirectUrl(url: string): boolean { export function isValidRedirectUrl(url: string): boolean {
// Пустой URL - небезопасен
if (!url) return false; if (!url) return false;
// Относительные пути всегда безопасны
if (url.startsWith('/') && !url.startsWith('//')) { if (url.startsWith('/') && !url.startsWith('//')) {
return true; return true;
} }
try { try {
const parsed = new URL(url, window.location.origin); const parsed = new URL(url, window.location.origin);
// Разрешаем только тот же origin
return parsed.origin === window.location.origin; return parsed.origin === window.location.origin;
} catch { } catch {
return false; return false;

View File

@@ -16,9 +16,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
export function saveTopUpPendingInfo(info: TopUpPendingInfo) { export function saveTopUpPendingInfo(info: TopUpPendingInfo) {
try { try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(info)); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(info));
} catch { } catch {}
// sessionStorage unavailable (private mode, quota, etc.)
}
} }
export function loadTopUpPendingInfo(): TopUpPendingInfo | null { export function loadTopUpPendingInfo(): TopUpPendingInfo | null {
@@ -57,7 +55,5 @@ export function loadTopUpPendingInfo(): TopUpPendingInfo | null {
export function clearTopUpPendingInfo() { export function clearTopUpPendingInfo() {
try { try {
sessionStorage.removeItem(STORAGE_KEY); sessionStorage.removeItem(STORAGE_KEY);
} catch { } catch {}
// ignore
}
} }