fix: double-click guard on link, wall-clock timer, blur cleanup

- Add linkingProvider state to prevent double-click on Link button
- Use wall-clock based countdown timer to prevent drift
- Clean up onBlur setTimeout ref on unmount
This commit is contained in:
Fringg
2026-03-04 16:05:24 +03:00
parent fba4481799
commit 8ad0500cc8
2 changed files with 31 additions and 14 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -42,6 +42,14 @@ export default function ConnectedAccounts() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null); const [confirmingUnlink, setConfirmingUnlink] = useState<string | null>(null);
const [linkingProvider, setLinkingProvider] = useState<string | null>(null);
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
};
}, []);
const { data, isLoading, isError } = useQuery({ const { data, isLoading, isError } = useQuery({
queryKey: ['linked-providers'], queryKey: ['linked-providers'],
@@ -76,6 +84,8 @@ export default function ConnectedAccounts() {
}; };
const handleLink = async (provider: string) => { const handleLink = async (provider: string) => {
if (linkingProvider) return;
setLinkingProvider(provider);
try { try {
const { authorize_url, state } = await authApi.linkProviderInit(provider); const { authorize_url, state } = await authApi.linkProviderInit(provider);
sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state); sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state);
@@ -86,6 +96,7 @@ export default function ConnectedAccounts() {
type: 'error', type: 'error',
message: t('profile.accounts.linkError'), message: t('profile.accounts.linkError'),
}); });
setLinkingProvider(null);
} }
}; };
@@ -160,7 +171,7 @@ export default function ConnectedAccounts() {
onClick={() => handleUnlink(provider.provider)} onClick={() => handleUnlink(provider.provider)}
onBlur={() => { onBlur={() => {
// Delay so click on the same button fires before blur resets state // Delay so click on the same button fires before blur resets state
setTimeout(() => { blurTimeoutRef.current = setTimeout(() => {
setConfirmingUnlink((cur) => (cur === provider.provider ? null : cur)); setConfirmingUnlink((cur) => (cur === provider.provider ? null : cur));
}, 150); }, 150);
}} }}
@@ -176,6 +187,8 @@ export default function ConnectedAccounts() {
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
disabled={linkingProvider !== null}
loading={linkingProvider === provider.provider}
onClick={() => handleLink(provider.provider)} onClick={() => handleLink(provider.provider)}
> >
{t('profile.accounts.link')} {t('profile.accounts.link')}

View File

@@ -357,22 +357,26 @@ export default function MergeAccounts() {
// If both have subs — null until user picks // If both have subs — null until user picks
}, [data, selectedUserId]); }, [data, selectedUserId]);
// Countdown timer // Countdown timer (wall-clock based to avoid drift)
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
setExpiresIn(data.expires_in_seconds); const startTime = Date.now();
const totalSeconds = data.expires_in_seconds;
const interval = setInterval(() => { const tick = () => {
setExpiresIn((prev) => { const elapsed = Math.floor((Date.now() - startTime) / 1000);
if (prev <= 1) { const remaining = totalSeconds - elapsed;
clearInterval(interval); if (remaining <= 0) {
setIsExpired(true); setExpiresIn(0);
return 0; setIsExpired(true);
} clearInterval(interval);
return prev - 1; } else {
}); setExpiresIn(remaining);
}, 1000); }
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [data]); }, [data]);