diff --git a/src/api/auth.ts b/src/api/auth.ts index 228e8f7..4a66f2d 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -248,10 +248,11 @@ export const authApi = { return response.data; }, - // Link Telegram account (Mini App initData or Login Widget data) + // Link Telegram account (Mini App initData, OIDC id_token, or Login Widget data) linkTelegram: async ( data: | { init_data: string } + | { id_token: string } | { id: number; first_name: string; diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 977e67e..2543da6 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -4,6 +4,7 @@ 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'; @@ -24,28 +25,126 @@ const isLinkableProvider = (provider: string): boolean => // SessionStorage key for Telegram link CSRF state export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state'; -/** Compact Telegram Login Widget for account linking (browser only). */ +/** 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 botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; + 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(() => { - if (!containerRef.current || !botUsername) return; + 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); } - // Global callback invoked by the Telegram Login Widget 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, @@ -56,17 +155,14 @@ function TelegramLinkWidget() { auth_date: user.auth_date as number, hash: user.hash as string, }); - 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') }); - } + if (mountedRef.current) await handleLinkResult(response); } catch (err: unknown) { - showToast({ - type: 'error', - message: getErrorDetail(err) || t('profile.accounts.linkError'), - }); + if (mountedRef.current) { + showToast({ + type: 'error', + message: getErrorDetail(err) || t('profile.accounts.linkError'), + }); + } } }; @@ -87,12 +183,33 @@ function TelegramLinkWidget() { container.removeChild(container.firstChild); } }; - }, [botUsername, navigate, showToast, t, queryClient]); + }, [isOIDC, botUsername, navigate, showToast, t, queryClient]); - if (!botUsername) { + if (!botUsername && !isOIDC) { return null; } + if (isOIDC) { + return ( + + ); + } + return
; }