diff --git a/src/api/auth.ts b/src/api/auth.ts index 106e537..787caba 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -254,6 +254,24 @@ export const authApi = { 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 ( + code: string, + state: string, + deviceId?: string, + ): Promise => { + const response = await apiClient.post( + '/cabinet/auth/account/link/server-complete', + { + code, + state, + device_id: deviceId, + }, + ); + return response.data; + }, + unlinkProvider: async (provider: string): Promise<{ success: boolean }> => { const response = await apiClient.post<{ success: boolean }>( `/cabinet/auth/account/unlink/${encodeURIComponent(provider)}`, diff --git a/src/api/client.ts b/src/api/client.ts index 5b5034a..eaddfb6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -76,6 +76,7 @@ const AUTH_ENDPOINTS = [ '/cabinet/auth/password/reset', '/cabinet/auth/oauth/', '/cabinet/auth/merge/', + '/cabinet/auth/account/link/server-complete', ]; function isAuthEndpoint(url: string | undefined): boolean { diff --git a/src/locales/en.json b/src/locales/en.json index 6047f1e..cbfdb5a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3616,6 +3616,8 @@ "goToAccounts": "Connected Accounts", "linking": "Linking account...", "linkingTelegram": "Connecting Telegram...", + "returnToTelegram": "Return to Telegram to continue", + "openTelegram": "Open Telegram", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/fa.json b/src/locales/fa.json index 96ab376..66be39b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3052,6 +3052,8 @@ "goToAccounts": "حساب‌های متصل", "linking": "در حال اتصال حساب...", "linkingTelegram": "در حال اتصال تلگرام...", + "returnToTelegram": "برای ادامه به تلگرام بازگردید", + "openTelegram": "باز کردن تلگرام", "providers": { "telegram": "تلگرام", "email": "ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4e28322..5a2f195 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4176,6 +4176,8 @@ "goToAccounts": "Привязанные аккаунты", "linking": "Привязка аккаунта...", "linkingTelegram": "Привязка Telegram...", + "returnToTelegram": "Вернитесь в Telegram, чтобы продолжить", + "openTelegram": "Открыть Telegram", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/zh.json b/src/locales/zh.json index 68038e7..210dc8c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3051,6 +3051,8 @@ "goToAccounts": "关联账户", "linking": "正在关联账户...", "linkingTelegram": "正在关联 Telegram...", + "returnToTelegram": "返回 Telegram 继续操作", + "openTelegram": "打开 Telegram", "providers": { "telegram": "Telegram", "email": "邮箱", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 7b7386f..d85abb6 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -11,6 +11,7 @@ import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import ProviderIcon from '../components/ProviderIcon'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; +import { usePlatform } from '@/platform/hooks/usePlatform'; import type { LinkedProvider } from '../types'; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; @@ -99,6 +100,7 @@ export default function ConnectedAccounts() { const blurTimeoutRef = useRef>(undefined); const inTelegram = isInTelegramWebApp(); + const platform = usePlatform(); useEffect(() => { return () => { @@ -109,6 +111,7 @@ export default function ConnectedAccounts() { const { data, isLoading, isError } = useQuery({ queryKey: ['linked-providers'], queryFn: () => authApi.getLinkedProviders(), + refetchOnWindowFocus: true, }); const unlinkMutation = useMutation({ @@ -143,9 +146,20 @@ export default function ConnectedAccounts() { setLinkingProvider(provider); try { const { authorize_url, state } = await authApi.linkProviderInit(provider); - sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state); - sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); - window.location.href = authorize_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); + // Reset loading state — user stays in Mini App + setLinkingProvider(null); + } else { + // Regular browser: navigate within the same tab. + // Save state in sessionStorage for the callback page to verify. + sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state); + sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); + window.location.href = authorize_url; + } } catch { showToast({ type: 'error', diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index ffccf93..d1c2b2c 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/auth'; +import { authApi } from '../api/auth'; +import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; -// SessionStorage helpers for OAuth state +// SessionStorage helpers for OAuth LOGIN state const OAUTH_STATE_KEY = 'oauth_state'; const OAUTH_PROVIDER_KEY = 'oauth_provider'; @@ -21,63 +23,178 @@ export function getAndClearOAuthState(): { state: string; provider: string } | n return { state, provider }; } +/** + * Check if this is an account LINKING callback (user was already authenticated). + * Returns the saved link state or null. + */ +function getAndClearLinkOAuthState(): { state: string; provider: string } | null { + const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY); + const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY); + sessionStorage.removeItem(LINK_OAUTH_STATE_KEY); + sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY); + if (!state || !provider) return null; + return { state, provider }; +} + +type CallbackMode = 'login' | 'link-browser' | 'link-server'; + +/** Result after successful server-complete linking from external browser. */ +interface ServerLinkResult { + success: boolean; + merge_required: boolean; + merge_token: string | null; + provider?: string; +} + export default function OAuthCallback() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [error, setError] = useState(''); + const [serverLinkResult, setServerLinkResult] = useState(null); const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const hasRun = useRef(false); useEffect(() => { - if (isAuthenticated) { - navigate('/', { replace: true }); - return; - } - - // Prevent double-fire from React StrictMode or dependency changes + // Prevent double-fire from React StrictMode if (hasRun.current) return; hasRun.current = true; - const authenticate = async () => { - const code = searchParams.get('code'); - const urlState = searchParams.get('state'); - // VK ID returns device_id in callback URL (required for token exchange) - const deviceId = searchParams.get('device_id'); + const code = searchParams.get('code'); + const urlState = searchParams.get('state'); + const deviceId = searchParams.get('device_id'); - if (!code || !urlState) { - setError(t('auth.oauthError', 'Authorization was denied or failed')); + if (!code || !urlState) { + setError(t('auth.oauthError', 'Authorization was denied or failed')); + return; + } + + // Determine callback mode: + // 1. Link state in sessionStorage → browser linking flow + // 2. Login state in sessionStorage → login flow + // 3. Neither → opened from external browser (Mini App flow) → server-complete + let mode: CallbackMode = 'link-server'; + let savedProvider: string | null = null; + let savedState: string | null = null; + + const linkSaved = getAndClearLinkOAuthState(); + if (linkSaved && linkSaved.state === urlState) { + mode = 'link-browser'; + savedProvider = linkSaved.provider; + savedState = linkSaved.state; + } else { + const loginSaved = getAndClearOAuthState(); + if (loginSaved && loginSaved.state === urlState) { + mode = 'login'; + savedProvider = loginSaved.provider; + savedState = loginSaved.state; + } + } + + const handle = async () => { + if (mode === 'link-browser') { + // Browser linking: user is authenticated, complete via JWT-protected endpoint + try { + const response = await authApi.linkProviderCallback( + savedProvider!, + code, + savedState!, + deviceId ?? undefined, + ); + if (response.merge_required && response.merge_token) { + navigate(`/merge/${response.merge_token}`, { replace: true }); + } else { + navigate('/profile/accounts', { replace: true }); + } + } catch { + setError(t('profile.accounts.linkError')); + } return; } - // Get saved state from sessionStorage - const saved = getAndClearOAuthState(); - if (!saved) { - setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.')); + if (mode === 'login') { + // Login flow + if (isAuthenticated) { + navigate('/', { replace: true }); + return; + } + try { + await loginWithOAuth(savedProvider!, code, savedState!, deviceId); + navigate('/', { replace: true }); + } catch (err: unknown) { + const detail = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + (err instanceof Error ? err.message : null); + setError(detail || t('auth.oauthError', 'Authorization was denied or failed')); + } return; } - // Validate state match - if (saved.state !== urlState) { - setError(t('auth.oauthError', 'Authorization was denied or failed')); - return; - } + // mode === 'link-server': No sessionStorage state found. + // This happens when OAuth was opened in external browser from Mini App. + // Complete linking via state-token-authenticated server endpoint. + // Clear sensitive data from URL immediately. + window.history.replaceState({}, '', '/auth/oauth/callback'); try { - await loginWithOAuth(saved.provider, code, urlState, deviceId); - navigate('/', { replace: true }); - } catch (err: unknown) { - const detail = - (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? - (err instanceof Error ? err.message : null); - setError(detail || t('auth.oauthError', 'Authorization was denied or failed')); + // Provider is resolved server-side from the state token in Redis. + const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined); + + setServerLinkResult(response); + } catch { + setError(t('profile.accounts.linkError')); } }; - authenticate(); + handle(); }, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]); + // Server-complete result: show success/error with "Return to Telegram" link + if (serverLinkResult) { + if (serverLinkResult.merge_required && serverLinkResult.merge_token) { + // Redirect to merge page (public, works without JWT) + navigate(`/merge/${serverLinkResult.merge_token}`, { replace: true }); + return null; + } + + const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; + const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; + + return ( +
+
+
+
+
+ + + +
+

+ {t('profile.accounts.linkSuccess')} +

+

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

+ {telegramLink && ( + + {t('profile.accounts.openTelegram')} + + )} +
+
+
+ ); + } + if (error) { return (