diff --git a/src/api/auth.ts b/src/api/auth.ts index 787caba..bd5e1e6 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -7,6 +7,7 @@ import type { MergeResponse, OAuthProvider, RegisterResponse, + ServerCompleteResponse, TokenResponse, User, } from '../types'; @@ -260,8 +261,8 @@ export const authApi = { code: string, state: string, deviceId?: string, - ): Promise => { - const response = await apiClient.post( + ): Promise => { + const response = await apiClient.post( '/cabinet/auth/account/link/server-complete', { code, diff --git a/src/api/client.ts b/src/api/client.ts index eaddfb6..1f27460 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -81,7 +81,7 @@ const AUTH_ENDPOINTS = [ function isAuthEndpoint(url: string | undefined): boolean { if (!url) return false; - return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint)); + return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint)); } // Request interceptor - add auth token with expiration check @@ -214,15 +214,10 @@ apiClient.interceptors.response.use( // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { // Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента - const authEndpoints = [ - '/cabinet/auth/email/login', - '/cabinet/auth/telegram', - '/cabinet/auth/telegram/widget', - ]; const requestUrl = originalRequest.url || ''; - const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint)); + const isLoginEndpoint = isAuthEndpoint(requestUrl); - if (isAuthEndpoint) { + if (isLoginEndpoint) { // Пробрасываем ошибку в компонент для показа сообщения пользователю return Promise.reject(error); } diff --git a/src/locales/en.json b/src/locales/en.json index 698c36e..453283c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3619,6 +3619,7 @@ "returnToTelegram": "Return to Telegram to continue", "openTelegram": "Open Telegram", "continueInBrowser": "Continue authorization in the opened browser", + "pollingTimeout": "Authorization check timed out. Refresh the page if you completed linking.", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/fa.json b/src/locales/fa.json index 29efa7e..3f8cdef 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3055,6 +3055,7 @@ "returnToTelegram": "برای ادامه به تلگرام بازگردید", "openTelegram": "باز کردن تلگرام", "continueInBrowser": "ادامه احراز هویت در مرورگر باز شده", + "pollingTimeout": "بررسی احراز هویت منقضی شد. اگر اتصال را تکمیل کرده‌اید صفحه را بارگذاری مجدد کنید.", "providers": { "telegram": "تلگرام", "email": "ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 80fe7b8..4042ac2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4179,6 +4179,7 @@ "returnToTelegram": "Вернитесь в Telegram, чтобы продолжить", "openTelegram": "Открыть Telegram", "continueInBrowser": "Продолжите авторизацию в открывшемся браузере", + "pollingTimeout": "Проверка авторизации истекла. Обновите страницу, если привязка завершена.", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/zh.json b/src/locales/zh.json index c6a9058..23d99a6 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3054,6 +3054,7 @@ "returnToTelegram": "返回 Telegram 继续操作", "openTelegram": "打开 Telegram", "continueInBrowser": "请在打开的浏览器中继续授权", + "pollingTimeout": "授权检查超时。如果您已完成关联,请刷新页面。", "providers": { "telegram": "Telegram", "email": "邮箱", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index e2ecbba..94f3ada 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -9,7 +9,7 @@ import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import ProviderIcon from '../components/ProviderIcon'; -import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './OAuthCallback'; +import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from './OAuthCallback'; import { getTelegramInitData } from '../hooks/useTelegramSDK'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; import type { LinkedProvider } from '../types'; @@ -98,7 +98,7 @@ export default function ConnectedAccounts() { const [confirmingUnlink, setConfirmingUnlink] = useState(null); const [linkingProvider, setLinkingProvider] = useState(null); const [waitingExternalLink, setWaitingExternalLink] = useState(false); - const linkedCountBeforePolling = useRef(null); + const pendingLinkProvider = useRef(null); const blurTimeoutRef = useRef>(undefined); const inTelegram = useIsTelegram(); @@ -118,21 +118,26 @@ export default function ConnectedAccounts() { refetchInterval: waitingExternalLink ? 5000 : false, }); - // Stop polling after 90 seconds + // Stop polling after 90 seconds with timeout feedback useEffect(() => { if (!waitingExternalLink) return; - const timeout = setTimeout(() => setWaitingExternalLink(false), 90_000); - return () => clearTimeout(timeout); - }, [waitingExternalLink]); - - // Detect successful external link: stop polling and show toast when linked count increases - useEffect(() => { - if (!waitingExternalLink || !data) return; - const currentLinked = data.providers.filter((p) => p.linked).length; - if (linkedCountBeforePolling.current === null) return; - if (currentLinked > linkedCountBeforePolling.current) { + const timeout = setTimeout(() => { setWaitingExternalLink(false); - linkedCountBeforePolling.current = null; + pendingLinkProvider.current = null; + // Final refresh in case link succeeded during the last polling interval + queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); + showToast({ type: 'warning', message: t('profile.accounts.pollingTimeout') }); + }, 90_000); + return () => clearTimeout(timeout); + }, [waitingExternalLink, showToast, t, queryClient]); + + // Detect successful external link: stop polling when the target provider becomes linked + useEffect(() => { + if (!waitingExternalLink || !data || !pendingLinkProvider.current) return; + const target = data.providers.find((p) => p.provider === pendingLinkProvider.current); + if (target?.linked) { + setWaitingExternalLink(false); + pendingLinkProvider.current = null; showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); } }, [data, waitingExternalLink, showToast, t]); @@ -169,14 +174,28 @@ export default function ConnectedAccounts() { setLinkingProvider(provider); try { const { authorize_url, state } = await authApi.linkProviderInit(provider); + if (!authorize_url || !state) { + throw new Error('Invalid response from server'); + } + + // Validate redirect URL — only allow HTTPS to prevent open redirect + let parsed: URL; + try { + parsed = new URL(authorize_url); + } catch { + throw new Error('Invalid OAuth redirect URL'); + } + if (parsed.protocol !== 'https:') { + throw new Error('Invalid OAuth redirect URL'); + } if (inTelegram) { // Mini App: open in external browser to avoid WebView OAuth restrictions. // The callback will use server-complete flow (auth via state token, no JWT). platform.openLink(authorize_url); setLinkingProvider(null); - // Snapshot current linked count before polling starts - linkedCountBeforePolling.current = data?.providers.filter((p) => p.linked).length ?? 0; + // Track which provider we're waiting to become linked + pendingLinkProvider.current = provider; // Start polling for linked providers (external browser has no way to notify Mini App) setWaitingExternalLink(true); showToast({ @@ -190,10 +209,10 @@ export default function ConnectedAccounts() { sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); window.location.href = authorize_url; } - } catch { + } catch (err: unknown) { showToast({ type: 'error', - message: t('profile.accounts.linkError'), + message: getErrorDetail(err) || t('profile.accounts.linkError'), }); setLinkingProvider(null); } @@ -213,8 +232,8 @@ export default function ConnectedAccounts() { queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); } - } catch { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); + } catch (err: unknown) { + showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') }); } finally { setLinkingProvider(null); } @@ -245,7 +264,7 @@ export default function ConnectedAccounts() { + ) : ( + + ); + return (
@@ -232,21 +261,7 @@ export default function OAuthCallback() {

{t('auth.loginFailed')}

{error}

- {isServerMode && telegramLink ? ( - - {t('profile.accounts.openTelegram')} - - ) : ( - - )} + {errorAction}
diff --git a/src/types/index.ts b/src/types/index.ts index 9c435a7..2a230ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -655,6 +655,10 @@ export interface LinkCallbackResponse { merge_token: string | null; } +export interface ServerCompleteResponse extends LinkCallbackResponse { + provider: string; +} + // Account Merge export interface MergeSubscriptionPreview { status: string;