fix: harden merge UI and improve error handling

- MergeAccounts: guard mutationFn against null token/userId
- MergeAccounts: call checkAdminStatus after successful merge
- MergeAccounts: add fallback when success but tokens null
- MergeAccounts: fix ErrorState showing "expired" for all errors
- MergeAccounts: fix formatCountdown dead ternary and hardcoded English
- MergeAccounts: add staleTime/refetchOnWindowFocus to preview query
- MergeAccounts: remove manual useCallback (React Compiler handles it)
- LinkOAuthCallback: add toast feedback on all error/success paths
- ConnectedAccounts: use invalidateQueries instead of refetch
- ConnectedAccounts: add error state rendering
- ConnectedAccounts/LinkOAuthCallback: share sessionStorage key constants
- i18n: add missing profile.accounts.linking key to zh and fa locales
This commit is contained in:
Fringg
2026-03-04 07:47:36 +03:00
parent 93f97d45be
commit 58cf1e3b50
5 changed files with 53 additions and 22 deletions

View File

@@ -3049,6 +3049,7 @@
"linkError": "اتصال حساب ناموفق بود", "linkError": "اتصال حساب ناموفق بود",
"unlinkError": "قطع اتصال ناموفق بود", "unlinkError": "قطع اتصال ناموفق بود",
"goToAccounts": "حساب‌های متصل", "goToAccounts": "حساب‌های متصل",
"linking": "در حال اتصال حساب...",
"providers": { "providers": {
"telegram": "تلگرام", "telegram": "تلگرام",
"email": "ایمیل", "email": "ایمیل",

View File

@@ -3048,6 +3048,7 @@
"linkError": "关联账户失败", "linkError": "关联账户失败",
"unlinkError": "取消关联失败", "unlinkError": "取消关联失败",
"goToAccounts": "关联账户", "goToAccounts": "关联账户",
"linking": "正在关联账户...",
"providers": { "providers": {
"telegram": "Telegram", "telegram": "Telegram",
"email": "邮箱", "email": "邮箱",

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; 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 { motion } from 'framer-motion';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
@@ -7,6 +7,7 @@ import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import OAuthProviderIcon from '../components/OAuthProviderIcon'; import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback';
import type { LinkedProvider } from '../types'; import type { LinkedProvider } from '../types';
const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk'];
@@ -78,8 +79,9 @@ function LoadingSkeleton() {
export default function ConnectedAccounts() { export default function ConnectedAccounts() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showToast } = useToast(); const { showToast } = useToast();
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ['linked-providers'], queryKey: ['linked-providers'],
queryFn: () => authApi.getLinkedProviders(), queryFn: () => authApi.getLinkedProviders(),
}); });
@@ -87,7 +89,7 @@ export default function ConnectedAccounts() {
const unlinkMutation = useMutation({ const unlinkMutation = useMutation({
mutationFn: (provider: string) => authApi.unlinkProvider(provider), mutationFn: (provider: string) => authApi.unlinkProvider(provider),
onSuccess: () => { onSuccess: () => {
refetch(); queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ showToast({
type: 'success', type: 'success',
message: t('profile.accounts.unlinkSuccess'), message: t('profile.accounts.unlinkSuccess'),
@@ -111,8 +113,8 @@ export default function ConnectedAccounts() {
const handleLink = async (provider: string) => { const handleLink = async (provider: string) => {
try { try {
const { authorize_url, state } = await authApi.linkProviderInit(provider); const { authorize_url, state } = await authApi.linkProviderInit(provider);
sessionStorage.setItem('link_oauth_state', state); sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state);
sessionStorage.setItem('link_oauth_provider', provider); sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider);
window.location.href = authorize_url; window.location.href = authorize_url;
} catch { } catch {
showToast({ showToast({
@@ -150,6 +152,15 @@ export default function ConnectedAccounts() {
</motion.div> </motion.div>
)} )}
{/* Error state */}
{isError && (
<motion.div variants={staggerItem}>
<Card>
<p className="text-center text-dark-400">{t('common.error')}</p>
</Card>
</motion.div>
)}
{/* Provider cards */} {/* Provider cards */}
{data?.providers.map((provider) => ( {data?.providers.map((provider) => (
<motion.div key={provider.provider} variants={staggerItem}> <motion.div key={provider.provider} variants={staggerItem}>

View File

@@ -2,10 +2,11 @@ import { useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth'; import { authApi } from '../api/auth';
import { useToast } from '../components/Toast';
// SessionStorage keys (set by ConnectedAccounts.tsx handleLink) // SessionStorage keys — shared with ConnectedAccounts.tsx
const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider'; export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider';
function getAndClearLinkOAuthState(): { state: string; provider: string } | null { function getAndClearLinkOAuthState(): { state: string; provider: string } | null {
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY); const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
@@ -20,6 +21,7 @@ export default function LinkOAuthCallback() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { showToast } = useToast();
const hasRun = useRef(false); const hasRun = useRef(false);
useEffect(() => { useEffect(() => {
@@ -34,6 +36,7 @@ export default function LinkOAuthCallback() {
const deviceId = searchParams.get('device_id'); const deviceId = searchParams.get('device_id');
if (!code || !urlState) { if (!code || !urlState) {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
return; return;
} }
@@ -41,12 +44,14 @@ export default function LinkOAuthCallback() {
// Get saved state from sessionStorage // Get saved state from sessionStorage
const saved = getAndClearLinkOAuthState(); const saved = getAndClearLinkOAuthState();
if (!saved) { if (!saved) {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
return; return;
} }
// Validate state match // Validate state match
if (saved.state !== urlState) { if (saved.state !== urlState) {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
return; return;
} }
@@ -64,15 +69,17 @@ export default function LinkOAuthCallback() {
navigate(`/merge/${response.merge_token}`, { replace: true }); navigate(`/merge/${response.merge_token}`, { replace: true });
} else { } else {
// Success - redirect back to accounts // Success - redirect back to accounts
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
} }
} catch { } catch {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
} }
}; };
linkAccount(); linkAccount();
}, [searchParams, navigate]); }, [searchParams, navigate, showToast, t]);
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router'; import { useParams, useNavigate, Link } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
@@ -114,10 +114,7 @@ function ProviderBadgeIcon({ provider }: { provider: string }) {
function formatCountdown(seconds: number): string { function formatCountdown(seconds: number): string {
const min = Math.floor(seconds / 60); const min = Math.floor(seconds / 60);
const sec = seconds % 60; const sec = seconds % 60;
if (min > 0) { return `${min}:${sec.toString().padStart(2, '0')}`;
return `${min} ${min === 1 ? 'min' : 'min'} ${sec.toString().padStart(2, '0')} ${sec === 1 ? 'sec' : 'sec'}`;
}
return `${sec} sec`;
} }
function formatDate(dateStr: string | null): string { function formatDate(dateStr: string | null): string {
@@ -329,7 +326,7 @@ function ErrorState() {
</motion.div> </motion.div>
<motion.div variants={staggerItem} className="text-center"> <motion.div variants={staggerItem} className="text-center">
<p className="text-lg font-medium text-dark-100">{t('merge.expired')}</p> <p className="text-lg font-medium text-dark-100">{t('merge.error')}</p>
</motion.div> </motion.div>
<motion.div variants={staggerItem}> <motion.div variants={staggerItem}>
@@ -362,6 +359,8 @@ export default function MergeAccounts() {
queryFn: () => authApi.getMergePreview(mergeToken!), queryFn: () => authApi.getMergePreview(mergeToken!),
enabled: !!mergeToken, enabled: !!mergeToken,
retry: false, retry: false,
staleTime: Infinity,
refetchOnWindowFocus: false,
}); });
// Auto-select subscription when data loads // Auto-select subscription when data loads
@@ -403,14 +402,26 @@ export default function MergeAccounts() {
// Execute merge // Execute merge
const mergeMutation = useMutation({ const mergeMutation = useMutation({
mutationFn: () => authApi.executeMerge(mergeToken!, selectedUserId!), mutationFn: () => {
onSuccess: (response) => { 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) { 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); setTokens(response.access_token, response.refresh_token);
if (response.user) { if (response.user) {
setUser(response.user); setUser(response.user);
} }
await checkAdminStatus();
showToast({
type: 'success',
message: t('merge.success'),
});
navigate('/', { replace: true });
} else {
showToast({ showToast({
type: 'success', type: 'success',
message: t('merge.success'), message: t('merge.success'),
@@ -426,14 +437,14 @@ export default function MergeAccounts() {
}, },
}); });
const handleMerge = useCallback(() => { const handleMerge = () => {
if (!selectedUserId || mergeMutation.isPending || isExpired) return; if (!selectedUserId || mergeMutation.isPending || isExpired) return;
mergeMutation.mutate(); mergeMutation.mutate();
}, [selectedUserId, mergeMutation, isExpired]); };
const handleCancel = useCallback(() => { const handleCancel = () => {
navigate('/profile/accounts', { replace: true }); navigate('/profile/accounts', { replace: true });
}, [navigate]); };
// Derived state // Derived state
const bothHaveSubscriptions = const bothHaveSubscriptions =