mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -3049,6 +3049,7 @@
|
|||||||
"linkError": "اتصال حساب ناموفق بود",
|
"linkError": "اتصال حساب ناموفق بود",
|
||||||
"unlinkError": "قطع اتصال ناموفق بود",
|
"unlinkError": "قطع اتصال ناموفق بود",
|
||||||
"goToAccounts": "حسابهای متصل",
|
"goToAccounts": "حسابهای متصل",
|
||||||
|
"linking": "در حال اتصال حساب...",
|
||||||
"providers": {
|
"providers": {
|
||||||
"telegram": "تلگرام",
|
"telegram": "تلگرام",
|
||||||
"email": "ایمیل",
|
"email": "ایمیل",
|
||||||
|
|||||||
@@ -3048,6 +3048,7 @@
|
|||||||
"linkError": "关联账户失败",
|
"linkError": "关联账户失败",
|
||||||
"unlinkError": "取消关联失败",
|
"unlinkError": "取消关联失败",
|
||||||
"goToAccounts": "关联账户",
|
"goToAccounts": "关联账户",
|
||||||
|
"linking": "正在关联账户...",
|
||||||
"providers": {
|
"providers": {
|
||||||
"telegram": "Telegram",
|
"telegram": "Telegram",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user