From 3c034d2e70359357c6522ff9ba703a69f1a2f5d6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 15:06:16 +0300 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20referral=20system=20=E2=80=94=20stop?= =?UTF-8?q?=20cabinet=20redirect=20to=20Telegram,=20fix=20deep=20link=20co?= =?UTF-8?q?de=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redirect to Telegram bot when email auth disabled + referral code present (cabinet links should stay on cabinet, bot links should go to bot) - Remove referral_code from deep link auth (existing users can't get referrals) - Don't consume referralCode in deep link path — leave it in localStorage for OIDC/widget auth methods that actually send it to the backend - Consume campaign slug once into ref to survive retries (codesConsumedRef pattern) - Update loginWithDeepLink to only accept (token, campaignSlug) — no referralCode - Update pollDeepLinkToken API to match backend schema change --- src/api/auth.ts | 4 ++-- src/components/TelegramLoginButton.tsx | 19 ++++++++++++++++++- src/pages/Login.tsx | 12 +----------- src/store/auth.ts | 6 +++--- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index d35b9d6..297fb77 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -290,7 +290,7 @@ export const authApi = { return response.data; }, - pollDeepLinkToken: async (token: string): Promise => { + pollDeepLinkToken: async (token: string, campaignSlug?: string | null): Promise => { // validateStatus: only treat 200 as success. // Server returns 202 for "pending" and 410 for "expired" — // these must reject so the polling catch-block can handle them. @@ -299,7 +299,7 @@ export const authApi = { // which triggers checkAdminStatus() → 401 → safeRedirectToLogin() → infinite reload. const response = await apiClient.post( '/cabinet/auth/deeplink/poll', - { token }, + { token, campaign_slug: campaignSlug || undefined }, { validateStatus: (status) => status === 200 }, ); return response.data; diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 197015c..71bcc99 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -6,6 +6,7 @@ import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; import { useNavigate } from 'react-router'; +import { consumeCampaignSlug } from '../utils/campaign'; interface TelegramLoginButtonProps { referralCode?: string; @@ -34,6 +35,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const loginWithDeepLink = useAuthStore((s) => s.loginWithDeepLink); + // Capture campaign slug once on mount (before any retry clears it) + const capturedCampaignRef = useRef(null); + const codesConsumedRef = useRef(false); + const { data: widgetConfig } = useQuery({ queryKey: ['telegram-widget-config'], queryFn: brandingApi.getTelegramWidgetConfig, @@ -239,6 +244,17 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } try { + // Consume campaign slug ONCE (first call only). + // Clears localStorage on first call, so subsequent retries reuse the ref. + // Note: referral code is NOT consumed here — deep link auth is for existing + // bot users where referrals don't apply. Leaving it in localStorage allows + // other auth methods (OIDC, widget) to pick it up if the user switches paths. + if (!codesConsumedRef.current) { + capturedCampaignRef.current = consumeCampaignSlug(); + codesConsumedRef.current = true; + } + const capturedCampaign = capturedCampaignRef.current; + const response = await authApi.requestDeepLinkToken(); const { token, bot_username, expires_in } = response; setDeepLinkToken(token); @@ -249,7 +265,8 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const poll = async () => { if (!mountedRef.current) return; try { - await loginWithDeepLink(token); + // Deep link auth is for existing bot users — only campaign_slug applies + await loginWithDeepLink(token, capturedCampaign); // Success — auth store is updated, navigate if (expireTimeoutRef.current) { clearTimeout(expireTimeoutRef.current); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index e32272e..eb3e628 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -22,7 +22,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher'; import TelegramLoginButton from '../components/TelegramLoginButton'; import OAuthProviderIcon from '../components/OAuthProviderIcon'; import { saveOAuthState } from '../utils/oauth'; -import { consumeReferralCode, getPendingReferralCode } from '../utils/referral'; +import { getPendingReferralCode } from '../utils/referral'; export default function Login() { const { t } = useTranslation(); @@ -146,16 +146,6 @@ export default function Login() { } }; - const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; - - // If email auth is disabled but user came with ref param, redirect to bot - useEffect(() => { - if (referralCode && emailAuthConfig?.enabled === false && botUsername) { - consumeReferralCode(); - window.location.href = `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`; - } - }, [referralCode, emailAuthConfig, botUsername]); - const appName = branding ? branding.name : import.meta.env.VITE_APP_NAME || 'VPN'; const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; diff --git a/src/store/auth.ts b/src/store/auth.ts index 81b9602..8d0b841 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -45,7 +45,7 @@ interface AuthState { state: string, deviceId?: string | null, ) => Promise; - loginWithDeepLink: (token: string) => Promise; + loginWithDeepLink: (token: string, campaignSlug?: string | null) => Promise; registerWithEmail: ( email: string, password: string, @@ -322,8 +322,8 @@ export const useAuthStore = create()( await get().checkAdminStatus(); }, - loginWithDeepLink: async (token) => { - const response = await authApi.pollDeepLinkToken(token); + loginWithDeepLink: async (token, campaignSlug) => { + const response = await authApi.pollDeepLinkToken(token, campaignSlug); if (!response.access_token || !response.refresh_token) { throw new Error('Invalid auth response: missing tokens'); } From 86d997d01d860b42a4d7fd61c703dc03c39f0a2a Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 01:54:11 +0300 Subject: [PATCH 2/9] feat: custom broadcast buttons UI and fix stale mediaType bug - Add inline custom button editor (no popups): label, type (callback/URL), action value with byte-level validation - Frontend validates callback_data in UTF-8 bytes via TextEncoder - URLs restricted to https:// and tg:// matching backend - Form wrapped in
for Enter key submit support - Fix pre-existing stale mediaType bug in file upload handler - Translations added for ru, en, zh, fa locales --- src/api/adminBroadcasts.ts | 7 ++ src/locales/en.json | 9 +- src/locales/fa.json | 9 +- src/locales/ru.json | 9 +- src/locales/zh.json | 9 +- src/pages/AdminBroadcastCreate.tsx | 183 +++++++++++++++++++++++++++-- 6 files changed, 212 insertions(+), 14 deletions(-) diff --git a/src/api/adminBroadcasts.ts b/src/api/adminBroadcasts.ts index d5360b2..2f0d0dd 100644 --- a/src/api/adminBroadcasts.ts +++ b/src/api/adminBroadcasts.ts @@ -48,6 +48,12 @@ export interface BroadcastButtonsResponse { buttons: BroadcastButton[]; } +export interface CustomBroadcastButton { + label: string; + action_type: 'callback' | 'url'; + action_value: string; +} + export interface BroadcastMedia { type: 'photo' | 'video' | 'document'; file_id: string; @@ -73,6 +79,7 @@ export interface CombinedBroadcastCreateRequest { // Telegram fields message_text?: string; selected_buttons?: string[]; + custom_buttons?: CustomBroadcastButton[]; media?: BroadcastMedia; // Email fields email_subject?: string; diff --git a/src/locales/en.json b/src/locales/en.json index 6e338c3..42942a2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1701,7 +1701,14 @@ "enableEmail": "Email", "sendingBoth": "2 broadcasts will be created", "bothCreated": "Broadcasts created", - "atLeastOneChannel": "Select at least one channel" + "atLeastOneChannel": "Select at least one channel", + "customButtons": "Custom buttons", + "addCustomButton": "Add button", + "customButtonLabelPlaceholder": "Button text", + "customButtonTypeCallback": "Callback", + "customButtonTypeUrl": "URL", + "customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "Pinned Messages", diff --git a/src/locales/fa.json b/src/locales/fa.json index 797c3e9..9f08085 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1336,7 +1336,14 @@ "enableEmail": "Email", "sendingBoth": "۲ پیام‌رسانی ایجاد خواهد شد", "bothCreated": "پیام‌رسانی‌ها ایجاد شدند", - "atLeastOneChannel": "حداقل یک کانال را انتخاب کنید" + "atLeastOneChannel": "حداقل یک کانال را انتخاب کنید", + "customButtons": "دکمه‌های سفارشی", + "addCustomButton": "افزودن دکمه", + "customButtonLabelPlaceholder": "متن دکمه", + "customButtonTypeCallback": "Callback", + "customButtonTypeUrl": "لینک", + "customButtonCallbackPlaceholder": "callback_data (مثلاً back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "Pinned Messages", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5137d97..99c85b6 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1726,7 +1726,14 @@ "enableEmail": "Email", "sendingBoth": "Будет создано 2 рассылки", "bothCreated": "Рассылки созданы", - "atLeastOneChannel": "Выберите хотя бы один канал" + "atLeastOneChannel": "Выберите хотя бы один канал", + "customButtons": "Кастомные кнопки", + "addCustomButton": "Добавить кнопку", + "customButtonLabelPlaceholder": "Текст кнопки", + "customButtonTypeCallback": "Callback", + "customButtonTypeUrl": "Ссылка", + "customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "Закреплённые сообщения", diff --git a/src/locales/zh.json b/src/locales/zh.json index 734a8bb..4cc4801 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1410,7 +1410,14 @@ "enableEmail": "Email", "sendingBoth": "将创建2个群发", "bothCreated": "群发已创建", - "atLeastOneChannel": "请至少选择一个渠道" + "atLeastOneChannel": "请至少选择一个渠道", + "customButtons": "自定义按钮", + "addCustomButton": "添加按钮", + "customButtonLabelPlaceholder": "按钮文字", + "customButtonTypeCallback": "回调", + "customButtonTypeUrl": "链接", + "customButtonCallbackPlaceholder": "callback_data(例如 back_to_menu)", + "customButtonUrlPlaceholder": "https://example.com" }, "pinnedMessages": { "title": "置顶消息", diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index 5e6073b..1709aab 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -7,6 +7,7 @@ import { BroadcastFilter, TariffFilter, CombinedBroadcastCreateRequest, + CustomBroadcastButton, } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; @@ -130,6 +131,11 @@ export default function AdminBroadcastCreate() { // Telegram-specific state const [messageText, setMessageText] = useState(''); const [selectedButtons, setSelectedButtons] = useState(['home']); + const [customButtons, setCustomButtons] = useState([]); + const [isAddingCustomButton, setIsAddingCustomButton] = useState(false); + const [newButtonLabel, setNewButtonLabel] = useState(''); + const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback'); + const [newButtonActionValue, setNewButtonActionValue] = useState(''); const [mediaFile, setMediaFile] = useState(null); const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo'); const [mediaPreview, setMediaPreview] = useState(null); @@ -275,28 +281,33 @@ export default function AdminBroadcastCreate() { const file = e.target.files?.[0]; if (!file) return; - setMediaFile(file); - + // Determine type locally to avoid stale state in async call + let detectedType: 'photo' | 'video' | 'document'; if (file.type.startsWith('image/')) { - setMediaType('photo'); + detectedType = 'photo'; + } else if (file.type.startsWith('video/')) { + detectedType = 'video'; + } else { + detectedType = 'document'; + } + + setMediaFile(file); + setMediaType(detectedType); + + if (detectedType === 'photo') { if (mediaPreviewRef.current) URL.revokeObjectURL(mediaPreviewRef.current); const url = URL.createObjectURL(file); mediaPreviewRef.current = url; setMediaPreview(url); - } else if (file.type.startsWith('video/')) { - setMediaType('video'); - setMediaPreview(null); } else { - setMediaType('document'); setMediaPreview(null); } setIsUploading(true); try { - const result = await adminBroadcastsApi.uploadMedia(file, mediaType); + const result = await adminBroadcastsApi.uploadMedia(file, detectedType); setUploadedFileId(result.file_id); - } catch (err) { - console.error('Upload failed:', err); + } catch { setMediaFile(null); setMediaPreview(null); } finally { @@ -325,6 +336,39 @@ export default function AdminBroadcastCreate() { ); }; + // Custom button validation + const isNewButtonValid = useMemo(() => { + if (!newButtonLabel.trim() || !newButtonActionValue.trim()) return false; + if (newButtonActionType === 'url') { + return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim()); + } + if (newButtonActionType === 'callback') { + return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64; + } + return true; + }, [newButtonLabel, newButtonActionType, newButtonActionValue]); + + // Custom button handlers + const addCustomButton = () => { + if (!isNewButtonValid) return; + setCustomButtons((prev) => [ + ...prev, + { + label: newButtonLabel.trim(), + action_type: newButtonActionType, + action_value: newButtonActionValue.trim(), + }, + ]); + setNewButtonLabel(''); + setNewButtonActionValue(''); + setNewButtonActionType('callback'); + setIsAddingCustomButton(false); + }; + + const removeCustomButton = (index: number) => { + setCustomButtons((prev) => prev.filter((_, i) => i !== index)); + }; + // Validate form const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0; const isEmailValid = @@ -350,6 +394,7 @@ export default function AdminBroadcastCreate() { target: telegramTarget, message_text: messageText, selected_buttons: selectedButtons, + custom_buttons: customButtons.length > 0 ? customButtons : undefined, }; if (uploadedFileId) { data.media = { type: mediaType, file_id: uploadedFileId }; @@ -377,6 +422,7 @@ export default function AdminBroadcastCreate() { target: telegramTarget, message_text: messageText, selected_buttons: selectedButtons, + custom_buttons: customButtons.length > 0 ? customButtons : undefined, }; if (uploadedFileId) { telegramData.media = { type: mediaType, file_id: uploadedFileId }; @@ -655,6 +701,123 @@ export default function AdminBroadcastCreate() { ))} + + {/* Custom buttons */} +
+ + + {/* Existing custom buttons */} + {customButtons.length > 0 && ( +
+ {customButtons.map((btn, index) => ( +
+
+ + {btn.action_type === 'url' + ? t('admin.broadcasts.customButtonTypeUrl') + : t('admin.broadcasts.customButtonTypeCallback')} + + {btn.label} + {btn.action_value} +
+ +
+ ))} +
+ )} + + {/* Inline add form */} + {isAddingCustomButton ? ( + { + e.preventDefault(); + addCustomButton(); + }} + className="space-y-3 rounded-lg border border-dark-600 bg-dark-800/50 p-3" + > + setNewButtonLabel(e.target.value)} + placeholder={t('admin.broadcasts.customButtonLabelPlaceholder')} + maxLength={64} + className="input" + autoFocus + /> +
+ + +
+ setNewButtonActionValue(e.target.value)} + placeholder={ + newButtonActionType === 'url' + ? t('admin.broadcasts.customButtonUrlPlaceholder') + : t('admin.broadcasts.customButtonCallbackPlaceholder') + } + maxLength={newButtonActionType === 'callback' ? 64 : 256} + className="input" + /> +
+ + +
+ + ) : ( + + )} +
)} From ac1550ce107fc37b81cdf0271666976499fed4ef Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 04:17:21 +0300 Subject: [PATCH 3/9] fix: use live panel traffic data in admin subscription card Subscription card showed stale traffic from local DB (0.0 GB) while the live traffic section below showed correct data from panel (3.52 GB). Now uses panelInfo.used_traffic_bytes when available, with DB fallback. --- src/pages/AdminUserDetail.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 50776b1..75174e1 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1387,8 +1387,10 @@ export default function AdminUserDetail() { {t('admin.users.detail.subscription.traffic')}
- {user.subscription.traffic_used_gb.toFixed(1)} /{' '} - {user.subscription.traffic_limit_gb} {t('common.units.gb')} + {panelInfo?.found + ? (panelInfo.used_traffic_bytes / (1024 * 1024 * 1024)).toFixed(1) + : user.subscription.traffic_used_gb.toFixed(1)}{' '} + / {user.subscription.traffic_limit_gb} {t('common.units.gb')}
From 9d519fb5ec8c06c35f0967a0901940d934e1c882 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 04:45:19 +0300 Subject: [PATCH 4/9] fix: show error state instead of blank page on purchase-options failure When the purchase-options API call failed (e.g. for email-registered users), the page rendered only the header with no content. Now shows an error message with retry button. Also adds a fallback for when the API returns no available tariffs or periods. --- src/pages/SubscriptionPurchase.tsx | 83 +++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 967637f..829a9fe 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -45,7 +45,12 @@ export default function SubscriptionPurchase() { const subscription = subscriptionResponse?.subscription ?? null; // Purchase options - const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ + const { + data: purchaseOptions, + isLoading: optionsLoading, + isError: optionsError, + refetch: refetchOptions, + } = useQuery({ queryKey: ['purchase-options'], queryFn: subscriptionApi.getPurchaseOptions, staleTime: 0, @@ -362,6 +367,58 @@ export default function SubscriptionPurchase() { ); } + if (optionsError || (!purchaseOptions && !optionsLoading)) { + return ( +
+
+ +

+ {t('subscription.extend')} +

+
+
+

+ {t('subscription.loadError', 'Не удалось загрузить варианты подписки')} +

+ +
+
+ ); + } + return (
{/* Header with back link */} @@ -1936,6 +1993,30 @@ export default function SubscriptionPurchase() { )}
)} + + {/* No options available fallback */} + {purchaseOptions && + !optionsLoading && + !(isTariffsMode && tariffs.length > 0) && + !(classicOptions && classicOptions.periods.length > 0) && ( +
+

+ {t('subscription.noOptionsAvailable', 'Нет доступных вариантов подписки')} +

+ +
+ )}
); } From 58b1f96852fb32f57bfb1ca255b27ebc021236e9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 05:06:14 +0300 Subject: [PATCH 5/9] feat: show short device identifier (HWID) in device list --- src/pages/Subscription.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 56700ae..e55104e 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -2124,7 +2124,12 @@ export default function Subscription() {
{device.device_model || device.platform}
-
{device.platform}
+
+ {device.platform} + + {device.hwid.slice(0, 8).toUpperCase()} + +
- ) : ( -
- - {t('common.loading')} -
- )} - - {/* Bot link fallback */} -
-

