import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { authApi } from '../api/auth'; import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { useToast } from '../components/Toast'; 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, getErrorDetail } from './OAuthCallback'; import { getTelegramInitData } from '../hooks/useTelegramSDK'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; import type { LinkedProvider } from '../types'; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider); const isLinkableProvider = (provider: string): boolean => isOAuthProvider(provider) || provider === 'telegram'; // SessionStorage key for Telegram link CSRF state export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state'; /** Telegram account linking widget (browser only). Supports OIDC popup and legacy widget. */ function TelegramLinkWidget() { const containerRef = useRef(null); const navigate = useNavigate(); const { showToast } = useToast(); const { t } = useTranslation(); const queryClient = useQueryClient(); const [oidcLoading, setOidcLoading] = useState(false); const [scriptLoaded, setScriptLoaded] = useState(false); const mountedRef = useRef(true); const { data: widgetConfig } = useQuery({ queryKey: ['telegram-widget-config'], queryFn: brandingApi.getTelegramWidgetConfig, staleTime: 60000, }); const botUsername = widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); // Shared handler for link result const handleLinkResult = async (response: Awaited>) => { if (response.merge_required && response.merge_token) { navigate(`/merge/${response.merge_token}`, { replace: true }); } else { queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); } }; // OIDC callback handler (ref pattern to avoid stale closures) const handleOIDCCallbackRef = useRef<(data: { id_token?: string; error?: string }) => void>(undefined); handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => { if (!mountedRef.current) return; if (data.error || !data.id_token) { setOidcLoading(false); showToast({ type: 'error', message: data.error || t('profile.accounts.linkError'), }); return; } try { setOidcLoading(true); const response = await authApi.linkTelegram({ id_token: data.id_token }); if (mountedRef.current) await handleLinkResult(response); } catch (err: unknown) { if (mountedRef.current) { showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError'), }); } } finally { if (mountedRef.current) setOidcLoading(false); } }; // Load OIDC script and init useEffect(() => { if (!isOIDC || !widgetConfig?.oidc_client_id) return; const scriptId = 'telegram-login-oidc-script'; let script = document.getElementById(scriptId) as HTMLScriptElement | null; const initTelegramLogin = () => { if (window.Telegram?.Login) { window.Telegram.Login.init( { client_id: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id, request_access: widgetConfig.request_access ? ['write'] : undefined, lang: document.documentElement.lang || 'en', }, (data) => handleOIDCCallbackRef.current?.(data), ); setScriptLoaded(true); } }; 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.onerror = () => { if (mountedRef.current) { showToast({ type: 'error', message: t('profile.accounts.linkError') }); } }; document.head.appendChild(script); } else { initTelegramLogin(); } }, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]); // Legacy widget effect (only when NOT OIDC) useEffect(() => { if (isOIDC || !containerRef.current || !botUsername) return; const container = containerRef.current; while (container.firstChild) { container.removeChild(container.firstChild); } const callbackName = '__onTelegramLinkAuth'; (window as unknown as Record)[callbackName] = async ( user: Record, ) => { if (!mountedRef.current) return; try { const response = await authApi.linkTelegram({ id: user.id as number, first_name: user.first_name as string, last_name: (user.last_name as string) || undefined, username: (user.username as string) || undefined, photo_url: (user.photo_url as string) || undefined, auth_date: user.auth_date as number, hash: user.hash as string, }); if (mountedRef.current) await handleLinkResult(response); } catch (err: unknown) { if (mountedRef.current) { showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError'), }); } } }; const script = document.createElement('script'); script.src = 'https://telegram.org/js/telegram-widget.js?23'; script.setAttribute('data-telegram-login', botUsername); script.setAttribute('data-size', 'small'); script.setAttribute('data-radius', '8'); script.setAttribute('data-onauth', `${callbackName}(user)`); script.setAttribute('data-request-access', 'write'); script.async = true; container.appendChild(script); return () => { delete (window as unknown as Record)[callbackName]; while (container.firstChild) { container.removeChild(container.firstChild); } }; }, [isOIDC, botUsername, navigate, showToast, t, queryClient]); if (!botUsername && !isOIDC) { return null; } if (isOIDC) { return ( ); } return
; } function LoadingSkeleton() { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } export default function ConnectedAccounts() { const { t } = useTranslation(); const { showToast } = useToast(); const queryClient = useQueryClient(); const navigate = useNavigate(); const [confirmingUnlink, setConfirmingUnlink] = useState(null); const [linkingProvider, setLinkingProvider] = useState(null); const [waitingExternalLink, setWaitingExternalLink] = useState(false); const pendingLinkProvider = useRef(null); const blurTimeoutRef = useRef>(undefined); const inTelegram = useIsTelegram(); const platform = usePlatform(); useEffect(() => { return () => { if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); }; }, []); const { data, isLoading, isError } = useQuery({ queryKey: ['linked-providers'], queryFn: () => authApi.getLinkedProviders(), refetchOnWindowFocus: true, // Poll every 5s while waiting for external browser OAuth to complete refetchInterval: waitingExternalLink ? 5000 : false, }); // Stop polling after 90 seconds with timeout feedback useEffect(() => { if (!waitingExternalLink) return; const timeout = setTimeout(() => { setWaitingExternalLink(false); 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]); const unlinkMutation = useMutation({ mutationFn: (provider: string) => authApi.unlinkProvider(provider), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); showToast({ type: 'success', message: t('profile.accounts.unlinkSuccess'), }); }, onError: () => { showToast({ type: 'error', message: t('profile.accounts.unlinkError'), }); }, onSettled: () => { setConfirmingUnlink(null); }, }); const canUnlink = (provider: LinkedProvider): boolean => { if (!provider.linked) return false; if (!isOAuthProvider(provider.provider)) return false; const linkedCount = data?.providers.filter((p) => p.linked).length ?? 0; return linkedCount > 1; }; const handleLinkOAuth = async (provider: string) => { if (linkingProvider) return; 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); // 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({ type: 'info', message: t('profile.accounts.continueInBrowser'), }); } 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 (err: unknown) { showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError'), }); setLinkingProvider(null); } }; const handleLinkTelegram = async () => { if (linkingProvider) return; const initData = getTelegramInitData(); if (!initData) return; setLinkingProvider('telegram'); try { const response = await authApi.linkTelegram({ init_data: initData }); if (response.merge_required && response.merge_token) { navigate(`/merge/${response.merge_token}`, { replace: true }); } else { queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); } } catch (err: unknown) { showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') }); } finally { setLinkingProvider(null); } }; const handleLink = async (provider: string) => { if (provider === 'telegram') { await handleLinkTelegram(); } else { await handleLinkOAuth(provider); } }; const handleUnlink = (provider: string) => { if (confirmingUnlink === provider) { setConfirmingUnlink(null); unlinkMutation.mutate(provider); } else { setConfirmingUnlink(provider); } }; const renderLinkButton = (provider: LinkedProvider) => { if (provider.provider === 'telegram') { if (inTelegram && getTelegramInitData()) { // Mini App: one-click button return ( ); } // Browser: Telegram Login Widget return ; } if (isOAuthProvider(provider.provider)) { return ( ); } return null; }; return ( {/* Page title */}

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

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

{/* Loading state */} {isLoading && ( )} {/* Error state */} {isError && (

{t('common.error')}

)} {/* Provider cards */} {data?.providers.map((provider) => (

{t(`profile.accounts.providers.${provider.provider}`)}

{provider.identifier && (

{provider.identifier}

)}
{provider.linked ? ( <> {t('profile.accounts.linked')} {canUnlink(provider) && ( )} ) : ( isLinkableProvider(provider.provider) && renderLinkButton(provider) )}
))}
); }