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 type { ServerCompleteResponse } from '../types'; // SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts) export const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider'; // SessionStorage helpers for OAuth LOGIN state const OAUTH_STATE_KEY = 'oauth_state'; const OAUTH_PROVIDER_KEY = 'oauth_provider'; export function saveOAuthState(state: string, provider: string): void { sessionStorage.setItem(OAUTH_STATE_KEY, state); sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider); } /** Read link OAuth state without clearing (cleared only after successful match). */ function peekLinkOAuthState(): { state: string; provider: string } | null { const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY); const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY); if (!state || !provider) return null; return { state, provider }; } function clearLinkOAuthState(): void { sessionStorage.removeItem(LINK_OAUTH_STATE_KEY); sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY); } type CallbackMode = 'login' | 'link-browser' | 'link-server'; export function getErrorDetail(err: unknown): string | null { if (err && typeof err === 'object' && 'response' in err) { const resp = (err as { response?: { data?: { detail?: unknown } } }).response; const detail = resp?.data?.detail; if (typeof detail === 'string') return detail; if (detail && typeof detail === 'object' && 'message' in detail) { const msg = (detail as Record).message; if (typeof msg === 'string') return msg; } } if (err instanceof Error) return err.message; return null; } export default function OAuthCallback() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [error, setError] = useState(''); const [errorMode, setErrorMode] = useState('login'); const [serverLinkResult, setServerCompleteResponse] = useState( null, ); const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const hasRun = useRef(false); // Handle merge redirect via useEffect (not in render) useEffect(() => { if (serverLinkResult?.merge_required && serverLinkResult.merge_token) { navigate(`/merge/${serverLinkResult.merge_token}`, { replace: true }); } }, [serverLinkResult, navigate]); useEffect(() => { // Prevent double-fire from React StrictMode if (hasRun.current) return; hasRun.current = true; 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')); 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 provider: string | undefined; let state: string | undefined; const linkSaved = peekLinkOAuthState(); if (linkSaved && linkSaved.state === urlState) { clearLinkOAuthState(); mode = 'link-browser'; provider = linkSaved.provider; state = linkSaved.state; } else { // Peek at login state first; only clear if it matches URL state const loginState = sessionStorage.getItem(OAUTH_STATE_KEY); const loginProvider = sessionStorage.getItem(OAUTH_PROVIDER_KEY); if (loginState && loginProvider && loginState === urlState) { sessionStorage.removeItem(OAUTH_STATE_KEY); sessionStorage.removeItem(OAUTH_PROVIDER_KEY); mode = 'login'; provider = loginProvider; state = loginState; } } const handle = async () => { // Clear sensitive OAuth params (code, state) from URL immediately for all modes window.history.replaceState({}, '', '/auth/oauth/callback'); if (mode === 'link-browser' && provider && state) { // Browser linking: user is authenticated, complete via JWT-protected endpoint try { const response = await authApi.linkProviderCallback( provider, code, state, deviceId ?? undefined, ); if (response.merge_required && response.merge_token) { navigate(`/merge/${response.merge_token}`, { replace: true }); } else { navigate('/profile/accounts', { replace: true }); } } catch (err: unknown) { setErrorMode('link-browser'); setError(getErrorDetail(err) || t('profile.accounts.linkError')); } return; } if (mode === 'login' && provider && state) { // Login flow if (isAuthenticated) { navigate('/', { replace: true }); return; } try { await loginWithOAuth(provider, code, state, deviceId); navigate('/', { replace: true }); } catch (err: unknown) { const detail = getErrorDetail(err); setError(detail || 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. try { // Provider is resolved server-side from the state token in Redis. const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined); setServerCompleteResponse(response); } catch (err: unknown) { setErrorMode('link-server'); setError(getErrorDetail(err) || t('profile.accounts.linkError')); } }; handle(); }, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]); // Server-complete result: show success with "Return to Telegram" link // (merge redirect is handled by the useEffect above) if ( serverLinkResult && serverLinkResult.success && !(serverLinkResult.merge_required && serverLinkResult.merge_token) ) { 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) { const isServerMode = errorMode === 'link-server'; const isLinkBrowserMode = errorMode === 'link-browser'; const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; const errorAction = isServerMode && telegramLink ? ( {t('profile.accounts.openTelegram')} ) : isLinkBrowserMode ? ( ) : ( ); return (

{t('auth.loginFailed')}

{error}

{errorAction}
); } return (

{t('auth.authenticating')}

{t('common.loading')}

); }