From 0d99ea0069b0601e890cb8c6d393f299591e29bc Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 03:10:22 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20review=20round=202=20=E2=80=94=20re?= =?UTF-8?q?move=20dead=20code,=20fix=20type=20safety,=20improve=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove LinkOAuthCallback.tsx (dead code) and its route from App.tsx - Move LINK_OAUTH_* constants to OAuthCallback.tsx - Replace local ServerLinkResult with LinkCallbackResponse from types - Move merge navigate() from render body to useEffect - Track errorMode to show correct CTA (Return to Telegram vs Back to Login) - Remove non-null assertions with proper narrowing - Extract getErrorDetail helper for error casting - Add smart polling success detection (stop + toast when linked count increases) --- src/App.tsx | 11 ---- src/pages/ConnectedAccounts.tsx | 17 +++++- src/pages/LinkOAuthCallback.tsx | 101 -------------------------------- src/pages/OAuthCallback.tsx | 94 +++++++++++++++++------------ 4 files changed, 74 insertions(+), 149 deletions(-) delete mode 100644 src/pages/LinkOAuthCallback.tsx diff --git a/src/App.tsx b/src/App.tsx index f51c9d5..dd48cd3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,6 @@ const ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts')); -const LinkOAuthCallback = lazy(() => import('./pages/LinkOAuthCallback')); const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback')); const MergeAccounts = lazy(() => import('./pages/MergeAccounts')); @@ -317,16 +316,6 @@ function App() { } /> - - - - - - } - /> (null); const [linkingProvider, setLinkingProvider] = useState(null); const [waitingExternalLink, setWaitingExternalLink] = useState(false); + const linkedCountBeforePolling = useRef(null); const blurTimeoutRef = useRef>(undefined); const inTelegram = useIsTelegram(); @@ -124,6 +125,18 @@ export default function ConnectedAccounts() { 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) { + setWaitingExternalLink(false); + linkedCountBeforePolling.current = null; + showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); + } + }, [data, waitingExternalLink, showToast, t]); + const unlinkMutation = useMutation({ mutationFn: (provider: string) => authApi.unlinkProvider(provider), onSuccess: () => { @@ -162,6 +175,8 @@ export default function ConnectedAccounts() { // 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; // Start polling for linked providers (external browser has no way to notify Mini App) setWaitingExternalLink(true); showToast({ diff --git a/src/pages/LinkOAuthCallback.tsx b/src/pages/LinkOAuthCallback.tsx deleted file mode 100644 index 76a44e1..0000000 --- a/src/pages/LinkOAuthCallback.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { useTranslation } from 'react-i18next'; -import { authApi } from '../api/auth'; -import { useToast } from '../components/Toast'; - -// SessionStorage keys — shared with ConnectedAccounts.tsx -export const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; -export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider'; - -function getLinkOAuthState(): { 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); -} - -export default function LinkOAuthCallback() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { showToast } = useToast(); - const hasRun = useRef(false); - - useEffect(() => { - // Prevent double-fire from React StrictMode - if (hasRun.current) return; - hasRun.current = true; - - const linkAccount = async () => { - const code = searchParams.get('code'); - const urlState = searchParams.get('state'); - // VK ID returns device_id in callback URL - const deviceId = searchParams.get('device_id'); - - if (!code || !urlState) { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - navigate('/profile/accounts', { replace: true }); - return; - } - - // Get saved state from sessionStorage (read only, don't clear yet) - const saved = getLinkOAuthState(); - if (!saved) { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - navigate('/profile/accounts', { replace: true }); - return; - } - - // Validate state match - if (saved.state !== urlState) { - clearLinkOAuthState(); - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - navigate('/profile/accounts', { replace: true }); - return; - } - - // State validated — clear it now (one-time use) - clearLinkOAuthState(); - - try { - const response = await authApi.linkProviderCallback( - saved.provider, - code, - urlState, - deviceId ?? undefined, - ); - - if (response.merge_required && response.merge_token) { - // Redirect to merge page - navigate(`/merge/${response.merge_token}`, { replace: true }); - } else { - // Success - redirect back to accounts - showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); - navigate('/profile/accounts', { replace: true }); - } - } catch { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - navigate('/profile/accounts', { replace: true }); - } - }; - - linkAccount(); - }, [searchParams, navigate, showToast, t]); - - return ( -
-
-
-
-

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

-

{t('common.loading')}

-
-
- ); -} diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index 01d07b9..512b4ab 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -3,7 +3,11 @@ 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'; +import type { LinkCallbackResponse } 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'; @@ -38,12 +42,15 @@ function clearLinkOAuthState(): void { 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; +type ServerLinkResult = LinkCallbackResponse & { provider?: string }; + +function getErrorDetail(err: unknown): string | null { + if (err && typeof err === 'object' && 'response' in err) { + const resp = (err as { response?: { data?: { detail?: string } } }).response; + if (resp?.data?.detail) return resp.data.detail; + } + if (err instanceof Error) return err.message; + return null; } export default function OAuthCallback() { @@ -51,11 +58,19 @@ export default function OAuthCallback() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [error, setError] = useState(''); + const [errorMode, setErrorMode] = useState('login'); const [serverLinkResult, setServerLinkResult] = 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; @@ -75,32 +90,32 @@ export default function OAuthCallback() { // 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; + let provider: string | undefined; + let state: string | undefined; const linkSaved = peekLinkOAuthState(); if (linkSaved && linkSaved.state === urlState) { clearLinkOAuthState(); mode = 'link-browser'; - savedProvider = linkSaved.provider; - savedState = linkSaved.state; + provider = linkSaved.provider; + state = linkSaved.state; } else { const loginSaved = getAndClearOAuthState(); if (loginSaved && loginSaved.state === urlState) { mode = 'login'; - savedProvider = loginSaved.provider; - savedState = loginSaved.state; + provider = loginSaved.provider; + state = loginSaved.state; } } const handle = async () => { - if (mode === 'link-browser') { + if (mode === 'link-browser' && provider && state) { // Browser linking: user is authenticated, complete via JWT-protected endpoint try { const response = await authApi.linkProviderCallback( - savedProvider!, + provider, code, - savedState!, + state, deviceId ?? undefined, ); if (response.merge_required && response.merge_token) { @@ -109,24 +124,23 @@ export default function OAuthCallback() { navigate('/profile/accounts', { replace: true }); } } catch { + setErrorMode('link-browser'); setError(t('profile.accounts.linkError')); } return; } - if (mode === 'login') { + if (mode === 'login' && provider && state) { // Login flow if (isAuthenticated) { navigate('/', { replace: true }); return; } try { - await loginWithOAuth(savedProvider!, code, savedState!, deviceId); + await loginWithOAuth(provider, code, state, deviceId); navigate('/', { replace: true }); } catch (err: unknown) { - const detail = - (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? - (err instanceof Error ? err.message : null); + const detail = getErrorDetail(err); setError(detail || t('auth.oauthError', 'Authorization was denied or failed')); } return; @@ -141,9 +155,9 @@ export default function OAuthCallback() { try { // Provider is resolved server-side from the state token in Redis. const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined); - setServerLinkResult(response); } catch { + setErrorMode('link-server'); setError(t('profile.accounts.linkError')); } }; @@ -151,14 +165,9 @@ export default function OAuthCallback() { 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; - } - + // Server-complete result: show success with "Return to Telegram" link + // (merge redirect is handled by the useEffect above) + if (serverLinkResult && !(serverLinkResult.merge_required && serverLinkResult.merge_token)) { const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; @@ -197,6 +206,10 @@ export default function OAuthCallback() { } if (error) { + const isServerMode = errorMode === 'link-server'; + const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; + const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; + return (
@@ -219,12 +232,21 @@ export default function OAuthCallback() {

{t('auth.loginFailed')}

{error}

- + {isServerMode && telegramLink ? ( + + {t('profile.accounts.openTelegram')} + + ) : ( + + )}