mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
fix: second round review fixes for merge UI
- Fix false success toast when response.success is false (CRITICAL) - Remove mergeToken! non-null assertion in queryFn - Add early return for missing mergeToken param - Zero-pad minutes in formatCountdown (MM:SS format) - Clamp negative seconds in formatCountdown - Block all unlink buttons while any unlink mutation is pending - Clear OAuth state only after validation succeeds (not before) - Split getAndClearLinkOAuthState into read + clear functions
This commit is contained in:
@@ -106,6 +106,7 @@ export default function ConnectedAccounts() {
|
|||||||
const canUnlink = (provider: LinkedProvider): boolean => {
|
const canUnlink = (provider: LinkedProvider): boolean => {
|
||||||
if (!provider.linked) return false;
|
if (!provider.linked) return false;
|
||||||
if (!isOAuthProvider(provider.provider)) return false;
|
if (!isOAuthProvider(provider.provider)) return false;
|
||||||
|
if (unlinkMutation.isPending) return false;
|
||||||
const linkedCount = data?.providers.filter((p) => p.linked).length ?? 0;
|
const linkedCount = data?.providers.filter((p) => p.linked).length ?? 0;
|
||||||
return linkedCount > 1;
|
return linkedCount > 1;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,15 +8,18 @@ import { useToast } from '../components/Toast';
|
|||||||
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
|
export const LINK_OAUTH_STATE_KEY = 'link_oauth_state';
|
||||||
export 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 getLinkOAuthState(): { state: string; provider: string } | null {
|
||||||
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
|
const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY);
|
||||||
const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY);
|
const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY);
|
||||||
sessionStorage.removeItem(LINK_OAUTH_STATE_KEY);
|
|
||||||
sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY);
|
|
||||||
if (!state || !provider) return null;
|
if (!state || !provider) return null;
|
||||||
return { state, provider };
|
return { state, provider };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearLinkOAuthState(): void {
|
||||||
|
sessionStorage.removeItem(LINK_OAUTH_STATE_KEY);
|
||||||
|
sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LinkOAuthCallback() {
|
export default function LinkOAuthCallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -41,8 +44,8 @@ export default function LinkOAuthCallback() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get saved state from sessionStorage
|
// Get saved state from sessionStorage (read only, don't clear yet)
|
||||||
const saved = getAndClearLinkOAuthState();
|
const saved = getLinkOAuthState();
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
showToast({ type: 'error', message: t('profile.accounts.linkError') });
|
||||||
navigate('/profile/accounts', { replace: true });
|
navigate('/profile/accounts', { replace: true });
|
||||||
@@ -56,6 +59,9 @@ export default function LinkOAuthCallback() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State validated — clear it now (one-time use)
|
||||||
|
clearLinkOAuthState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authApi.linkProviderCallback(
|
const response = await authApi.linkProviderCallback(
|
||||||
saved.provider,
|
saved.provider,
|
||||||
|
|||||||
@@ -112,9 +112,10 @@ function ProviderBadgeIcon({ provider }: { provider: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatCountdown(seconds: number): string {
|
function formatCountdown(seconds: number): string {
|
||||||
const min = Math.floor(seconds / 60);
|
const clamped = Math.max(0, seconds);
|
||||||
const sec = seconds % 60;
|
const min = Math.floor(clamped / 60);
|
||||||
return `${min}:${sec.toString().padStart(2, '0')}`;
|
const sec = clamped % 60;
|
||||||
|
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
function formatDate(dateStr: string | null): string {
|
||||||
@@ -356,7 +357,10 @@ export default function MergeAccounts() {
|
|||||||
// Fetch merge preview (no auth required)
|
// Fetch merge preview (no auth required)
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['merge-preview', mergeToken],
|
queryKey: ['merge-preview', mergeToken],
|
||||||
queryFn: () => authApi.getMergePreview(mergeToken!),
|
queryFn: () => {
|
||||||
|
if (!mergeToken) return Promise.reject(new Error('Missing merge token'));
|
||||||
|
return authApi.getMergePreview(mergeToken);
|
||||||
|
},
|
||||||
enabled: !!mergeToken,
|
enabled: !!mergeToken,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
@@ -409,25 +413,22 @@ export default function MergeAccounts() {
|
|||||||
return authApi.executeMerge(mergeToken, selectedUserId);
|
return authApi.executeMerge(mergeToken, selectedUserId);
|
||||||
},
|
},
|
||||||
onSuccess: async (response) => {
|
onSuccess: async (response) => {
|
||||||
if (response.success && response.access_token && response.refresh_token) {
|
if (!response.success) {
|
||||||
|
showToast({ type: 'error', message: t('merge.error') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.access_token && response.refresh_token) {
|
||||||
const { setTokens, setUser, checkAdminStatus } = 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();
|
await checkAdminStatus();
|
||||||
showToast({
|
|
||||||
type: 'success',
|
|
||||||
message: t('merge.success'),
|
|
||||||
});
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
} else {
|
|
||||||
showToast({
|
|
||||||
type: 'success',
|
|
||||||
message: t('merge.success'),
|
|
||||||
});
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showToast({ type: 'success', message: t('merge.success') });
|
||||||
|
navigate('/', { replace: true });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -452,6 +453,11 @@ export default function MergeAccounts() {
|
|||||||
const canConfirm = selectedUserId !== null && !isExpired && !mergeMutation.isPending;
|
const canConfirm = selectedUserId !== null && !isExpired && !mergeMutation.isPending;
|
||||||
const combinedBalance = data ? data.primary.balance_kopeks + data.secondary.balance_kopeks : 0;
|
const combinedBalance = data ? data.primary.balance_kopeks + data.secondary.balance_kopeks : 0;
|
||||||
|
|
||||||
|
// Missing token param
|
||||||
|
if (!mergeToken) {
|
||||||
|
return <ErrorState />;
|
||||||
|
}
|
||||||
|
|
||||||
// Loading
|
// Loading
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingSkeleton />;
|
return <LoadingSkeleton />;
|
||||||
|
|||||||
Reference in New Issue
Block a user