{t('auth.orOpenInApp')}

- - - - - @{botUsername} - -
+ {/* Polling status */} + {deepLinkPolling && ( +
+ + {t('auth.waitingForConfirmation')} +
+ )} + + ) : deepLinkError ? ( +
+

{deepLinkError}

+ +
+ ) : ( +
+ + {t('common.loading')} +
+ )} ); } diff --git a/src/locales/en.json b/src/locales/en.json index 42942a2..1728e45 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -180,6 +180,9 @@ "waitingForConfirmation": "Waiting for confirmation...", "deepLinkExpired": "Link expired. Please try again.", "tryAgain": "Try Again", + "scanQrToLogin": "Scan QR code with your phone camera", + "orSendCommand": "Or send the bot this command:", + "commandCopied": "Command copied", "welcomeBack": "Welcome back!", "loginTitle": "Login to Cabinet", "loginSubtitle": "Sign in with Telegram or use your email", diff --git a/src/locales/ru.json b/src/locales/ru.json index 99c85b6..c361682 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -183,6 +183,9 @@ "waitingForConfirmation": "Ожидание подтверждения...", "deepLinkExpired": "Ссылка истекла. Попробуйте снова.", "tryAgain": "Попробовать снова", + "scanQrToLogin": "Отсканируйте QR-код камерой телефона", + "orSendCommand": "Или отправьте боту команду:", + "commandCopied": "Команда скопирована", "welcomeBack": "Добро пожаловать!", "loginTitle": "Вход в личный кабинет", "loginSubtitle": "Войдите через Telegram или используйте email", From e57166745c24875ffd50b78e31bbd1e20480720f Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 05:21:01 +0300 Subject: [PATCH 7/9] fix: prevent polling race condition and add missing zh/fa translations --- src/components/TelegramLoginButton.tsx | 13 +++++++++++-- src/locales/fa.json | 3 +++ src/locales/zh.json | 3 +++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 3601c16..cdd6d45 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -36,6 +36,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const pollTimeoutRef = useRef>(null); const expireTimeoutRef = useRef>(null); const copiedTimeoutRef = useRef>(null); + const pollInFlightRef = useRef(false); const loginWithDeepLink = useAuthStore((s) => s.loginWithDeepLink); @@ -264,7 +265,8 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto // Recursive setTimeout prevents overlapping async calls const poll = async () => { - if (!mountedRef.current) return; + if (!mountedRef.current || pollInFlightRef.current) return; + pollInFlightRef.current = true; try { // Deep link auth is for existing bot users — only campaign_slug applies await loginWithDeepLink(token, capturedCampaign); @@ -296,6 +298,8 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto // Other error — stop polling setDeepLinkPolling(false); setDeepLinkError(t('common.error')); + } finally { + pollInFlightRef.current = false; } }; @@ -348,9 +352,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto clearTimeout(pollTimeoutRef.current); pollTimeoutRef.current = null; } + // Skip if another poll is already in-flight to prevent race conditions + if (pollInFlightRef.current) return; const capturedCampaign = capturedCampaignRef.current; const immediatePoll = async () => { - if (!mountedRef.current) return; + if (!mountedRef.current || pollInFlightRef.current) return; + pollInFlightRef.current = true; try { await loginWithDeepLink(deepLinkToken, capturedCampaign); if (expireTimeoutRef.current) { @@ -377,6 +384,8 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } setDeepLinkPolling(false); setDeepLinkError(t('common.error')); + } finally { + pollInFlightRef.current = false; } }; immediatePoll(); diff --git a/src/locales/fa.json b/src/locales/fa.json index 9f08085..467e54d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -172,6 +172,9 @@ "waitingForConfirmation": "در انتظار تایید...", "deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.", "tryAgain": "تلاش مجدد", + "scanQrToLogin": "کد QR را با دوربین گوشی اسکن کنید", + "orSendCommand": "یا این دستور را به ربات ارسال کنید:", + "commandCopied": "دستور کپی شد", "welcomeBack": "خوش آمدید!", "loginTitle": "ورود به کابین", "loginSubtitle": "با تلگرام یا ایمیل وارد شوید", diff --git a/src/locales/zh.json b/src/locales/zh.json index 4cc4801..b6c525c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -172,6 +172,9 @@ "waitingForConfirmation": "等待确认...", "deepLinkExpired": "链接已过期,请重试。", "tryAgain": "重试", + "scanQrToLogin": "用手机相机扫描二维码", + "orSendCommand": "或向机器人发送命令:", + "commandCopied": "命令已复制", "welcomeBack": "欢迎回来!", "loginTitle": "登录个人中心", "loginSubtitle": "通过Telegram或邮箱登录", From 1538879f970bcadc115d10ff027ce4d015606099 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 05:37:09 +0300 Subject: [PATCH 8/9] fix: clear expire timer on 410/error and reset pollInFlight on retry --- src/components/TelegramLoginButton.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index cdd6d45..0bf5b1a 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -235,7 +235,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const startDeepLinkAuth = useCallback(async () => { setDeepLinkError(''); - // Clear any previous timers + // Clear any previous timers and in-flight guard if (pollTimeoutRef.current) { clearTimeout(pollTimeoutRef.current); pollTimeoutRef.current = null; @@ -244,6 +244,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto clearTimeout(expireTimeoutRef.current); expireTimeoutRef.current = null; } + pollInFlightRef.current = false; try { // Consume campaign slug ONCE (first call only). @@ -288,14 +289,22 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto return; } if (err.response?.status === 410) { - // Token expired + // Token expired — clear expire timer to prevent stale timer killing future sessions + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } setDeepLinkPolling(false); setDeepLinkToken(null); setDeepLinkError(t('auth.deepLinkExpired')); return; } } - // Other error — stop polling + // Other error — stop polling and clear expire timer + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } setDeepLinkPolling(false); setDeepLinkError(t('common.error')); } finally { @@ -376,12 +385,20 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto return; } if (err.response?.status === 410) { + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } setDeepLinkPolling(false); setDeepLinkToken(null); setDeepLinkError(t('auth.deepLinkExpired')); return; } } + if (expireTimeoutRef.current) { + clearTimeout(expireTimeoutRef.current); + expireTimeoutRef.current = null; + } setDeepLinkPolling(false); setDeepLinkError(t('common.error')); } finally { From 3e27472c8aaf836e855a7163f5a2829395655ccc Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 22 Mar 2026 07:24:45 +0300 Subject: [PATCH 9/9] fix: clear all cached auth state on Telegram MiniApp retry When users hit 'Try Again' after auth failure, clear ALL cached state (tokens, SDK launch params, initData, zustand persist, tg_user_id) to prevent stale token loops. Applies to both Login and TelegramRedirect retry handlers. --- src/pages/Login.tsx | 13 ++++++++++--- src/pages/TelegramRedirect.tsx | 9 +++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index eb3e628..adf0f8b 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -15,7 +15,7 @@ import { type BrandingInfo, type EmailAuthEnabled, } from '../api/branding'; -import { getAndClearReturnUrl } from '../utils/token'; +import { getAndClearReturnUrl, tokenStorage } from '../utils/token'; import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK'; import { closeMiniApp } from '@telegram-apps/sdk-react'; import LanguageSwitcher from '../components/LanguageSwitcher'; @@ -205,11 +205,18 @@ export default function Login() { }, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]); const handleRetryTelegramAuth = () => { + // Clear ALL cached auth state to prevent stale token/initData loops + tokenStorage.clearTokens(); + sessionStorage.removeItem('tapps/launchParams'); + sessionStorage.removeItem('telegram_init_data'); + localStorage.removeItem('cabinet-auth'); + localStorage.removeItem('tg_user_id'); + try { - sessionStorage.removeItem('tapps/launchParams'); - sessionStorage.removeItem('telegram_init_data'); + // Close miniapp — Telegram will provide fresh initData on reopen closeMiniApp(); } catch { + // If closeMiniApp fails, force a clean page reload window.location.reload(); } }; diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index 107a8ef..8ca1bcb 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -6,6 +6,7 @@ import { useAuthStore } from '../store/auth'; import { useShallow } from 'zustand/shallow'; import { brandingApi } from '../api/branding'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; +import { tokenStorage } from '../utils/token'; // Validate redirect URL to prevent open redirect attacks const getSafeRedirectUrl = (url: string | null): string => { @@ -117,6 +118,14 @@ export default function TelegramRedirect() { const newCount = retryCount + 1; setRetryCount(newCount); sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount)); + + // Clear all cached auth state to prevent stale token/initData loops + tokenStorage.clearTokens(); + sessionStorage.removeItem('tapps/launchParams'); + sessionStorage.removeItem('telegram_init_data'); + localStorage.removeItem('cabinet-auth'); + localStorage.removeItem('tg_user_id'); + setStatus('loading'); setErrorMessage(''); window.location.reload();