diff --git a/src/locales/fa.json b/src/locales/fa.json index bd2db2b..b6a3364 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3049,6 +3049,7 @@ "linkError": "اتصال حساب ناموفق بود", "unlinkError": "قطع اتصال ناموفق بود", "goToAccounts": "حساب‌های متصل", + "linking": "در حال اتصال حساب...", "providers": { "telegram": "تلگرام", "email": "ایمیل", diff --git a/src/locales/zh.json b/src/locales/zh.json index 599436c..2903c4d 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3048,6 +3048,7 @@ "linkError": "关联账户失败", "unlinkError": "取消关联失败", "goToAccounts": "关联账户", + "linking": "正在关联账户...", "providers": { "telegram": "Telegram", "email": "邮箱", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index f0714c4..bb449f1 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { authApi } from '../api/auth'; import { useToast } from '../components/Toast'; @@ -7,6 +7,7 @@ import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import OAuthProviderIcon from '../components/OAuthProviderIcon'; +import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; import type { LinkedProvider } from '../types'; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; @@ -78,8 +79,9 @@ function LoadingSkeleton() { export default function ConnectedAccounts() { const { t } = useTranslation(); const { showToast } = useToast(); + const queryClient = useQueryClient(); - const { data, isLoading, refetch } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ['linked-providers'], queryFn: () => authApi.getLinkedProviders(), }); @@ -87,7 +89,7 @@ export default function ConnectedAccounts() { const unlinkMutation = useMutation({ mutationFn: (provider: string) => authApi.unlinkProvider(provider), onSuccess: () => { - refetch(); + queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); showToast({ type: 'success', message: t('profile.accounts.unlinkSuccess'), @@ -111,8 +113,8 @@ export default function ConnectedAccounts() { const handleLink = async (provider: string) => { try { const { authorize_url, state } = await authApi.linkProviderInit(provider); - sessionStorage.setItem('link_oauth_state', state); - sessionStorage.setItem('link_oauth_provider', provider); + sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state); + sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); window.location.href = authorize_url; } catch { showToast({ @@ -150,6 +152,15 @@ export default function ConnectedAccounts() { )} + {/* Error state */} + {isError && ( + + +

{t('common.error')}

+
+
+ )} + {/* Provider cards */} {data?.providers.map((provider) => ( diff --git a/src/pages/LinkOAuthCallback.tsx b/src/pages/LinkOAuthCallback.tsx index 257b014..bb57e2e 100644 --- a/src/pages/LinkOAuthCallback.tsx +++ b/src/pages/LinkOAuthCallback.tsx @@ -2,10 +2,11 @@ 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 (set by ConnectedAccounts.tsx handleLink) -const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; -const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider'; +// 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 getAndClearLinkOAuthState(): { state: string; provider: string } | null { const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY); @@ -20,6 +21,7 @@ export default function LinkOAuthCallback() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const { showToast } = useToast(); const hasRun = useRef(false); useEffect(() => { @@ -34,6 +36,7 @@ export default function LinkOAuthCallback() { const deviceId = searchParams.get('device_id'); if (!code || !urlState) { + showToast({ type: 'error', message: t('profile.accounts.linkError') }); navigate('/profile/accounts', { replace: true }); return; } @@ -41,12 +44,14 @@ export default function LinkOAuthCallback() { // Get saved state from sessionStorage const saved = getAndClearLinkOAuthState(); if (!saved) { + showToast({ type: 'error', message: t('profile.accounts.linkError') }); navigate('/profile/accounts', { replace: true }); return; } // Validate state match if (saved.state !== urlState) { + showToast({ type: 'error', message: t('profile.accounts.linkError') }); navigate('/profile/accounts', { replace: true }); return; } @@ -64,15 +69,17 @@ export default function LinkOAuthCallback() { 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]); + }, [searchParams, navigate, showToast, t]); return (
diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx index bfe994f..c1f36f7 100644 --- a/src/pages/MergeAccounts.tsx +++ b/src/pages/MergeAccounts.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation } from '@tanstack/react-query'; @@ -114,10 +114,7 @@ function ProviderBadgeIcon({ provider }: { provider: string }) { function formatCountdown(seconds: number): string { const min = Math.floor(seconds / 60); const sec = seconds % 60; - if (min > 0) { - return `${min} ${min === 1 ? 'min' : 'min'} ${sec.toString().padStart(2, '0')} ${sec === 1 ? 'sec' : 'sec'}`; - } - return `${sec} sec`; + return `${min}:${sec.toString().padStart(2, '0')}`; } function formatDate(dateStr: string | null): string { @@ -329,7 +326,7 @@ function ErrorState() { -

{t('merge.expired')}

+

{t('merge.error')}

@@ -362,6 +359,8 @@ export default function MergeAccounts() { queryFn: () => authApi.getMergePreview(mergeToken!), enabled: !!mergeToken, retry: false, + staleTime: Infinity, + refetchOnWindowFocus: false, }); // Auto-select subscription when data loads @@ -403,14 +402,26 @@ export default function MergeAccounts() { // Execute merge const mergeMutation = useMutation({ - mutationFn: () => authApi.executeMerge(mergeToken!, selectedUserId!), - onSuccess: (response) => { + mutationFn: () => { + if (!mergeToken || !selectedUserId) { + return Promise.reject(new Error('Missing merge token or user selection')); + } + return authApi.executeMerge(mergeToken, selectedUserId); + }, + onSuccess: async (response) => { if (response.success && response.access_token && response.refresh_token) { - const { setTokens, setUser } = useAuthStore.getState(); + const { setTokens, setUser, checkAdminStatus } = useAuthStore.getState(); setTokens(response.access_token, response.refresh_token); if (response.user) { setUser(response.user); } + await checkAdminStatus(); + showToast({ + type: 'success', + message: t('merge.success'), + }); + navigate('/', { replace: true }); + } else { showToast({ type: 'success', message: t('merge.success'), @@ -426,14 +437,14 @@ export default function MergeAccounts() { }, }); - const handleMerge = useCallback(() => { + const handleMerge = () => { if (!selectedUserId || mergeMutation.isPending || isExpired) return; mergeMutation.mutate(); - }, [selectedUserId, mergeMutation, isExpired]); + }; - const handleCancel = useCallback(() => { + const handleCancel = () => { navigate('/profile/accounts', { replace: true }); - }, [navigate]); + }; // Derived state const bothHaveSubscriptions =