diff --git a/src/api/auth.ts b/src/api/auth.ts index 8ccaa8b..0505d7e 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -277,6 +277,24 @@ export const authApi = { return response.data; }, + requestDeepLinkToken: async (): Promise<{ + token: string; + bot_username: string; + expires_in: number; + }> => { + const response = await apiClient.post<{ + token: string; + bot_username: string; + expires_in: number; + }>('/cabinet/auth/deeplink/request'); + return response.data; + }, + + pollDeepLinkToken: async (token: string): Promise => { + const response = await apiClient.post('/cabinet/auth/deeplink/poll', { token }); + return response.data; + }, + getMergePreview: async (mergeToken: string): Promise => { const response = await apiClient.get( `/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`, diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 7c36bd3..4f385db 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; +import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; import { useNavigate } from 'react-router'; @@ -9,6 +10,9 @@ interface TelegramLoginButtonProps { referralCode?: string; } +const SCRIPT_LOAD_TIMEOUT_MS = 8000; +const DEEPLINK_POLL_INTERVAL_MS = 2500; + export default function TelegramLoginButton({ referralCode }: TelegramLoginButtonProps) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -16,8 +20,18 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto const [oidcLoading, setOidcLoading] = useState(false); const [oidcError, setOidcError] = useState(''); const [scriptLoaded, setScriptLoaded] = useState(false); + const [scriptFailed, setScriptFailed] = useState(false); const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC); + // Deep link auth state + const [deepLinkToken, setDeepLinkToken] = useState(null); + const [deepLinkBotUsername, setDeepLinkBotUsername] = useState(''); + const [deepLinkPolling, setDeepLinkPolling] = useState(false); + const [deepLinkError, setDeepLinkError] = useState(''); + const pollIntervalRef = useRef>(null); + + const loginWithDeepLink = useAuthStore((s) => s.loginWithDeepLink); + const { data: widgetConfig } = useQuery({ queryKey: ['telegram-widget-config'], queryFn: brandingApi.getTelegramWidgetConfig, @@ -28,7 +42,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id); - // OIDC callback handler (ref pattern to avoid stale closures and unnecessary re-inits) + // OIDC callback handler const handleOIDCCallbackRef = useRef<(data: { id_token?: string; error?: string }) => void>(undefined); const mountedRef = useRef(true); @@ -40,6 +54,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto }; }, []); + // Cleanup polling on unmount + useEffect(() => { + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + }, []); + handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => { if (!mountedRef.current) return; if (data.error || !data.id_token) { @@ -65,7 +88,14 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } }; - // Load OIDC script and init + // Handle script load failure (timeout or error) + const handleScriptFailed = useCallback(() => { + if (!mountedRef.current || scriptLoaded) return; + setScriptFailed(true); + setOidcError(''); + }, [scriptLoaded]); + + // Load OIDC script with timeout useEffect(() => { if (!isOIDC || !widgetConfig?.oidc_client_id) return; @@ -85,27 +115,45 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } }; + // Set up timeout + const timeoutId = setTimeout(() => { + if (!scriptLoaded) { + handleScriptFailed(); + } + }, SCRIPT_LOAD_TIMEOUT_MS); + if (!script) { script = document.createElement('script'); script.id = scriptId; script.src = 'https://oauth.telegram.org/js/telegram-login.js?3'; script.async = true; script.onload = () => { + clearTimeout(timeoutId); setScriptLoaded(true); initTelegramLogin(); }; script.onerror = () => { - setOidcError(t('auth.loginFailed')); + clearTimeout(timeoutId); + handleScriptFailed(); }; document.head.appendChild(script); } else { - // Script already loaded, just re-init + clearTimeout(timeoutId); setScriptLoaded(true); initTelegramLogin(); } - }, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access, t]); - // Legacy widget effect (only when NOT OIDC) + return () => clearTimeout(timeoutId); + }, [ + isOIDC, + widgetConfig?.oidc_client_id, + widgetConfig?.request_access, + t, + scriptLoaded, + handleScriptFailed, + ]); + + // Legacy widget effect with timeout const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget); useEffect(() => { @@ -148,15 +196,102 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto } script.async = true; + // Timeout for legacy widget + const timeoutId = setTimeout(() => { + // If container still has no iframe child (widget didn't render), mark as failed + if (container && !container.querySelector('iframe')) { + handleScriptFailed(); + } + }, SCRIPT_LOAD_TIMEOUT_MS); + + script.onerror = () => { + clearTimeout(timeoutId); + handleScriptFailed(); + }; + container.appendChild(script); return () => { + clearTimeout(timeoutId); delete (window as unknown as Record)[callbackName]; while (container.firstChild) { container.removeChild(container.firstChild); } }; - }, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate]); + }, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]); + + // Deep link auth: request token and start polling + const startDeepLinkAuth = useCallback(async () => { + setDeepLinkError(''); + try { + const { token, bot_username } = await authApi.requestDeepLinkToken(); + setDeepLinkToken(token); + setDeepLinkBotUsername(bot_username || botUsername); + setDeepLinkPolling(true); + + // Start polling + const intervalId = setInterval(async () => { + if (!mountedRef.current) { + clearInterval(intervalId); + return; + } + try { + await loginWithDeepLink(token); + // Success - auth store is updated, navigate + clearInterval(intervalId); + if (mountedRef.current) { + setDeepLinkPolling(false); + navigate('/'); + } + } catch (err: unknown) { + const error = err as { response?: { status?: number } }; + if (error.response?.status === 202) { + // Still pending, continue polling + return; + } + if (error.response?.status === 410) { + // Token expired + clearInterval(intervalId); + if (mountedRef.current) { + setDeepLinkPolling(false); + setDeepLinkToken(null); + setDeepLinkError(t('auth.deepLinkExpired')); + } + return; + } + // Other error - stop polling + clearInterval(intervalId); + if (mountedRef.current) { + setDeepLinkPolling(false); + setDeepLinkError(t('common.error')); + } + } + }, DEEPLINK_POLL_INTERVAL_MS); + + pollIntervalRef.current = intervalId; + + // Auto-expire after token TTL + setTimeout( + () => { + clearInterval(intervalId); + if (mountedRef.current && !useAuthStore.getState().isAuthenticated) { + setDeepLinkPolling(false); + setDeepLinkToken(null); + } + }, + 5 * 60 * 1000, + ); // 5 minutes + } catch { + setDeepLinkError(t('common.error')); + } + }, [botUsername, loginWithDeepLink, navigate, t]); + + // Auto-start deep link auth when script fails + useEffect(() => { + if (scriptFailed && !deepLinkToken && !deepLinkPolling) { + startDeepLinkAuth(); + } + }, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]); if (!botUsername || botUsername === 'your_bot') { return ( @@ -166,9 +301,88 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto ); } + // Deep link fallback UI + if (scriptFailed) { + const deepLinkUrl = deepLinkToken + ? `https://t.me/${deepLinkBotUsername || botUsername}?start=webauth_${deepLinkToken}` + : ''; + + return ( +
+
+ {/* Info message */} +

+ {t('auth.telegramWidgetBlocked')} +

+ + {deepLinkToken && deepLinkUrl ? ( + <> + {/* Deep link button */} + + + + + {t('auth.openBotToLogin')} + + + {/* Polling status */} + {deepLinkPolling && ( +
+ + {t('auth.waitingForConfirmation')} +
+ )} + + ) : deepLinkError ? ( +
+

{deepLinkError}

+ +
+ ) : ( +
+ + {t('common.loading')} +
+ )} +
+ + {/* Bot link fallback */} +
+

{t('auth.orOpenInApp')}

+ + + + + @{botUsername} + +
+
+ ); + } + + // Normal widget UI (when script loads successfully) return (
- {/* OIDC mode: custom button that opens popup */} {isOIDC ? (
) : ( - /* Legacy widget mode: iframe-based widget */
)} diff --git a/src/locales/en.json b/src/locales/en.json index a87d9f8..3307e52 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -175,6 +175,10 @@ "authenticating": "Authenticating...", "orOpenInApp": "Or open the bot in the app", "loginFailed": "Login Failed", + "telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:", + "openBotToLogin": "Open bot to sign in", + "waitingForConfirmation": "Waiting for confirmation...", + "deepLinkExpired": "Link expired. Please try again.", "tryAgain": "Try Again", "welcomeBack": "Welcome back!", "loginTitle": "Login to Cabinet", @@ -3864,6 +3868,7 @@ "linkSuccess": "Account connected successfully", "unlinkSuccess": "Account disconnected successfully", "linkError": "Failed to connect account", + "telegramLinkUnavailable": "Telegram linking is temporarily unavailable. Use the bot:", "unlinkError": "Failed to disconnect account", "goToAccounts": "Connected Accounts", "linking": "Linking account...", diff --git a/src/locales/fa.json b/src/locales/fa.json index 75e3a6b..07b6fba 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -167,6 +167,10 @@ "authenticating": "در حال تایید هویت...", "orOpenInApp": "یا ربات را در برنامه باز کنید", "loginFailed": "ورود ناموفق", + "telegramWidgetBlocked": "ویجت ورود تلگرام در دسترس نیست. از طریق ربات وارد شوید:", + "openBotToLogin": "باز کردن ربات برای ورود", + "waitingForConfirmation": "در انتظار تایید...", + "deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.", "tryAgain": "تلاش مجدد", "welcomeBack": "خوش آمدید!", "loginTitle": "ورود به کابین", @@ -3258,6 +3262,7 @@ "linkSuccess": "حساب با موفقیت متصل شد", "unlinkSuccess": "اتصال حساب با موفقیت قطع شد", "linkError": "اتصال حساب ناموفق بود", + "telegramLinkUnavailable": "اتصال تلگرام موقتاً در دسترس نیست. از ربات استفاده کنید:", "unlinkError": "قطع اتصال ناموفق بود", "goToAccounts": "حساب‌های متصل", "linking": "در حال اتصال حساب...", diff --git a/src/locales/ru.json b/src/locales/ru.json index f8e5496..db4916b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -178,6 +178,10 @@ "authenticating": "Авторизация...", "orOpenInApp": "Или откройте бота в приложении", "loginFailed": "Ошибка входа", + "telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:", + "openBotToLogin": "Открыть бота для входа", + "waitingForConfirmation": "Ожидание подтверждения...", + "deepLinkExpired": "Ссылка истекла. Попробуйте снова.", "tryAgain": "Попробовать снова", "welcomeBack": "Добро пожаловать!", "loginTitle": "Вход в личный кабинет", @@ -4421,6 +4425,7 @@ "linkSuccess": "Аккаунт успешно привязан", "unlinkSuccess": "Аккаунт успешно отвязан", "linkError": "Не удалось привязать аккаунт", + "telegramLinkUnavailable": "Привязка Telegram временно недоступна. Используйте бота:", "unlinkError": "Не удалось отвязать аккаунт", "goToAccounts": "Привязанные аккаунты", "linking": "Привязка аккаунта...", diff --git a/src/locales/zh.json b/src/locales/zh.json index e24f931..a11c045 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -167,6 +167,10 @@ "authenticating": "正在验证...", "orOpenInApp": "或在应用中打开机器人", "loginFailed": "登录失败", + "telegramWidgetBlocked": "Telegram登录小部件不可用。请使用机器人登录:", + "openBotToLogin": "打开机器人登录", + "waitingForConfirmation": "等待确认...", + "deepLinkExpired": "链接已过期,请重试。", "tryAgain": "重试", "welcomeBack": "欢迎回来!", "loginTitle": "登录个人中心", @@ -3257,6 +3261,7 @@ "linkSuccess": "账户关联成功", "unlinkSuccess": "账户取消关联成功", "linkError": "关联账户失败", + "telegramLinkUnavailable": "Telegram关联暂时不可用。请使用机器人:", "unlinkError": "取消关联失败", "goToAccounts": "关联账户", "linking": "正在关联账户...", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 1502bb5..7a839bd 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -25,6 +25,8 @@ const isLinkableProvider = (provider: string): boolean => // SessionStorage key for Telegram link CSRF state export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state'; +const LINK_SCRIPT_LOAD_TIMEOUT_MS = 8000; + /** Telegram account linking widget (browser only). Supports OIDC popup and legacy widget. */ function TelegramLinkWidget() { const containerRef = useRef(null); @@ -34,6 +36,7 @@ function TelegramLinkWidget() { const queryClient = useQueryClient(); const [oidcLoading, setOidcLoading] = useState(false); const [scriptLoaded, setScriptLoaded] = useState(false); + const [scriptFailed, setScriptFailed] = useState(false); const mountedRef = useRef(true); const { data: widgetConfig } = useQuery({ @@ -65,6 +68,12 @@ function TelegramLinkWidget() { [navigate, queryClient, showToast, t], ); + // Handle script load failure (timeout or error) + const handleScriptFailed = useCallback(() => { + if (!mountedRef.current || scriptLoaded) return; + setScriptFailed(true); + }, [scriptLoaded]); + // OIDC callback handler (ref pattern to avoid stale closures) const handleOIDCCallbackRef = useRef<(data: { id_token?: string; error?: string }) => void>(undefined); @@ -95,7 +104,7 @@ function TelegramLinkWidget() { } }; - // Load OIDC script and init + // Load OIDC script and init with timeout useEffect(() => { if (!isOIDC || !widgetConfig?.oidc_client_id) return; @@ -116,24 +125,43 @@ function TelegramLinkWidget() { } }; + const timeoutId = setTimeout(() => { + if (!scriptLoaded) { + handleScriptFailed(); + } + }, LINK_SCRIPT_LOAD_TIMEOUT_MS); + if (!script) { script = document.createElement('script'); script.id = scriptId; script.src = 'https://oauth.telegram.org/js/telegram-login.js?3'; script.async = true; - script.onload = () => initTelegramLogin(); + script.onload = () => { + clearTimeout(timeoutId); + initTelegramLogin(); + }; script.onerror = () => { - if (mountedRef.current) { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - } + clearTimeout(timeoutId); + handleScriptFailed(); }; document.head.appendChild(script); } else { + clearTimeout(timeoutId); initTelegramLogin(); } - }, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access, showToast, t]); - // Legacy widget effect (only when NOT OIDC) + return () => clearTimeout(timeoutId); + }, [ + isOIDC, + widgetConfig?.oidc_client_id, + widgetConfig?.request_access, + showToast, + t, + scriptLoaded, + handleScriptFailed, + ]); + + // Legacy widget effect (only when NOT OIDC) with timeout useEffect(() => { if (isOIDC || !containerRef.current || !botUsername) return; @@ -177,20 +205,58 @@ function TelegramLinkWidget() { script.setAttribute('data-request-access', 'write'); script.async = true; + const timeoutId = setTimeout(() => { + if (container && !container.querySelector('iframe')) { + handleScriptFailed(); + } + }, LINK_SCRIPT_LOAD_TIMEOUT_MS); + + script.onerror = () => { + clearTimeout(timeoutId); + handleScriptFailed(); + }; + container.appendChild(script); return () => { + clearTimeout(timeoutId); delete (window as unknown as Record)[callbackName]; while (container.firstChild) { container.removeChild(container.firstChild); } }; - }, [isOIDC, botUsername, navigate, showToast, t, queryClient, handleLinkResult]); + }, [ + isOIDC, + botUsername, + navigate, + showToast, + t, + queryClient, + handleLinkResult, + handleScriptFailed, + ]); if (!botUsername && !isOIDC) { return null; } + // Script failed to load - show unavailable message with bot link + if (scriptFailed) { + return ( +
+

{t('profile.accounts.telegramLinkUnavailable')}

+ + @{botUsername} + +
+ ); + } + if (isOIDC) { return (