From 93f97d45bec4ac4ac893475edd3e79107fe5806b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 07:25:45 +0300 Subject: [PATCH 01/21] feat: account linking and merge UI for cabinet Add Connected Accounts page (link/unlink OAuth providers), Link OAuth Callback handler, and Merge Accounts page with subscription comparison and user choice. Includes API methods, TypeScript types, routes in App.tsx, navigation from Profile, and i18n for all 4 locales (ru, en, zh, fa). Merge page works without JWT auth (validated by merge token). --- src/App.tsx | 31 ++ src/api/auth.ts | 72 +++- src/api/client.ts | 1 + src/locales/en.json | 51 +++ src/locales/fa.json | 50 +++ src/locales/ru.json | 51 +++ src/locales/zh.json | 50 +++ src/pages/ConnectedAccounts.tsx | 205 ++++++++++++ src/pages/LinkOAuthCallback.tsx | 87 +++++ src/pages/MergeAccounts.tsx | 577 ++++++++++++++++++++++++++++++++ src/pages/Profile.tsx | 26 +- src/types/index.ts | 54 +++ 12 files changed, 1253 insertions(+), 2 deletions(-) create mode 100644 src/pages/ConnectedAccounts.tsx create mode 100644 src/pages/LinkOAuthCallback.tsx create mode 100644 src/pages/MergeAccounts.tsx diff --git a/src/App.tsx b/src/App.tsx index e03ee71..eb2d837 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,9 @@ const Connection = lazy(() => import('./pages/Connection')); const ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); +const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts')); +const LinkOAuthCallback = lazy(() => import('./pages/LinkOAuthCallback')); +const MergeAccounts = lazy(() => import('./pages/MergeAccounts')); // Admin pages - lazy load (only for admins) const AdminPanel = lazy(() => import('./pages/AdminPanel')); @@ -183,6 +186,14 @@ function App() { } /> } /> } /> + + + + } + /> {/* Protected routes */} } /> + + + + + + } + /> + + + + + + } + /> => { + const response = await apiClient.get( + '/cabinet/auth/account/linked-providers', + ); + return response.data; + }, + + linkProviderInit: async (provider: string): Promise<{ authorize_url: string; state: string }> => { + const response = await apiClient.get<{ authorize_url: string; state: string }>( + `/cabinet/auth/account/link/${encodeURIComponent(provider)}/init`, + ); + return response.data; + }, + + linkProviderCallback: async ( + provider: string, + code: string, + state: string, + deviceId?: string, + ): Promise => { + const response = await apiClient.post( + `/cabinet/auth/account/link/${encodeURIComponent(provider)}/callback`, + { + code, + state, + device_id: deviceId, + }, + ); + return response.data; + }, + + unlinkProvider: async (provider: string): Promise<{ success: boolean }> => { + const response = await apiClient.post<{ success: boolean }>( + `/cabinet/auth/account/unlink/${encodeURIComponent(provider)}`, + ); + return response.data; + }, + + // Account merge (no JWT required) + getMergePreview: async (mergeToken: string): Promise => { + const response = await apiClient.get( + `/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`, + ); + return response.data; + }, + + executeMerge: async ( + mergeToken: string, + keepSubscriptionFrom: number, + ): Promise => { + const response = await apiClient.post( + `/cabinet/auth/merge/${encodeURIComponent(mergeToken)}`, + { + keep_subscription_from: keepSubscriptionFrom, + }, + ); + return response.data; + }, }; diff --git a/src/api/client.ts b/src/api/client.ts index 0b344a0..5b5034a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -75,6 +75,7 @@ const AUTH_ENDPOINTS = [ '/cabinet/auth/password/forgot', '/cabinet/auth/password/reset', '/cabinet/auth/oauth/', + '/cabinet/auth/merge/', ]; function isAuthEndpoint(url: string | undefined): boolean { diff --git a/src/locales/en.json b/src/locales/en.json index d3682fd..c771ce2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3598,6 +3598,30 @@ "promoOffers": "Promo Offers", "promoOffersDesc": "Receive special offers and discounts", "unavailable": "Notification settings unavailable" + }, + "accounts": { + "title": "Connected Accounts", + "subtitle": "Manage your sign-in methods", + "linked": "Connected", + "notLinked": "Not connected", + "link": "Connect", + "unlink": "Disconnect", + "unlinkConfirm": "Are you sure? You won't be able to sign in with this service after disconnecting.", + "cannotUnlinkLast": "Cannot disconnect the last sign-in method", + "linkSuccess": "Account connected successfully", + "unlinkSuccess": "Account disconnected successfully", + "linkError": "Failed to connect account", + "unlinkError": "Failed to disconnect account", + "goToAccounts": "Connected Accounts", + "linking": "Linking account...", + "providers": { + "telegram": "Telegram", + "email": "Email", + "google": "Google", + "yandex": "Yandex", + "discord": "Discord", + "vk": "VK" + } } }, "theme": { @@ -3807,5 +3831,32 @@ "reason": "Reason", "contactSupport": "If you believe this is an error, please contact support." } + }, + "merge": { + "title": "Merge Accounts", + "description": "This sign-in method is already linked to another account. You can merge both accounts into one.", + "currentAccount": "Your current account", + "foundAccount": "Found account", + "authMethods": "Sign-in methods", + "subscription": "Subscription", + "noSubscription": "No subscription", + "traffic": "Traffic", + "devices": "Devices", + "balance": "Balance", + "until": "until {{date}}", + "keepThisSubscription": "Keep this subscription", + "chooseSubscription": "Choose which subscription to keep", + "afterMerge": "After merging", + "allAuthMethodsMerged": "All sign-in methods will be combined", + "balanceSummed": "Balance: {{amount}} ₽ (combined)", + "unselectedSubscriptionDeleted": "The unselected subscription will be deleted", + "historyPreserved": "Transaction history will be preserved", + "confirm": "Merge Accounts", + "cancel": "Cancel", + "expired": "The merge link has expired. Please try again.", + "success": "Accounts merged successfully!", + "error": "Failed to merge accounts. Please try again later.", + "expiresIn": "Expires in {{minutes}}", + "merging": "Merging..." } } diff --git a/src/locales/fa.json b/src/locales/fa.json index 00fb686..bd2db2b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3034,6 +3034,29 @@ "promoOffers": "پیشنهادات تبلیغاتی", "promoOffersDesc": "دریافت پیشنهادات ویژه و تخفیف‌ها", "unavailable": "تنظیمات اعلان در دسترس نیست" + }, + "accounts": { + "title": "حساب‌های متصل", + "subtitle": "مدیریت روش‌های ورود", + "linked": "متصل", + "notLinked": "متصل نیست", + "link": "اتصال", + "unlink": "قطع اتصال", + "unlinkConfirm": "آیا مطمئن هستید؟ پس از قطع اتصال نمی‌توانید از طریق این سرویس وارد شوید.", + "cannotUnlinkLast": "نمی‌توان آخرین روش ورود را قطع کرد", + "linkSuccess": "حساب با موفقیت متصل شد", + "unlinkSuccess": "اتصال حساب با موفقیت قطع شد", + "linkError": "اتصال حساب ناموفق بود", + "unlinkError": "قطع اتصال ناموفق بود", + "goToAccounts": "حساب‌های متصل", + "providers": { + "telegram": "تلگرام", + "email": "ایمیل", + "google": "گوگل", + "yandex": "یاندکس", + "discord": "دیسکورد", + "vk": "VK" + } } }, "theme": { @@ -3358,5 +3381,32 @@ "reason": "دلیل", "contactSupport": "اگر فکر می‌کنید این اشتباه است، با پشتیبانی تماس بگیرید." } + }, + "merge": { + "title": "ادغام حساب‌ها", + "description": "این روش ورود به حساب دیگری متصل است. می‌توانید هر دو حساب را ادغام کنید.", + "currentAccount": "حساب فعلی شما", + "foundAccount": "حساب یافت شده", + "authMethods": "روش‌های ورود", + "subscription": "اشتراک", + "noSubscription": "بدون اشتراک", + "traffic": "ترافیک", + "devices": "دستگاه‌ها", + "balance": "موجودی", + "until": "تا {{date}}", + "keepThisSubscription": "نگه داشتن این اشتراک", + "chooseSubscription": "اشتراک مورد نظر را انتخاب کنید", + "afterMerge": "پس از ادغام", + "allAuthMethodsMerged": "تمام روش‌های ورود ادغام خواهند شد", + "balanceSummed": "موجودی: {{amount}} ₽ (مجموع)", + "unselectedSubscriptionDeleted": "اشتراک انتخاب نشده حذف خواهد شد", + "historyPreserved": "تاریخچه تراکنش‌ها حفظ خواهد شد", + "confirm": "ادغام حساب‌ها", + "cancel": "لغو", + "expired": "لینک ادغام منقضی شده است. لطفاً دوباره تلاش کنید.", + "success": "حساب‌ها با موفقیت ادغام شدند!", + "error": "ادغام ناموفق بود. لطفاً بعداً دوباره تلاش کنید.", + "expiresIn": "{{minutes}} دقیقه باقی مانده", + "merging": "در حال ادغام..." } } diff --git a/src/locales/ru.json b/src/locales/ru.json index 82883d4..e19a81a 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4158,6 +4158,30 @@ "promoOffers": "Промо-предложения", "promoOffersDesc": "Получать специальные предложения и скидки", "unavailable": "Настройки уведомлений недоступны" + }, + "accounts": { + "title": "Привязанные аккаунты", + "subtitle": "Управляйте способами входа в кабинет", + "linked": "Привязан", + "notLinked": "Не привязан", + "link": "Привязать", + "unlink": "Отвязать", + "unlinkConfirm": "Вы уверены? После отвязки вы не сможете входить через этот сервис.", + "cannotUnlinkLast": "Нельзя отвязать последний способ входа", + "linkSuccess": "Аккаунт успешно привязан", + "unlinkSuccess": "Аккаунт успешно отвязан", + "linkError": "Не удалось привязать аккаунт", + "unlinkError": "Не удалось отвязать аккаунт", + "goToAccounts": "Привязанные аккаунты", + "linking": "Привязка аккаунта...", + "providers": { + "telegram": "Telegram", + "email": "Email", + "google": "Google", + "yandex": "Яндекс", + "discord": "Discord", + "vk": "VK" + } } }, "theme": { @@ -4370,5 +4394,32 @@ "reason": "Причина", "contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку." } + }, + "merge": { + "title": "Объединение аккаунтов", + "description": "Этот способ входа уже привязан к другому аккаунту. Вы можете объединить два аккаунта в один.", + "currentAccount": "Ваш текущий аккаунт", + "foundAccount": "Найденный аккаунт", + "authMethods": "Способы входа", + "subscription": "Подписка", + "noSubscription": "Нет подписки", + "traffic": "Трафик", + "devices": "Устройства", + "balance": "Баланс", + "until": "до {{date}}", + "keepThisSubscription": "Оставить эту подписку", + "chooseSubscription": "Выберите подписку, которую хотите сохранить", + "afterMerge": "После объединения", + "allAuthMethodsMerged": "Все способы входа объединятся", + "balanceSummed": "Баланс: {{amount}} ₽ (сумма)", + "unselectedSubscriptionDeleted": "Невыбранная подписка будет удалена", + "historyPreserved": "История операций сохранится", + "confirm": "Объединить аккаунты", + "cancel": "Отмена", + "expired": "Время на объединение истекло. Попробуйте снова.", + "success": "Аккаунты успешно объединены!", + "error": "Ошибка при объединении. Попробуйте позже.", + "expiresIn": "Действует ещё {{minutes}}", + "merging": "Объединение..." } } diff --git a/src/locales/zh.json b/src/locales/zh.json index cb7f347..599436c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3033,6 +3033,29 @@ "promoOffers": "促销活动", "promoOffersDesc": "接收特别优惠和折扣", "unavailable": "通知设置不可用" + }, + "accounts": { + "title": "关联账户", + "subtitle": "管理您的登录方式", + "linked": "已关联", + "notLinked": "未关联", + "link": "关联", + "unlink": "取消关联", + "unlinkConfirm": "确定吗?取消关联后,您将无法通过此服务登录。", + "cannotUnlinkLast": "无法取消最后一种登录方式", + "linkSuccess": "账户关联成功", + "unlinkSuccess": "账户取消关联成功", + "linkError": "关联账户失败", + "unlinkError": "取消关联失败", + "goToAccounts": "关联账户", + "providers": { + "telegram": "Telegram", + "email": "邮箱", + "google": "Google", + "yandex": "Yandex", + "discord": "Discord", + "vk": "VK" + } } }, "theme": { @@ -3357,5 +3380,32 @@ "requests": "请求数", "ipHistory": "IP历史" } + }, + "merge": { + "title": "合并账户", + "description": "此登录方式已关联到另一个账户。您可以将两个账户合并为一个。", + "currentAccount": "您的当前账户", + "foundAccount": "发现的账户", + "authMethods": "登录方式", + "subscription": "订阅", + "noSubscription": "无订阅", + "traffic": "流量", + "devices": "设备", + "balance": "余额", + "until": "至 {{date}}", + "keepThisSubscription": "保留此订阅", + "chooseSubscription": "选择要保留的订阅", + "afterMerge": "合并后", + "allAuthMethodsMerged": "所有登录方式将合并", + "balanceSummed": "余额:{{amount}} ₽(合计)", + "unselectedSubscriptionDeleted": "未选择的订阅将被删除", + "historyPreserved": "交易记录将保留", + "confirm": "合并账户", + "cancel": "取消", + "expired": "合并链接已过期,请重试。", + "success": "账户合并成功!", + "error": "合并失败,请稍后重试。", + "expiresIn": "剩余 {{minutes}} 分钟", + "merging": "合并中..." } } diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx new file mode 100644 index 0000000..f0714c4 --- /dev/null +++ b/src/pages/ConnectedAccounts.tsx @@ -0,0 +1,205 @@ +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { authApi } from '../api/auth'; +import { useToast } from '../components/Toast'; +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 type { LinkedProvider } from '../types'; + +const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; + +const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider); + +// Icons for providers not covered by OAuthProviderIcon +function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ); +} + +function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ); +} + +function ProviderIcon({ provider }: { provider: string }) { + switch (provider) { + case 'telegram': + return ; + case 'email': + return ; + default: + return ; + } +} + +function LoadingSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + +
+
+
+
+
+
+
+
+
+
+ + ))} +
+ ); +} + +export default function ConnectedAccounts() { + const { t } = useTranslation(); + const { showToast } = useToast(); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['linked-providers'], + queryFn: () => authApi.getLinkedProviders(), + }); + + const unlinkMutation = useMutation({ + mutationFn: (provider: string) => authApi.unlinkProvider(provider), + onSuccess: () => { + refetch(); + showToast({ + type: 'success', + message: t('profile.accounts.unlinkSuccess'), + }); + }, + onError: () => { + showToast({ + type: 'error', + message: t('profile.accounts.unlinkError'), + }); + }, + }); + + const canUnlink = (provider: LinkedProvider): boolean => { + if (!provider.linked) return false; + if (!isOAuthProvider(provider.provider)) return false; + const linkedCount = data?.providers.filter((p) => p.linked).length ?? 0; + return linkedCount > 1; + }; + + 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); + window.location.href = authorize_url; + } catch { + showToast({ + type: 'error', + message: t('profile.accounts.linkError'), + }); + } + }; + + const handleUnlink = (provider: string) => { + if (window.confirm(t('profile.accounts.unlinkConfirm'))) { + unlinkMutation.mutate(provider); + } + }; + + return ( + + {/* Page title */} + +

+ {t('profile.accounts.title')} +

+

{t('profile.accounts.subtitle')}

+
+ + {/* Loading state */} + {isLoading && ( + + + + )} + + {/* Provider cards */} + {data?.providers.map((provider) => ( + + +
+
+ +
+

+ {t(`profile.accounts.providers.${provider.provider}`)} +

+ {provider.identifier && ( +

{provider.identifier}

+ )} +
+
+
+ {provider.linked ? ( + <> + {t('profile.accounts.linked')} + {canUnlink(provider) && ( + + )} + + ) : ( + isOAuthProvider(provider.provider) && ( + + ) + )} +
+
+
+
+ ))} +
+ ); +} diff --git a/src/pages/LinkOAuthCallback.tsx b/src/pages/LinkOAuthCallback.tsx new file mode 100644 index 0000000..257b014 --- /dev/null +++ b/src/pages/LinkOAuthCallback.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { authApi } from '../api/auth'; + +// SessionStorage keys (set by ConnectedAccounts.tsx handleLink) +const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; +const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider'; + +function getAndClearLinkOAuthState(): { state: string; provider: string } | null { + const state = sessionStorage.getItem(LINK_OAUTH_STATE_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; + return { state, provider }; +} + +export default function LinkOAuthCallback() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const hasRun = useRef(false); + + useEffect(() => { + // Prevent double-fire from React StrictMode + if (hasRun.current) return; + hasRun.current = true; + + const linkAccount = async () => { + const code = searchParams.get('code'); + const urlState = searchParams.get('state'); + // VK ID returns device_id in callback URL + const deviceId = searchParams.get('device_id'); + + if (!code || !urlState) { + navigate('/profile/accounts', { replace: true }); + return; + } + + // Get saved state from sessionStorage + const saved = getAndClearLinkOAuthState(); + if (!saved) { + navigate('/profile/accounts', { replace: true }); + return; + } + + // Validate state match + if (saved.state !== urlState) { + navigate('/profile/accounts', { replace: true }); + return; + } + + try { + const response = await authApi.linkProviderCallback( + saved.provider, + code, + urlState, + deviceId ?? undefined, + ); + + if (response.merge_required && response.merge_token) { + // Redirect to merge page + navigate(`/merge/${response.merge_token}`, { replace: true }); + } else { + // Success - redirect back to accounts + navigate('/profile/accounts', { replace: true }); + } + } catch { + navigate('/profile/accounts', { replace: true }); + } + }; + + linkAccount(); + }, [searchParams, navigate]); + + return ( +
+
+
+
+

{t('profile.accounts.linking')}

+

{t('common.loading')}

+
+
+ ); +} diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx new file mode 100644 index 0000000..bfe994f --- /dev/null +++ b/src/pages/MergeAccounts.tsx @@ -0,0 +1,577 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { authApi } from '../api/auth'; +import { useAuthStore } from '../store/auth'; +import { useToast } from '../components/Toast'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/data-display/Card'; +import { Button } from '@/components/primitives/Button'; +import { staggerContainer, staggerItem } from '@/components/motion/transitions'; +import { cn } from '@/lib/utils'; +import OAuthProviderIcon from '../components/OAuthProviderIcon'; +import type { MergeAccountPreview } from '../types'; + +// -- Icons -- + +function WarningIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ); +} + +function ClockIcon({ className = 'h-4 w-4' }: { className?: string }) { + return ( + + + + ); +} + +function CheckCircleIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ); +} + +function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ); +} + +function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + + + ); +} + +// -- Helpers -- + +function ProviderBadgeIcon({ provider }: { provider: string }) { + switch (provider) { + case 'telegram': + return ; + case 'email': + return ; + default: + return ; + } +} + +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`; +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return '-'; + try { + return new Date(dateStr).toLocaleDateString(undefined, { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } catch { + return dateStr; + } +} + +function formatBalance(kopeks: number): string { + return Math.floor(kopeks / 100).toLocaleString(); +} + +// -- Radio Indicator -- + +function RadioIndicator({ selected }: { selected: boolean }) { + return ( +
+ {selected &&
} +
+ ); +} + +// -- Account Card -- + +interface AccountCardProps { + account: MergeAccountPreview; + label: string; + isSelected: boolean; + onSelect: () => void; + showRadio: boolean; +} + +function AccountCard({ account, label, isSelected, onSelect, showRadio }: AccountCardProps) { + const { t } = useTranslation(); + + return ( + + + {label} + + + {/* Auth methods */} +
+ {t('merge.authMethods')}: +
+ {account.auth_methods.map((method) => ( + + + {t(`profile.accounts.providers.${method}`)} + + ))} +
+
+ + {/* Subscription */} + {account.subscription ? ( +
+ {t('merge.subscription')}: +

+ {account.subscription.tariff_name ?? account.subscription.status} +

+ {account.subscription.end_date && ( +

+ {t('merge.until', { date: formatDate(account.subscription.end_date) })} +

+ )} +

+ {t('merge.traffic')}: {account.subscription.traffic_limit_gb} GB, {t('merge.devices')} + : {account.subscription.device_limit} +

+
+ ) : ( +
+ {t('merge.subscription')}: +

{t('merge.noSubscription')}

+
+ )} + + {/* Balance */} +
+ {t('merge.balance')}: + + {formatBalance(account.balance_kopeks)} ₽ + +
+ + {/* Radio selection */} + {showRadio && account.subscription && ( + + )} +
+
+ ); +} + +// -- Loading Skeleton -- + +function LoadingSkeleton() { + return ( + + +
+
+
+
+ + + {Array.from({ length: 3 }).map((_, i) => ( + + +
+
+
+
+
+
+ + + ))} + + +
+ + + +
+ + + ); +} + +// -- Expired State -- + +function ExpiredState() { + const { t } = useTranslation(); + + return ( + + +
+ +
+
+ + +

{t('merge.expired')}

+
+ + + + {t('profile.accounts.goToAccounts')} + + +
+ ); +} + +// -- Error State -- + +function ErrorState() { + const { t } = useTranslation(); + + return ( + + +
+ +
+
+ + +

{t('merge.expired')}

+
+ + + + {t('profile.accounts.goToAccounts')} + + +
+ ); +} + +// -- Main Component -- + +export default function MergeAccounts() { + const { t } = useTranslation(); + const { mergeToken } = useParams<{ mergeToken: string }>(); + const navigate = useNavigate(); + const { showToast } = useToast(); + + const [selectedUserId, setSelectedUserId] = useState(null); + const [expiresIn, setExpiresIn] = useState(0); + const [isExpired, setIsExpired] = useState(false); + + // Fetch merge preview (no auth required) + const { data, isLoading, error } = useQuery({ + queryKey: ['merge-preview', mergeToken], + queryFn: () => authApi.getMergePreview(mergeToken!), + enabled: !!mergeToken, + retry: false, + }); + + // Auto-select subscription when data loads + useEffect(() => { + if (!data) return; + + const primaryHasSub = !!data.primary.subscription; + const secondaryHasSub = !!data.secondary.subscription; + + if (primaryHasSub && !secondaryHasSub) { + setSelectedUserId(data.primary.id); + } else if (!primaryHasSub && secondaryHasSub) { + setSelectedUserId(data.secondary.id); + } else if (!primaryHasSub && !secondaryHasSub) { + // Neither has subscription — default to primary + setSelectedUserId(data.primary.id); + } + // If both have subs — null until user picks + }, [data]); + + // Countdown timer + useEffect(() => { + if (!data) return; + setExpiresIn(data.expires_in_seconds); + + const interval = setInterval(() => { + setExpiresIn((prev) => { + if (prev <= 1) { + clearInterval(interval); + setIsExpired(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(interval); + }, [data]); + + // Execute merge + const mergeMutation = useMutation({ + mutationFn: () => authApi.executeMerge(mergeToken!, selectedUserId!), + onSuccess: (response) => { + if (response.success && response.access_token && response.refresh_token) { + const { setTokens, setUser } = useAuthStore.getState(); + setTokens(response.access_token, response.refresh_token); + if (response.user) { + setUser(response.user); + } + showToast({ + type: 'success', + message: t('merge.success'), + }); + navigate('/', { replace: true }); + } + }, + onError: () => { + showToast({ + type: 'error', + message: t('merge.error'), + }); + }, + }); + + const handleMerge = useCallback(() => { + if (!selectedUserId || mergeMutation.isPending || isExpired) return; + mergeMutation.mutate(); + }, [selectedUserId, mergeMutation, isExpired]); + + const handleCancel = useCallback(() => { + navigate('/profile/accounts', { replace: true }); + }, [navigate]); + + // Derived state + const bothHaveSubscriptions = + data && !!data.primary.subscription && !!data.secondary.subscription; + const canConfirm = selectedUserId !== null && !isExpired && !mergeMutation.isPending; + const combinedBalance = data ? data.primary.balance_kopeks + data.secondary.balance_kopeks : 0; + + // Loading + if (isLoading) { + return ; + } + + // Fetch error (404 = expired/invalid token) + if (error || !data) { + return ; + } + + // Timer expired + if (isExpired) { + return ; + } + + return ( + + {/* Header with warning */} + + +
+ +
+

{t('merge.title')}

+

{t('merge.description')}

+
+
+
+
+ + {/* Subscription choice prompt (when both have subs) */} + {bothHaveSubscriptions && !selectedUserId && ( + +
+

{t('merge.chooseSubscription')}

+
+
+ )} + + {/* Primary account card */} + + setSelectedUserId(data.primary.id)} + showRadio={!!bothHaveSubscriptions} + /> + + + {/* Secondary account card */} + + setSelectedUserId(data.secondary.id)} + showRadio={!!bothHaveSubscriptions} + /> + + + {/* After merge summary */} + + + + {t('merge.afterMerge')} + + +
    +
  • + + {t('merge.allAuthMethodsMerged')} +
  • +
  • + + + {t('merge.balanceSummed', { amount: formatBalance(combinedBalance) })} + +
  • + {bothHaveSubscriptions && ( +
  • + + + {t('merge.unselectedSubscriptionDeleted')} + +
  • + )} +
  • + + {t('merge.historyPreserved')} +
  • +
+
+
+
+ + {/* Confirm button */} + + + + + {/* Cancel link */} + + + + + {/* Countdown timer */} + + + + {t('merge.expiresIn', { minutes: formatCountdown(expiresIn) })} + + +
+ ); +} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 8884393..c90d4ee 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; @@ -61,6 +61,7 @@ const PencilIcon = () => ( export default function Profile() { const { t } = useTranslation(); + const navigate = useNavigate(); const user = useAuthStore((state) => state.user); const setUser = useAuthStore((state) => state.setUser); const queryClient = useQueryClient(); @@ -394,6 +395,29 @@ export default function Profile() { + {/* Connected Accounts Link */} + + navigate('/profile/accounts')}> +
+
+

+ {t('profile.accounts.goToAccounts')} +

+

{t('profile.accounts.subtitle')}

+
+ + + +
+
+
+ {/* Referral Link Widget */} {referralTerms?.is_enabled && referralLink && ( diff --git a/src/types/index.ts b/src/types/index.ts index 85882e7..9c435a7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -636,3 +636,57 @@ export interface PromoGroupSimple { id: number; name: string; } + +// Account Linking +export interface LinkedProvider { + provider: string; + linked: boolean; + identifier: string | null; +} + +export interface LinkedProvidersResponse { + providers: LinkedProvider[]; +} + +export interface LinkCallbackResponse { + success: boolean; + message: string | null; + merge_required: boolean; + merge_token: string | null; +} + +// Account Merge +export interface MergeSubscriptionPreview { + status: string; + is_trial: boolean; + end_date: string | null; + traffic_limit_gb: number; + traffic_used_gb: number; + device_limit: number; + tariff_name: string | null; + autopay_enabled: boolean; +} + +export interface MergeAccountPreview { + id: number; + username: string | null; + first_name: string | null; + email: string | null; + auth_methods: string[]; + balance_kopeks: number; + subscription: MergeSubscriptionPreview | null; + created_at: string | null; +} + +export interface MergePreviewResponse { + primary: MergeAccountPreview; + secondary: MergeAccountPreview; + expires_in_seconds: number; +} + +export interface MergeResponse { + success: boolean; + access_token: string | null; + refresh_token: string | null; + user: User | null; +} From 58cf1e3b504c8577e6d6aa081bf861cb871fb765 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 07:47:36 +0300 Subject: [PATCH 02/21] 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 --- src/locales/fa.json | 1 + src/locales/zh.json | 1 + src/pages/ConnectedAccounts.tsx | 21 ++++++++++++++----- src/pages/LinkOAuthCallback.tsx | 15 +++++++++---- src/pages/MergeAccounts.tsx | 37 +++++++++++++++++++++------------ 5 files changed, 53 insertions(+), 22 deletions(-) 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 = From aa26059e004dc7ce96b3b0953343ace5e86696c3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 07:56:19 +0300 Subject: [PATCH 03/21] 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 --- src/pages/ConnectedAccounts.tsx | 1 + src/pages/LinkOAuthCallback.tsx | 16 +++++++++----- src/pages/MergeAccounts.tsx | 38 +++++++++++++++++++-------------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index bb449f1..dddfd87 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -106,6 +106,7 @@ export default function ConnectedAccounts() { const canUnlink = (provider: LinkedProvider): boolean => { if (!provider.linked) return false; if (!isOAuthProvider(provider.provider)) return false; + if (unlinkMutation.isPending) return false; const linkedCount = data?.providers.filter((p) => p.linked).length ?? 0; return linkedCount > 1; }; diff --git a/src/pages/LinkOAuthCallback.tsx b/src/pages/LinkOAuthCallback.tsx index bb57e2e..913df31 100644 --- a/src/pages/LinkOAuthCallback.tsx +++ b/src/pages/LinkOAuthCallback.tsx @@ -8,15 +8,18 @@ import { useToast } from '../components/Toast'; 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 { +function getLinkOAuthState(): { state: string; provider: string } | null { const state = sessionStorage.getItem(LINK_OAUTH_STATE_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; return { state, provider }; } +function clearLinkOAuthState(): void { + sessionStorage.removeItem(LINK_OAUTH_STATE_KEY); + sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY); +} + export default function LinkOAuthCallback() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -41,8 +44,8 @@ export default function LinkOAuthCallback() { return; } - // Get saved state from sessionStorage - const saved = getAndClearLinkOAuthState(); + // Get saved state from sessionStorage (read only, don't clear yet) + const saved = getLinkOAuthState(); if (!saved) { showToast({ type: 'error', message: t('profile.accounts.linkError') }); navigate('/profile/accounts', { replace: true }); @@ -56,6 +59,9 @@ export default function LinkOAuthCallback() { return; } + // State validated — clear it now (one-time use) + clearLinkOAuthState(); + try { const response = await authApi.linkProviderCallback( saved.provider, diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx index c1f36f7..8b236e3 100644 --- a/src/pages/MergeAccounts.tsx +++ b/src/pages/MergeAccounts.tsx @@ -112,9 +112,10 @@ function ProviderBadgeIcon({ provider }: { provider: string }) { } function formatCountdown(seconds: number): string { - const min = Math.floor(seconds / 60); - const sec = seconds % 60; - return `${min}:${sec.toString().padStart(2, '0')}`; + const clamped = Math.max(0, seconds); + const min = Math.floor(clamped / 60); + const sec = clamped % 60; + return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } function formatDate(dateStr: string | null): string { @@ -356,7 +357,10 @@ export default function MergeAccounts() { // Fetch merge preview (no auth required) const { data, isLoading, error } = useQuery({ 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, retry: false, staleTime: Infinity, @@ -409,25 +413,22 @@ export default function MergeAccounts() { return authApi.executeMerge(mergeToken, selectedUserId); }, 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(); 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'), - }); - navigate('/', { replace: true }); } + + showToast({ type: 'success', message: t('merge.success') }); + navigate('/', { replace: true }); }, onError: () => { showToast({ @@ -452,6 +453,11 @@ export default function MergeAccounts() { const canConfirm = selectedUserId !== null && !isExpired && !mergeMutation.isPending; const combinedBalance = data ? data.primary.balance_kopeks + data.secondary.balance_kopeks : 0; + // Missing token param + if (!mergeToken) { + return ; + } + // Loading if (isLoading) { return ; From 579f47e563a13f4a56ca92064949d594bfe66063 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 08:12:18 +0300 Subject: [PATCH 04/21] fix(merge): accessibility, token guard, state cleanup - Add role="radio" + aria-checked to subscription radio buttons - Add aria-hidden="true" to decorative SVG icons - Treat success=true with null tokens as error (show error toast) - Guard auto-select useEffect from overwriting user's manual choice - Clear sessionStorage on OAuth state validation failure --- src/pages/LinkOAuthCallback.tsx | 1 + src/pages/MergeAccounts.tsx | 28 +++++++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/pages/LinkOAuthCallback.tsx b/src/pages/LinkOAuthCallback.tsx index 913df31..76a44e1 100644 --- a/src/pages/LinkOAuthCallback.tsx +++ b/src/pages/LinkOAuthCallback.tsx @@ -54,6 +54,7 @@ export default function LinkOAuthCallback() { // Validate state match if (saved.state !== urlState) { + clearLinkOAuthState(); showToast({ type: 'error', message: t('profile.accounts.linkError') }); navigate('/profile/accounts', { replace: true }); return; diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx index 8b236e3..971e96e 100644 --- a/src/pages/MergeAccounts.tsx +++ b/src/pages/MergeAccounts.tsx @@ -23,6 +23,7 @@ function WarningIcon({ className = 'h-5 w-5' }: { className?: string }) { viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} + aria-hidden="true" > )} - {/* Primary account card */} - - setSelectedUserId(data.primary.id)} - showRadio={!!bothHaveSubscriptions} - /> - + {/* Account cards */} +
+ + setSelectedUserId(data.primary.id)} + showRadio={!!bothHaveSubscriptions} + /> + - {/* Secondary account card */} - - setSelectedUserId(data.secondary.id)} - showRadio={!!bothHaveSubscriptions} - /> - + + setSelectedUserId(data.secondary.id)} + showRadio={!!bothHaveSubscriptions} + /> + +
{/* After merge summary */} From d0c01a0e5cb656661b75175416ccf98c5aff8911 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 15:30:00 +0300 Subject: [PATCH 06/21] fix: replace window.confirm with inline confirmation for unlink window.confirm() is silently suppressed in Telegram Mini Apps and iOS WebView, making unlink completely non-functional on mobile. Replaced with two-click inline confirmation: first click shows destructive "Confirm disconnect?" button, second click executes unlink. Button resets on blur. --- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/ConnectedAccounts.tsx | 17 ++++++++++++++--- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index c771ce2..7ce6a4d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3607,6 +3607,7 @@ "link": "Connect", "unlink": "Disconnect", "unlinkConfirm": "Are you sure? You won't be able to sign in with this service after disconnecting.", + "unlinkConfirmBtn": "Confirm disconnect?", "cannotUnlinkLast": "Cannot disconnect the last sign-in method", "linkSuccess": "Account connected successfully", "unlinkSuccess": "Account disconnected successfully", diff --git a/src/locales/fa.json b/src/locales/fa.json index b6a3364..b1de938 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3043,6 +3043,7 @@ "link": "اتصال", "unlink": "قطع اتصال", "unlinkConfirm": "آیا مطمئن هستید؟ پس از قطع اتصال نمی‌توانید از طریق این سرویس وارد شوید.", + "unlinkConfirmBtn": "تأیید قطع اتصال؟", "cannotUnlinkLast": "نمی‌توان آخرین روش ورود را قطع کرد", "linkSuccess": "حساب با موفقیت متصل شد", "unlinkSuccess": "اتصال حساب با موفقیت قطع شد", diff --git a/src/locales/ru.json b/src/locales/ru.json index e19a81a..4a1e108 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4167,6 +4167,7 @@ "link": "Привязать", "unlink": "Отвязать", "unlinkConfirm": "Вы уверены? После отвязки вы не сможете входить через этот сервис.", + "unlinkConfirmBtn": "Точно отвязать?", "cannotUnlinkLast": "Нельзя отвязать последний способ входа", "linkSuccess": "Аккаунт успешно привязан", "unlinkSuccess": "Аккаунт успешно отвязан", diff --git a/src/locales/zh.json b/src/locales/zh.json index 2903c4d..7caeace 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3042,6 +3042,7 @@ "link": "关联", "unlink": "取消关联", "unlinkConfirm": "确定吗?取消关联后,您将无法通过此服务登录。", + "unlinkConfirmBtn": "确认取消关联?", "cannotUnlinkLast": "无法取消最后一种登录方式", "linkSuccess": "账户关联成功", "unlinkSuccess": "账户取消关联成功", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index c738561..1a2756f 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { motion } from 'framer-motion'; @@ -126,9 +127,14 @@ export default function ConnectedAccounts() { } }; + const [confirmingUnlink, setConfirmingUnlink] = useState(null); + const handleUnlink = (provider: string) => { - if (window.confirm(t('profile.accounts.unlinkConfirm'))) { + if (confirmingUnlink === provider) { + setConfirmingUnlink(null); unlinkMutation.mutate(provider); + } else { + setConfirmingUnlink(provider); } }; @@ -185,15 +191,20 @@ export default function ConnectedAccounts() { {t('profile.accounts.linked')} {canUnlink(provider) && ( )} From 3418ba9b8da69ec8ea3822971729bd16fcfcd1ce Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 15:35:45 +0300 Subject: [PATCH 07/21] fix: prevent onBlur race cancelling unlink confirmation Add 150ms delay before resetting confirmation state so the click event fires before blur resets it. --- src/pages/ConnectedAccounts.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 1a2756f..31c23c5 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -199,7 +199,10 @@ export default function ConnectedAccounts() { } onClick={() => handleUnlink(provider.provider)} onBlur={() => { - if (confirmingUnlink === provider.provider) setConfirmingUnlink(null); + // Delay so click on the same button fires before blur resets state + setTimeout(() => { + setConfirmingUnlink((cur) => (cur === provider.provider ? null : cur)); + }, 150); }} > {confirmingUnlink === provider.provider From 71198ab18ad6b7f08d450cb8648ad6a412cb8061 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 15:46:53 +0300 Subject: [PATCH 08/21] refactor: extract shared ProviderIcon, fix canUnlink hiding all buttons - Extract TelegramIcon + EmailIcon to shared ProviderIcon component - Remove duplicated icons from ConnectedAccounts and MergeAccounts - Remove isPending from canUnlink (buttons already have disabled prop) - Add onSettled to reset confirmingUnlink state after mutation --- src/components/ProviderIcon.tsx | 49 +++++++++++++++++++++++++++++++++ src/pages/ConnectedAccounts.tsx | 48 +++----------------------------- src/pages/MergeAccounts.tsx | 41 ++------------------------- 3 files changed, 55 insertions(+), 83 deletions(-) create mode 100644 src/components/ProviderIcon.tsx diff --git a/src/components/ProviderIcon.tsx b/src/components/ProviderIcon.tsx new file mode 100644 index 0000000..758f0b3 --- /dev/null +++ b/src/components/ProviderIcon.tsx @@ -0,0 +1,49 @@ +import { cn } from '@/lib/utils'; +import OAuthProviderIcon from './OAuthProviderIcon'; + +export function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + ); +} + +export function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( + + ); +} + +export default function ProviderIcon({ + provider, + className, +}: { + provider: string; + className?: string; +}) { + switch (provider) { + case 'telegram': + return ; + case 'email': + return ; + default: + return ; + } +} diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 31c23c5..2b8053e 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -7,7 +7,7 @@ import { useToast } from '../components/Toast'; 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 ProviderIcon from '../components/ProviderIcon'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; import type { LinkedProvider } from '../types'; @@ -15,48 +15,6 @@ const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider); -// Icons for providers not covered by OAuthProviderIcon -function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) { - return ( - - ); -} - -function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) { - return ( - - ); -} - -function ProviderIcon({ provider }: { provider: string }) { - switch (provider) { - case 'telegram': - return ; - case 'email': - return ; - default: - return ; - } -} - function LoadingSkeleton() { return (
@@ -103,12 +61,14 @@ export default function ConnectedAccounts() { message: t('profile.accounts.unlinkError'), }); }, + onSettled: () => { + setConfirmingUnlink(null); + }, }); const canUnlink = (provider: LinkedProvider): boolean => { if (!provider.linked) return false; if (!isOAuthProvider(provider.provider)) return false; - if (unlinkMutation.isPending) return false; const linkedCount = data?.providers.filter((p) => p.linked).length ?? 0; return linkedCount > 1; }; diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx index c410ba6..89a6abc 100644 --- a/src/pages/MergeAccounts.tsx +++ b/src/pages/MergeAccounts.tsx @@ -10,7 +10,7 @@ import { Card, CardHeader, CardTitle, CardContent } from '@/components/data-disp import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { cn } from '@/lib/utils'; -import OAuthProviderIcon from '../components/OAuthProviderIcon'; +import ProviderIcon from '../components/ProviderIcon'; import type { MergeAccountPreview } from '../types'; // -- Icons -- @@ -72,47 +72,10 @@ function CheckCircleIcon({ className = 'h-5 w-5' }: { className?: string }) { ); } -function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) { - return ( - - ); -} - -function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) { - return ( - - ); -} - // -- Helpers -- function ProviderBadgeIcon({ provider }: { provider: string }) { - switch (provider) { - case 'telegram': - return ; - case 'email': - return ; - default: - return ; - } + return ; } function formatCountdown(seconds: number): string { From fba4481799081b05f0b082bcf983c6ac4c4daf1b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 15:57:04 +0300 Subject: [PATCH 09/21] fix: move useState before useMutation for consistent hook ordering --- src/pages/ConnectedAccounts.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 2b8053e..310f3c7 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -41,6 +41,8 @@ export default function ConnectedAccounts() { const { showToast } = useToast(); const queryClient = useQueryClient(); + const [confirmingUnlink, setConfirmingUnlink] = useState(null); + const { data, isLoading, isError } = useQuery({ queryKey: ['linked-providers'], queryFn: () => authApi.getLinkedProviders(), @@ -87,8 +89,6 @@ export default function ConnectedAccounts() { } }; - const [confirmingUnlink, setConfirmingUnlink] = useState(null); - const handleUnlink = (provider: string) => { if (confirmingUnlink === provider) { setConfirmingUnlink(null); From 8ad0500cc80fee51b03880e7988ffe1192e7f214 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 16:05:24 +0300 Subject: [PATCH 10/21] 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 --- src/pages/ConnectedAccounts.tsx | 17 +++++++++++++++-- src/pages/MergeAccounts.tsx | 28 ++++++++++++++++------------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 310f3c7..ce1ae0e 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { motion } from 'framer-motion'; @@ -42,6 +42,14 @@ export default function ConnectedAccounts() { const queryClient = useQueryClient(); const [confirmingUnlink, setConfirmingUnlink] = useState(null); + const [linkingProvider, setLinkingProvider] = useState(null); + const blurTimeoutRef = useRef>(undefined); + + useEffect(() => { + return () => { + if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current); + }; + }, []); const { data, isLoading, isError } = useQuery({ queryKey: ['linked-providers'], @@ -76,6 +84,8 @@ export default function ConnectedAccounts() { }; const handleLink = async (provider: string) => { + if (linkingProvider) return; + setLinkingProvider(provider); try { const { authorize_url, state } = await authApi.linkProviderInit(provider); sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state); @@ -86,6 +96,7 @@ export default function ConnectedAccounts() { type: 'error', message: t('profile.accounts.linkError'), }); + setLinkingProvider(null); } }; @@ -160,7 +171,7 @@ export default function ConnectedAccounts() { onClick={() => handleUnlink(provider.provider)} onBlur={() => { // Delay so click on the same button fires before blur resets state - setTimeout(() => { + blurTimeoutRef.current = setTimeout(() => { setConfirmingUnlink((cur) => (cur === provider.provider ? null : cur)); }, 150); }} @@ -176,6 +187,8 @@ export default function ConnectedAccounts() { + ); + } + // Browser: Telegram Login Widget + return ; + } + + if (isOAuthProvider(provider.provider)) { + return ( + + ); + } + + return null; + }; + return ( handleUnlink(provider.provider)} onBlur={() => { - // Delay so click on the same button fires before blur resets state blurTimeoutRef.current = setTimeout(() => { setConfirmingUnlink((cur) => (cur === provider.provider ? null : cur)); }, 150); @@ -183,17 +303,7 @@ export default function ConnectedAccounts() { )} ) : ( - isOAuthProvider(provider.provider) && ( - - ) + isLinkableProvider(provider.provider) && renderLinkButton(provider) )}
diff --git a/src/pages/LinkTelegramCallback.tsx b/src/pages/LinkTelegramCallback.tsx new file mode 100644 index 0000000..9fec27b --- /dev/null +++ b/src/pages/LinkTelegramCallback.tsx @@ -0,0 +1,93 @@ +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'; +import { LINK_TELEGRAM_STATE_KEY } from './ConnectedAccounts'; + +export default function LinkTelegramCallback() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { showToast } = useToast(); + const hasRun = useRef(false); + + useEffect(() => { + if (hasRun.current) return; + hasRun.current = true; + + // Clear sensitive data from URL immediately + window.history.replaceState({}, '', '/auth/link/telegram/callback'); + + const linkAccount = async () => { + // 1. Validate CSRF state + const csrfState = searchParams.get('csrf_state'); + const savedState = sessionStorage.getItem(LINK_TELEGRAM_STATE_KEY); + sessionStorage.removeItem(LINK_TELEGRAM_STATE_KEY); + + if (!csrfState || !savedState || csrfState !== savedState) { + showToast({ type: 'error', message: t('profile.accounts.linkError') }); + navigate('/profile/accounts', { replace: true }); + return; + } + + // 2. Validate required Telegram fields + const id = searchParams.get('id'); + const firstName = searchParams.get('first_name'); + const authDate = searchParams.get('auth_date'); + const hash = searchParams.get('hash'); + + if (!id || !firstName || !authDate || !hash) { + showToast({ type: 'error', message: t('profile.accounts.linkError') }); + navigate('/profile/accounts', { replace: true }); + return; + } + + const parsedId = parseInt(id, 10); + const parsedAuthDate = parseInt(authDate, 10); + + if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) { + showToast({ type: 'error', message: t('profile.accounts.linkError') }); + navigate('/profile/accounts', { replace: true }); + return; + } + + try { + const response = await authApi.linkTelegram({ + id: parsedId, + first_name: firstName, + last_name: searchParams.get('last_name') || undefined, + username: searchParams.get('username') || undefined, + photo_url: searchParams.get('photo_url') || undefined, + auth_date: parsedAuthDate, + hash, + }); + + if (response.merge_required && response.merge_token) { + navigate(`/merge/${response.merge_token}`, { replace: true }); + } else { + 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, showToast, t]); + + return ( +
+
+
+
+

+ {t('profile.accounts.linkingTelegram')} +

+

{t('common.loading')}

+
+
+ ); +} From 9b4a8512c2e3cedf1f075aef62415fa5464b69e6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 4 Mar 2026 17:21:16 +0300 Subject: [PATCH 13/21] fix: remove unused linkTelegramWidget i18n key from all locales --- src/locales/en.json | 1 - src/locales/fa.json | 1 - src/locales/ru.json | 1 - src/locales/zh.json | 1 - 4 files changed, 4 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 9bc63d9..6047f1e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3615,7 +3615,6 @@ "unlinkError": "Failed to disconnect account", "goToAccounts": "Connected Accounts", "linking": "Linking account...", - "linkTelegramWidget": "Sign in with Telegram to connect", "linkingTelegram": "Connecting Telegram...", "providers": { "telegram": "Telegram", diff --git a/src/locales/fa.json b/src/locales/fa.json index e4f9993..96ab376 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3051,7 +3051,6 @@ "unlinkError": "قطع اتصال ناموفق بود", "goToAccounts": "حساب‌های متصل", "linking": "در حال اتصال حساب...", - "linkTelegramWidget": "برای اتصال از طریق تلگرام وارد شوید", "linkingTelegram": "در حال اتصال تلگرام...", "providers": { "telegram": "تلگرام", diff --git a/src/locales/ru.json b/src/locales/ru.json index 80c11de..4e28322 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4175,7 +4175,6 @@ "unlinkError": "Не удалось отвязать аккаунт", "goToAccounts": "Привязанные аккаунты", "linking": "Привязка аккаунта...", - "linkTelegramWidget": "Войдите через Telegram для привязки", "linkingTelegram": "Привязка Telegram...", "providers": { "telegram": "Telegram", diff --git a/src/locales/zh.json b/src/locales/zh.json index b78e260..68038e7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3050,7 +3050,6 @@ "unlinkError": "取消关联失败", "goToAccounts": "关联账户", "linking": "正在关联账户...", - "linkTelegramWidget": "通过 Telegram 登录以关联", "linkingTelegram": "正在关联 Telegram...", "providers": { "telegram": "Telegram", From 7c30a1eab616846253df1ec2c93b97259a54c8b8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 02:24:28 +0300 Subject: [PATCH 14/21] feat: open OAuth linking in external browser from Telegram Mini App - Rewrite OAuthCallback.tsx to handle 3 modes: login, link-browser, and link-server (external browser without JWT) - Add linkServerComplete API method (no JWT, auth via state token) - Update ConnectedAccounts to use platform.openLink() in Mini App - Add server-complete endpoint to AUTH_ENDPOINTS (skip Bearer token) - Enable refetchOnWindowFocus for linked-providers query - Add returnToTelegram/openTelegram i18n keys (ru, en, zh, fa) --- src/api/auth.ts | 18 ++++ src/api/client.ts | 1 + src/locales/en.json | 2 + src/locales/fa.json | 2 + src/locales/ru.json | 2 + src/locales/zh.json | 2 + src/pages/ConnectedAccounts.tsx | 20 +++- src/pages/OAuthCallback.tsx | 179 ++++++++++++++++++++++++++------ 8 files changed, 192 insertions(+), 34 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index 106e537..787caba 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -254,6 +254,24 @@ export const authApi = { return response.data; }, + // Server-side OAuth linking completion (no JWT needed — auth via state token) + // Used when OAuth opens in external browser from Telegram Mini App + linkServerComplete: async ( + code: string, + state: string, + deviceId?: string, + ): Promise => { + const response = await apiClient.post( + '/cabinet/auth/account/link/server-complete', + { + code, + state, + device_id: deviceId, + }, + ); + return response.data; + }, + unlinkProvider: async (provider: string): Promise<{ success: boolean }> => { const response = await apiClient.post<{ success: boolean }>( `/cabinet/auth/account/unlink/${encodeURIComponent(provider)}`, diff --git a/src/api/client.ts b/src/api/client.ts index 5b5034a..eaddfb6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -76,6 +76,7 @@ const AUTH_ENDPOINTS = [ '/cabinet/auth/password/reset', '/cabinet/auth/oauth/', '/cabinet/auth/merge/', + '/cabinet/auth/account/link/server-complete', ]; function isAuthEndpoint(url: string | undefined): boolean { diff --git a/src/locales/en.json b/src/locales/en.json index 6047f1e..cbfdb5a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3616,6 +3616,8 @@ "goToAccounts": "Connected Accounts", "linking": "Linking account...", "linkingTelegram": "Connecting Telegram...", + "returnToTelegram": "Return to Telegram to continue", + "openTelegram": "Open Telegram", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/fa.json b/src/locales/fa.json index 96ab376..66be39b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3052,6 +3052,8 @@ "goToAccounts": "حساب‌های متصل", "linking": "در حال اتصال حساب...", "linkingTelegram": "در حال اتصال تلگرام...", + "returnToTelegram": "برای ادامه به تلگرام بازگردید", + "openTelegram": "باز کردن تلگرام", "providers": { "telegram": "تلگرام", "email": "ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4e28322..5a2f195 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4176,6 +4176,8 @@ "goToAccounts": "Привязанные аккаунты", "linking": "Привязка аккаунта...", "linkingTelegram": "Привязка Telegram...", + "returnToTelegram": "Вернитесь в Telegram, чтобы продолжить", + "openTelegram": "Открыть Telegram", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/zh.json b/src/locales/zh.json index 68038e7..210dc8c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3051,6 +3051,8 @@ "goToAccounts": "关联账户", "linking": "正在关联账户...", "linkingTelegram": "正在关联 Telegram...", + "returnToTelegram": "返回 Telegram 继续操作", + "openTelegram": "打开 Telegram", "providers": { "telegram": "Telegram", "email": "邮箱", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 7b7386f..d85abb6 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -11,6 +11,7 @@ import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import ProviderIcon from '../components/ProviderIcon'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; +import { usePlatform } from '@/platform/hooks/usePlatform'; import type { LinkedProvider } from '../types'; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; @@ -99,6 +100,7 @@ export default function ConnectedAccounts() { const blurTimeoutRef = useRef>(undefined); const inTelegram = isInTelegramWebApp(); + const platform = usePlatform(); useEffect(() => { return () => { @@ -109,6 +111,7 @@ export default function ConnectedAccounts() { const { data, isLoading, isError } = useQuery({ queryKey: ['linked-providers'], queryFn: () => authApi.getLinkedProviders(), + refetchOnWindowFocus: true, }); const unlinkMutation = useMutation({ @@ -143,9 +146,20 @@ export default function ConnectedAccounts() { setLinkingProvider(provider); try { const { authorize_url, state } = await authApi.linkProviderInit(provider); - sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state); - sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); - window.location.href = authorize_url; + + if (inTelegram) { + // Mini App: open in external browser to avoid WebView OAuth restrictions. + // The callback will use server-complete flow (auth via state token, no JWT). + platform.openLink(authorize_url); + // Reset loading state — user stays in Mini App + setLinkingProvider(null); + } else { + // Regular browser: navigate within the same tab. + // Save state in sessionStorage for the callback page to verify. + sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state); + sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); + window.location.href = authorize_url; + } } catch { showToast({ type: 'error', diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index ffccf93..d1c2b2c 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -2,8 +2,10 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/auth'; +import { authApi } from '../api/auth'; +import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; -// SessionStorage helpers for OAuth state +// SessionStorage helpers for OAuth LOGIN state const OAUTH_STATE_KEY = 'oauth_state'; const OAUTH_PROVIDER_KEY = 'oauth_provider'; @@ -21,63 +23,178 @@ export function getAndClearOAuthState(): { state: string; provider: string } | n return { state, provider }; } +/** + * Check if this is an account LINKING callback (user was already authenticated). + * Returns the saved link state or null. + */ +function getAndClearLinkOAuthState(): { state: string; provider: string } | null { + const state = sessionStorage.getItem(LINK_OAUTH_STATE_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; + return { state, provider }; +} + +type CallbackMode = 'login' | 'link-browser' | 'link-server'; + +/** Result after successful server-complete linking from external browser. */ +interface ServerLinkResult { + success: boolean; + merge_required: boolean; + merge_token: string | null; + provider?: string; +} + export default function OAuthCallback() { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [error, setError] = useState(''); + const [serverLinkResult, setServerLinkResult] = useState(null); const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const hasRun = useRef(false); useEffect(() => { - if (isAuthenticated) { - navigate('/', { replace: true }); - return; - } - - // Prevent double-fire from React StrictMode or dependency changes + // Prevent double-fire from React StrictMode if (hasRun.current) return; hasRun.current = true; - const authenticate = async () => { - const code = searchParams.get('code'); - const urlState = searchParams.get('state'); - // VK ID returns device_id in callback URL (required for token exchange) - const deviceId = searchParams.get('device_id'); + const code = searchParams.get('code'); + const urlState = searchParams.get('state'); + const deviceId = searchParams.get('device_id'); - if (!code || !urlState) { - setError(t('auth.oauthError', 'Authorization was denied or failed')); + if (!code || !urlState) { + setError(t('auth.oauthError', 'Authorization was denied or failed')); + return; + } + + // Determine callback mode: + // 1. Link state in sessionStorage → browser linking flow + // 2. Login state in sessionStorage → login flow + // 3. Neither → opened from external browser (Mini App flow) → server-complete + let mode: CallbackMode = 'link-server'; + let savedProvider: string | null = null; + let savedState: string | null = null; + + const linkSaved = getAndClearLinkOAuthState(); + if (linkSaved && linkSaved.state === urlState) { + mode = 'link-browser'; + savedProvider = linkSaved.provider; + savedState = linkSaved.state; + } else { + const loginSaved = getAndClearOAuthState(); + if (loginSaved && loginSaved.state === urlState) { + mode = 'login'; + savedProvider = loginSaved.provider; + savedState = loginSaved.state; + } + } + + const handle = async () => { + if (mode === 'link-browser') { + // Browser linking: user is authenticated, complete via JWT-protected endpoint + try { + const response = await authApi.linkProviderCallback( + savedProvider!, + code, + savedState!, + deviceId ?? undefined, + ); + if (response.merge_required && response.merge_token) { + navigate(`/merge/${response.merge_token}`, { replace: true }); + } else { + navigate('/profile/accounts', { replace: true }); + } + } catch { + setError(t('profile.accounts.linkError')); + } return; } - // Get saved state from sessionStorage - const saved = getAndClearOAuthState(); - if (!saved) { - setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.')); + if (mode === 'login') { + // Login flow + if (isAuthenticated) { + navigate('/', { replace: true }); + return; + } + try { + await loginWithOAuth(savedProvider!, code, savedState!, deviceId); + navigate('/', { replace: true }); + } catch (err: unknown) { + const detail = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + (err instanceof Error ? err.message : null); + setError(detail || t('auth.oauthError', 'Authorization was denied or failed')); + } return; } - // Validate state match - if (saved.state !== urlState) { - setError(t('auth.oauthError', 'Authorization was denied or failed')); - return; - } + // mode === 'link-server': No sessionStorage state found. + // This happens when OAuth was opened in external browser from Mini App. + // Complete linking via state-token-authenticated server endpoint. + // Clear sensitive data from URL immediately. + window.history.replaceState({}, '', '/auth/oauth/callback'); try { - await loginWithOAuth(saved.provider, code, urlState, deviceId); - navigate('/', { replace: true }); - } catch (err: unknown) { - const detail = - (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? - (err instanceof Error ? err.message : null); - setError(detail || t('auth.oauthError', 'Authorization was denied or failed')); + // Provider is resolved server-side from the state token in Redis. + const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined); + + setServerLinkResult(response); + } catch { + setError(t('profile.accounts.linkError')); } }; - authenticate(); + handle(); }, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]); + // Server-complete result: show success/error with "Return to Telegram" link + if (serverLinkResult) { + if (serverLinkResult.merge_required && serverLinkResult.merge_token) { + // Redirect to merge page (public, works without JWT) + navigate(`/merge/${serverLinkResult.merge_token}`, { replace: true }); + return null; + } + + const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; + const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; + + return ( +
+
+
+
+
+ + + +
+

+ {t('profile.accounts.linkSuccess')} +

+

{t('profile.accounts.returnToTelegram')}

+ {telegramLink && ( + + {t('profile.accounts.openTelegram')} + + )} +
+
+
+ ); + } + if (error) { return (
From da1926f0e1ab7f117aef120ac7648bdd50add72c Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 02:33:33 +0300 Subject: [PATCH 15/21] =?UTF-8?q?fix:=20review=20findings=20=E2=80=94=20po?= =?UTF-8?q?lling=20fallback,=20sessionStorage=20cleanup,=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 5s polling for 90s after opening external browser link (Mini App) - Show info toast "Continue in browser" when opening external OAuth - Fix premature sessionStorage cleanup: peek-then-clear pattern - Use useIsTelegram() hook instead of direct isInTelegramWebApp() - Add continueInBrowser i18n key (ru, en, zh, fa) --- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/ConnectedAccounts.tsx | 23 +++++++++++++++++++---- src/pages/OAuthCallback.tsx | 17 +++++++++-------- 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index cbfdb5a..698c36e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3618,6 +3618,7 @@ "linkingTelegram": "Connecting Telegram...", "returnToTelegram": "Return to Telegram to continue", "openTelegram": "Open Telegram", + "continueInBrowser": "Continue authorization in the opened browser", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/fa.json b/src/locales/fa.json index 66be39b..29efa7e 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3054,6 +3054,7 @@ "linkingTelegram": "در حال اتصال تلگرام...", "returnToTelegram": "برای ادامه به تلگرام بازگردید", "openTelegram": "باز کردن تلگرام", + "continueInBrowser": "ادامه احراز هویت در مرورگر باز شده", "providers": { "telegram": "تلگرام", "email": "ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5a2f195..80fe7b8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4178,6 +4178,7 @@ "linkingTelegram": "Привязка Telegram...", "returnToTelegram": "Вернитесь в Telegram, чтобы продолжить", "openTelegram": "Открыть Telegram", + "continueInBrowser": "Продолжите авторизацию в открывшемся браузере", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/zh.json b/src/locales/zh.json index 210dc8c..c6a9058 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3053,6 +3053,7 @@ "linkingTelegram": "正在关联 Telegram...", "returnToTelegram": "返回 Telegram 继续操作", "openTelegram": "打开 Telegram", + "continueInBrowser": "请在打开的浏览器中继续授权", "providers": { "telegram": "Telegram", "email": "邮箱", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index d85abb6..75e26cb 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -10,8 +10,8 @@ import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import ProviderIcon from '../components/ProviderIcon'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; -import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; -import { usePlatform } from '@/platform/hooks/usePlatform'; +import { getTelegramInitData } from '../hooks/useTelegramSDK'; +import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; import type { LinkedProvider } from '../types'; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; @@ -97,9 +97,10 @@ export default function ConnectedAccounts() { const [confirmingUnlink, setConfirmingUnlink] = useState(null); const [linkingProvider, setLinkingProvider] = useState(null); + const [waitingExternalLink, setWaitingExternalLink] = useState(false); const blurTimeoutRef = useRef>(undefined); - const inTelegram = isInTelegramWebApp(); + const inTelegram = useIsTelegram(); const platform = usePlatform(); useEffect(() => { @@ -112,8 +113,17 @@ export default function ConnectedAccounts() { queryKey: ['linked-providers'], queryFn: () => authApi.getLinkedProviders(), refetchOnWindowFocus: true, + // Poll every 5s while waiting for external browser OAuth to complete + refetchInterval: waitingExternalLink ? 5000 : false, }); + // Stop polling after 90 seconds + useEffect(() => { + if (!waitingExternalLink) return; + const timeout = setTimeout(() => setWaitingExternalLink(false), 90_000); + return () => clearTimeout(timeout); + }, [waitingExternalLink]); + const unlinkMutation = useMutation({ mutationFn: (provider: string) => authApi.unlinkProvider(provider), onSuccess: () => { @@ -151,8 +161,13 @@ export default function ConnectedAccounts() { // Mini App: open in external browser to avoid WebView OAuth restrictions. // The callback will use server-complete flow (auth via state token, no JWT). platform.openLink(authorize_url); - // Reset loading state — user stays in Mini App setLinkingProvider(null); + // Start polling for linked providers (external browser has no way to notify Mini App) + setWaitingExternalLink(true); + showToast({ + type: 'info', + message: t('profile.accounts.continueInBrowser'), + }); } else { // Regular browser: navigate within the same tab. // Save state in sessionStorage for the callback page to verify. diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index d1c2b2c..01d07b9 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -23,19 +23,19 @@ export function getAndClearOAuthState(): { state: string; provider: string } | n return { state, provider }; } -/** - * Check if this is an account LINKING callback (user was already authenticated). - * Returns the saved link state or null. - */ -function getAndClearLinkOAuthState(): { state: string; provider: string } | null { +/** Read link OAuth state without clearing (cleared only after successful match). */ +function peekLinkOAuthState(): { state: string; provider: string } | null { const state = sessionStorage.getItem(LINK_OAUTH_STATE_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; return { state, provider }; } +function clearLinkOAuthState(): void { + sessionStorage.removeItem(LINK_OAUTH_STATE_KEY); + sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY); +} + type CallbackMode = 'login' | 'link-browser' | 'link-server'; /** Result after successful server-complete linking from external browser. */ @@ -78,8 +78,9 @@ export default function OAuthCallback() { let savedProvider: string | null = null; let savedState: string | null = null; - const linkSaved = getAndClearLinkOAuthState(); + const linkSaved = peekLinkOAuthState(); if (linkSaved && linkSaved.state === urlState) { + clearLinkOAuthState(); mode = 'link-browser'; savedProvider = linkSaved.provider; savedState = linkSaved.state; From 0d99ea0069b0601e890cb8c6d393f299591e29bc Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 03:10:22 +0300 Subject: [PATCH 16/21] =?UTF-8?q?refactor:=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code,=20fix=20type=20safety,=20impr?= =?UTF-8?q?ove=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove LinkOAuthCallback.tsx (dead code) and its route from App.tsx - Move LINK_OAUTH_* constants to OAuthCallback.tsx - Replace local ServerLinkResult with LinkCallbackResponse from types - Move merge navigate() from render body to useEffect - Track errorMode to show correct CTA (Return to Telegram vs Back to Login) - Remove non-null assertions with proper narrowing - Extract getErrorDetail helper for error casting - Add smart polling success detection (stop + toast when linked count increases) --- src/App.tsx | 11 ---- src/pages/ConnectedAccounts.tsx | 17 +++++- src/pages/LinkOAuthCallback.tsx | 101 -------------------------------- src/pages/OAuthCallback.tsx | 94 +++++++++++++++++------------ 4 files changed, 74 insertions(+), 149 deletions(-) delete mode 100644 src/pages/LinkOAuthCallback.tsx diff --git a/src/App.tsx b/src/App.tsx index f51c9d5..dd48cd3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,6 @@ const ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts')); -const LinkOAuthCallback = lazy(() => import('./pages/LinkOAuthCallback')); const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback')); const MergeAccounts = lazy(() => import('./pages/MergeAccounts')); @@ -317,16 +316,6 @@ function App() { } /> - - - - - - } - /> (null); const [linkingProvider, setLinkingProvider] = useState(null); const [waitingExternalLink, setWaitingExternalLink] = useState(false); + const linkedCountBeforePolling = useRef(null); const blurTimeoutRef = useRef>(undefined); const inTelegram = useIsTelegram(); @@ -124,6 +125,18 @@ export default function ConnectedAccounts() { return () => clearTimeout(timeout); }, [waitingExternalLink]); + // Detect successful external link: stop polling and show toast when linked count increases + useEffect(() => { + if (!waitingExternalLink || !data) return; + const currentLinked = data.providers.filter((p) => p.linked).length; + if (linkedCountBeforePolling.current === null) return; + if (currentLinked > linkedCountBeforePolling.current) { + setWaitingExternalLink(false); + linkedCountBeforePolling.current = null; + showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); + } + }, [data, waitingExternalLink, showToast, t]); + const unlinkMutation = useMutation({ mutationFn: (provider: string) => authApi.unlinkProvider(provider), onSuccess: () => { @@ -162,6 +175,8 @@ export default function ConnectedAccounts() { // The callback will use server-complete flow (auth via state token, no JWT). platform.openLink(authorize_url); setLinkingProvider(null); + // Snapshot current linked count before polling starts + linkedCountBeforePolling.current = data?.providers.filter((p) => p.linked).length ?? 0; // Start polling for linked providers (external browser has no way to notify Mini App) setWaitingExternalLink(true); showToast({ diff --git a/src/pages/LinkOAuthCallback.tsx b/src/pages/LinkOAuthCallback.tsx deleted file mode 100644 index 76a44e1..0000000 --- a/src/pages/LinkOAuthCallback.tsx +++ /dev/null @@ -1,101 +0,0 @@ -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 — shared with ConnectedAccounts.tsx -export const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; -export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider'; - -function getLinkOAuthState(): { state: string; provider: string } | null { - const state = sessionStorage.getItem(LINK_OAUTH_STATE_KEY); - const provider = sessionStorage.getItem(LINK_OAUTH_PROVIDER_KEY); - if (!state || !provider) return null; - return { state, provider }; -} - -function clearLinkOAuthState(): void { - sessionStorage.removeItem(LINK_OAUTH_STATE_KEY); - sessionStorage.removeItem(LINK_OAUTH_PROVIDER_KEY); -} - -export default function LinkOAuthCallback() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const { showToast } = useToast(); - const hasRun = useRef(false); - - useEffect(() => { - // Prevent double-fire from React StrictMode - if (hasRun.current) return; - hasRun.current = true; - - const linkAccount = async () => { - const code = searchParams.get('code'); - const urlState = searchParams.get('state'); - // VK ID returns device_id in callback URL - const deviceId = searchParams.get('device_id'); - - if (!code || !urlState) { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - navigate('/profile/accounts', { replace: true }); - return; - } - - // Get saved state from sessionStorage (read only, don't clear yet) - const saved = getLinkOAuthState(); - if (!saved) { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - navigate('/profile/accounts', { replace: true }); - return; - } - - // Validate state match - if (saved.state !== urlState) { - clearLinkOAuthState(); - showToast({ type: 'error', message: t('profile.accounts.linkError') }); - navigate('/profile/accounts', { replace: true }); - return; - } - - // State validated — clear it now (one-time use) - clearLinkOAuthState(); - - try { - const response = await authApi.linkProviderCallback( - saved.provider, - code, - urlState, - deviceId ?? undefined, - ); - - if (response.merge_required && response.merge_token) { - // Redirect to merge page - 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, showToast, t]); - - return ( -
-
-
-
-

{t('profile.accounts.linking')}

-

{t('common.loading')}

-
-
- ); -} diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index 01d07b9..512b4ab 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -3,7 +3,11 @@ import { useNavigate, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/auth'; import { authApi } from '../api/auth'; -import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './LinkOAuthCallback'; +import type { LinkCallbackResponse } from '../types'; + +// SessionStorage keys for OAuth LINK state (shared with ConnectedAccounts) +export const LINK_OAUTH_STATE_KEY = 'link_oauth_state'; +export const LINK_OAUTH_PROVIDER_KEY = 'link_oauth_provider'; // SessionStorage helpers for OAuth LOGIN state const OAUTH_STATE_KEY = 'oauth_state'; @@ -38,12 +42,15 @@ function clearLinkOAuthState(): void { type CallbackMode = 'login' | 'link-browser' | 'link-server'; -/** Result after successful server-complete linking from external browser. */ -interface ServerLinkResult { - success: boolean; - merge_required: boolean; - merge_token: string | null; - provider?: string; +type ServerLinkResult = LinkCallbackResponse & { provider?: string }; + +function getErrorDetail(err: unknown): string | null { + if (err && typeof err === 'object' && 'response' in err) { + const resp = (err as { response?: { data?: { detail?: string } } }).response; + if (resp?.data?.detail) return resp.data.detail; + } + if (err instanceof Error) return err.message; + return null; } export default function OAuthCallback() { @@ -51,11 +58,19 @@ export default function OAuthCallback() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [error, setError] = useState(''); + const [errorMode, setErrorMode] = useState('login'); const [serverLinkResult, setServerLinkResult] = useState(null); const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth); const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const hasRun = useRef(false); + // Handle merge redirect via useEffect (not in render) + useEffect(() => { + if (serverLinkResult?.merge_required && serverLinkResult.merge_token) { + navigate(`/merge/${serverLinkResult.merge_token}`, { replace: true }); + } + }, [serverLinkResult, navigate]); + useEffect(() => { // Prevent double-fire from React StrictMode if (hasRun.current) return; @@ -75,32 +90,32 @@ export default function OAuthCallback() { // 2. Login state in sessionStorage → login flow // 3. Neither → opened from external browser (Mini App flow) → server-complete let mode: CallbackMode = 'link-server'; - let savedProvider: string | null = null; - let savedState: string | null = null; + let provider: string | undefined; + let state: string | undefined; const linkSaved = peekLinkOAuthState(); if (linkSaved && linkSaved.state === urlState) { clearLinkOAuthState(); mode = 'link-browser'; - savedProvider = linkSaved.provider; - savedState = linkSaved.state; + provider = linkSaved.provider; + state = linkSaved.state; } else { const loginSaved = getAndClearOAuthState(); if (loginSaved && loginSaved.state === urlState) { mode = 'login'; - savedProvider = loginSaved.provider; - savedState = loginSaved.state; + provider = loginSaved.provider; + state = loginSaved.state; } } const handle = async () => { - if (mode === 'link-browser') { + if (mode === 'link-browser' && provider && state) { // Browser linking: user is authenticated, complete via JWT-protected endpoint try { const response = await authApi.linkProviderCallback( - savedProvider!, + provider, code, - savedState!, + state, deviceId ?? undefined, ); if (response.merge_required && response.merge_token) { @@ -109,24 +124,23 @@ export default function OAuthCallback() { navigate('/profile/accounts', { replace: true }); } } catch { + setErrorMode('link-browser'); setError(t('profile.accounts.linkError')); } return; } - if (mode === 'login') { + if (mode === 'login' && provider && state) { // Login flow if (isAuthenticated) { navigate('/', { replace: true }); return; } try { - await loginWithOAuth(savedProvider!, code, savedState!, deviceId); + await loginWithOAuth(provider, code, state, deviceId); navigate('/', { replace: true }); } catch (err: unknown) { - const detail = - (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? - (err instanceof Error ? err.message : null); + const detail = getErrorDetail(err); setError(detail || t('auth.oauthError', 'Authorization was denied or failed')); } return; @@ -141,9 +155,9 @@ export default function OAuthCallback() { try { // Provider is resolved server-side from the state token in Redis. const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined); - setServerLinkResult(response); } catch { + setErrorMode('link-server'); setError(t('profile.accounts.linkError')); } }; @@ -151,14 +165,9 @@ export default function OAuthCallback() { handle(); }, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]); - // Server-complete result: show success/error with "Return to Telegram" link - if (serverLinkResult) { - if (serverLinkResult.merge_required && serverLinkResult.merge_token) { - // Redirect to merge page (public, works without JWT) - navigate(`/merge/${serverLinkResult.merge_token}`, { replace: true }); - return null; - } - + // Server-complete result: show success with "Return to Telegram" link + // (merge redirect is handled by the useEffect above) + if (serverLinkResult && !(serverLinkResult.merge_required && serverLinkResult.merge_token)) { const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; @@ -197,6 +206,10 @@ export default function OAuthCallback() { } if (error) { + const isServerMode = errorMode === 'link-server'; + const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; + const telegramLink = botUsername ? `https://t.me/${botUsername}` : ''; + return (
@@ -219,12 +232,21 @@ export default function OAuthCallback() {

{t('auth.loginFailed')}

{error}

- + {isServerMode && telegramLink ? ( + + {t('profile.accounts.openTelegram')} + + ) : ( + + )}
From 2fc0759f89da90b7a349deb8a502417a4f790827 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 05:25:50 +0300 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20account=20merge=20flow=20?= =?UTF-8?q?=E2=80=94=20merge=20redirect,=20error=20handling,=20server-comp?= =?UTF-8?q?lete=20linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен merge_required + merge_token redirect в OAuthCallback и LinkTelegramCallback - Server-complete OAuth linking для Mini App (внешний браузер) - getErrorDetail: единый экстрактор ошибок из axios response - LinkTelegramCallback: типизированный catch с getErrorDetail - Удалён мёртвый экспорт getAndClearOAuthState - CSRF валидация для Telegram и OAuth linking flows - Типы ServerCompleteResponse, LinkCallbackResponse --- src/api/auth.ts | 5 +- src/api/client.ts | 11 +-- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/ConnectedAccounts.tsx | 63 ++++++++++++------ src/pages/LinkTelegramCallback.tsx | 8 ++- src/pages/OAuthCallback.tsx | 103 +++++++++++++++++------------ src/types/index.ts | 4 ++ 10 files changed, 120 insertions(+), 78 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index 787caba..bd5e1e6 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -7,6 +7,7 @@ import type { MergeResponse, OAuthProvider, RegisterResponse, + ServerCompleteResponse, TokenResponse, User, } from '../types'; @@ -260,8 +261,8 @@ export const authApi = { code: string, state: string, deviceId?: string, - ): Promise => { - const response = await apiClient.post( + ): Promise => { + const response = await apiClient.post( '/cabinet/auth/account/link/server-complete', { code, diff --git a/src/api/client.ts b/src/api/client.ts index eaddfb6..1f27460 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -81,7 +81,7 @@ const AUTH_ENDPOINTS = [ function isAuthEndpoint(url: string | undefined): boolean { if (!url) return false; - return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint)); + return AUTH_ENDPOINTS.some((endpoint) => url.startsWith(endpoint)); } // Request interceptor - add auth token with expiration check @@ -214,15 +214,10 @@ apiClient.interceptors.response.use( // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { // Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента - const authEndpoints = [ - '/cabinet/auth/email/login', - '/cabinet/auth/telegram', - '/cabinet/auth/telegram/widget', - ]; const requestUrl = originalRequest.url || ''; - const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint)); + const isLoginEndpoint = isAuthEndpoint(requestUrl); - if (isAuthEndpoint) { + if (isLoginEndpoint) { // Пробрасываем ошибку в компонент для показа сообщения пользователю return Promise.reject(error); } diff --git a/src/locales/en.json b/src/locales/en.json index 698c36e..453283c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3619,6 +3619,7 @@ "returnToTelegram": "Return to Telegram to continue", "openTelegram": "Open Telegram", "continueInBrowser": "Continue authorization in the opened browser", + "pollingTimeout": "Authorization check timed out. Refresh the page if you completed linking.", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/fa.json b/src/locales/fa.json index 29efa7e..3f8cdef 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3055,6 +3055,7 @@ "returnToTelegram": "برای ادامه به تلگرام بازگردید", "openTelegram": "باز کردن تلگرام", "continueInBrowser": "ادامه احراز هویت در مرورگر باز شده", + "pollingTimeout": "بررسی احراز هویت منقضی شد. اگر اتصال را تکمیل کرده‌اید صفحه را بارگذاری مجدد کنید.", "providers": { "telegram": "تلگرام", "email": "ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 80fe7b8..4042ac2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4179,6 +4179,7 @@ "returnToTelegram": "Вернитесь в Telegram, чтобы продолжить", "openTelegram": "Открыть Telegram", "continueInBrowser": "Продолжите авторизацию в открывшемся браузере", + "pollingTimeout": "Проверка авторизации истекла. Обновите страницу, если привязка завершена.", "providers": { "telegram": "Telegram", "email": "Email", diff --git a/src/locales/zh.json b/src/locales/zh.json index c6a9058..23d99a6 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3054,6 +3054,7 @@ "returnToTelegram": "返回 Telegram 继续操作", "openTelegram": "打开 Telegram", "continueInBrowser": "请在打开的浏览器中继续授权", + "pollingTimeout": "授权检查超时。如果您已完成关联,请刷新页面。", "providers": { "telegram": "Telegram", "email": "邮箱", diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index e2ecbba..94f3ada 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -9,7 +9,7 @@ import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import ProviderIcon from '../components/ProviderIcon'; -import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY } from './OAuthCallback'; +import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from './OAuthCallback'; import { getTelegramInitData } from '../hooks/useTelegramSDK'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; import type { LinkedProvider } from '../types'; @@ -98,7 +98,7 @@ export default function ConnectedAccounts() { const [confirmingUnlink, setConfirmingUnlink] = useState(null); const [linkingProvider, setLinkingProvider] = useState(null); const [waitingExternalLink, setWaitingExternalLink] = useState(false); - const linkedCountBeforePolling = useRef(null); + const pendingLinkProvider = useRef(null); const blurTimeoutRef = useRef>(undefined); const inTelegram = useIsTelegram(); @@ -118,21 +118,26 @@ export default function ConnectedAccounts() { refetchInterval: waitingExternalLink ? 5000 : false, }); - // Stop polling after 90 seconds + // Stop polling after 90 seconds with timeout feedback useEffect(() => { if (!waitingExternalLink) return; - const timeout = setTimeout(() => setWaitingExternalLink(false), 90_000); - return () => clearTimeout(timeout); - }, [waitingExternalLink]); - - // Detect successful external link: stop polling and show toast when linked count increases - useEffect(() => { - if (!waitingExternalLink || !data) return; - const currentLinked = data.providers.filter((p) => p.linked).length; - if (linkedCountBeforePolling.current === null) return; - if (currentLinked > linkedCountBeforePolling.current) { + const timeout = setTimeout(() => { setWaitingExternalLink(false); - linkedCountBeforePolling.current = null; + pendingLinkProvider.current = null; + // Final refresh in case link succeeded during the last polling interval + queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); + showToast({ type: 'warning', message: t('profile.accounts.pollingTimeout') }); + }, 90_000); + return () => clearTimeout(timeout); + }, [waitingExternalLink, showToast, t, queryClient]); + + // Detect successful external link: stop polling when the target provider becomes linked + useEffect(() => { + if (!waitingExternalLink || !data || !pendingLinkProvider.current) return; + const target = data.providers.find((p) => p.provider === pendingLinkProvider.current); + if (target?.linked) { + setWaitingExternalLink(false); + pendingLinkProvider.current = null; showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); } }, [data, waitingExternalLink, showToast, t]); @@ -169,14 +174,28 @@ export default function ConnectedAccounts() { setLinkingProvider(provider); try { const { authorize_url, state } = await authApi.linkProviderInit(provider); + if (!authorize_url || !state) { + throw new Error('Invalid response from server'); + } + + // Validate redirect URL — only allow HTTPS to prevent open redirect + let parsed: URL; + try { + parsed = new URL(authorize_url); + } catch { + throw new Error('Invalid OAuth redirect URL'); + } + if (parsed.protocol !== 'https:') { + throw new Error('Invalid OAuth redirect URL'); + } if (inTelegram) { // Mini App: open in external browser to avoid WebView OAuth restrictions. // The callback will use server-complete flow (auth via state token, no JWT). platform.openLink(authorize_url); setLinkingProvider(null); - // Snapshot current linked count before polling starts - linkedCountBeforePolling.current = data?.providers.filter((p) => p.linked).length ?? 0; + // Track which provider we're waiting to become linked + pendingLinkProvider.current = provider; // Start polling for linked providers (external browser has no way to notify Mini App) setWaitingExternalLink(true); showToast({ @@ -190,10 +209,10 @@ export default function ConnectedAccounts() { sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider); window.location.href = authorize_url; } - } catch { + } catch (err: unknown) { showToast({ type: 'error', - message: t('profile.accounts.linkError'), + message: getErrorDetail(err) || t('profile.accounts.linkError'), }); setLinkingProvider(null); } @@ -213,8 +232,8 @@ export default function ConnectedAccounts() { queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); } - } catch { - showToast({ type: 'error', message: t('profile.accounts.linkError') }); + } catch (err: unknown) { + showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') }); } finally { setLinkingProvider(null); } @@ -245,7 +264,7 @@ export default function ConnectedAccounts() { + ) : ( + + ); + return (
@@ -232,21 +261,7 @@ export default function OAuthCallback() {

{t('auth.loginFailed')}

{error}

- {isServerMode && telegramLink ? ( - - {t('profile.accounts.openTelegram')} - - ) : ( - - )} + {errorAction}
diff --git a/src/types/index.ts b/src/types/index.ts index 9c435a7..2a230ea 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -655,6 +655,10 @@ export interface LinkCallbackResponse { merge_token: string | null; } +export interface ServerCompleteResponse extends LinkCallbackResponse { + provider: string; +} + // Account Merge export interface MergeSubscriptionPreview { status: string; From 262303d623a6e8a597b3aa9310d1b8290b494595 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 05:46:07 +0300 Subject: [PATCH 18/21] feat: add sales_stats RBAC permission section to frontend - Update route guard and menu item to use sales_stats:read - Add sales_stats:read to marketer preset - Add i18n translations for sales_stats section (ru, en, fa, zh) --- src/App.tsx | 2 +- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/AdminPanel.tsx | 2 +- src/pages/AdminRoleEdit.tsx | 1 + 7 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index dd48cd3..26e0dad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -779,7 +779,7 @@ function App() { + diff --git a/src/locales/en.json b/src/locales/en.json index 453283c..ec4ad25 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2843,6 +2843,7 @@ "users": "Users", "tickets": "Tickets", "stats": "Statistics", + "sales_stats": "Sales statistics", "broadcasts": "Broadcasts", "tariffs": "Tariffs", "promocodes": "Promo codes", diff --git a/src/locales/fa.json b/src/locales/fa.json index 3f8cdef..7530135 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2576,6 +2576,7 @@ "users": "کاربران", "tickets": "تیکت‌ها", "stats": "آمار", + "sales_stats": "آمار فروش", "broadcasts": "پیام‌های همگانی", "tariffs": "تعرفه‌ها", "promocodes": "کدهای تخفیف", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4042ac2..17146de 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3391,6 +3391,7 @@ "users": "Пользователи", "tickets": "Тикеты", "stats": "Статистика", + "sales_stats": "Статистика продаж", "broadcasts": "Рассылки", "tariffs": "Тарифы", "promocodes": "Промокоды", diff --git a/src/locales/zh.json b/src/locales/zh.json index 23d99a6..86b3489 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2575,6 +2575,7 @@ "users": "用户", "tickets": "工单", "stats": "统计", + "sales_stats": "销售统计", "broadcasts": "广播", "tariffs": "套餐", "promocodes": "促销码", diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 1bcacaf..32f90b1 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -419,7 +419,7 @@ export default function AdminPanel() { icon: , title: t('admin.nav.salesStats'), description: t('admin.panel.salesStatsDesc'), - permission: 'stats:read', + permission: 'sales_stats:read', }, ], }, diff --git a/src/pages/AdminRoleEdit.tsx b/src/pages/AdminRoleEdit.tsx index 159c9a7..0e8f609 100644 --- a/src/pages/AdminRoleEdit.tsx +++ b/src/pages/AdminRoleEdit.tsx @@ -45,6 +45,7 @@ const PRESETS: Record = { 'promo_offers:*', 'promo_groups:*', 'stats:read', + 'sales_stats:read', 'pinned_messages:*', 'wheel:*', ], From d526d095dec1c4dc80f45ccd7940516a49051f3b Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 5 Mar 2026 10:56:45 +0300 Subject: [PATCH 19/21] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=85=D0=B0=D1=80=D0=B4=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B5=D0=BB=D1=91=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20(#3EDBB0)=20=D0=BD=D0=B0=20=D0=B0=D0=BA=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20=D1=86=D0=B2=D0=B5=D1=82=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все вхождения #3EDBB0, #2BC49A и rgba(62,219,176,...) заменены на CSS-переменные --color-accent-400/500 из системы тем. Затронутые компоненты: - TrialOfferCard: кнопка, бордер, свечение, иконка - Subscription: лейблы, иконки, кнопки паузы/копирования - SubscriptionCardActive: бордер карточки, бейдж статуса - SubscriptionPurchase: градиент триал-блока - PurchaseCTAButton: градиент кнопки, иконка - TrafficProgressBar: цвета прогресс-бара - StatsGrid: убрана зависимость от useTrafficZone, используется accent напрямую - trafficZone.ts: добавлены mainVar/mainVarRaw для CSS-переменных - tailwind.config.js: trialGlow keyframes на accent цвет --- src/components/dashboard/StatsGrid.tsx | 15 ++--- .../dashboard/SubscriptionCardActive.tsx | 56 ++++++++++--------- .../dashboard/TrafficProgressBar.tsx | 34 ++++++----- src/components/dashboard/TrialOfferCard.tsx | 29 ++++++---- .../subscription/PurchaseCTAButton.tsx | 8 ++- src/pages/Dashboard.tsx | 1 - src/pages/Subscription.tsx | 35 +++++++----- src/pages/SubscriptionPurchase.tsx | 3 +- src/utils/trafficZone.ts | 12 ++++ tailwind.config.js | 4 +- 10 files changed, 114 insertions(+), 83 deletions(-) diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 735d581..791ffd1 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -1,14 +1,11 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; -import type { Subscription } from '../../types'; import { useCurrency } from '../../hooks/useCurrency'; import { useTheme } from '../../hooks/useTheme'; -import { useTrafficZone } from '../../hooks/useTrafficZone'; import { getGlassColors } from '../../utils/glassTheme'; interface StatsGridProps { balanceRubles: number; - subscription: Subscription | null; referralCount: number; earningsRubles: number; refLoading: boolean; @@ -35,7 +32,6 @@ const ChevronIcon = ({ color }: { color: string }) => ( export default function StatsGrid({ balanceRubles, - subscription, referralCount, earningsRubles, refLoading, @@ -45,13 +41,14 @@ export default function StatsGrid({ const { isDark } = useTheme(); const g = getGlassColors(isDark); - const zone = useTrafficZone(subscription?.traffic_used_percent ?? 0); + const accentColor = 'rgb(var(--color-accent-400))'; + const accentBg = 'rgba(var(--color-accent-400), 0.07)'; const cards = [ { label: t('dashboard.stats.balance'), value: `${formatAmount(balanceRubles)} ${currencySymbol}`, - valueColor: zone.mainHex, + valueColor: accentColor, to: '/balance', icon: (color: string) => ( ), - iconBg: `${zone.mainHex}12`, - iconColor: zone.mainHex, + iconBg: accentBg, + iconColor: accentColor, loading: false, onboarding: 'balance', }, @@ -80,7 +77,7 @@ export default function StatsGrid({ value: `${referralCount}`, valueColor: g.text, subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`, - subtitleColor: zone.mainHex, + subtitleColor: accentColor, to: '/referral', icon: (color: string) => ( {/* Trial shimmer border */} @@ -96,7 +98,7 @@ export default function SubscriptionCardActive({ width: 200, height: 200, borderRadius: '50%', - background: `radial-gradient(circle, ${zone.mainHex}${g.glowAlpha} 0%, transparent 70%)`, + background: `radial-gradient(circle, rgba(${zone.mainVarRaw}, ${isDark ? 0.08 : 0.03}) 0%, transparent 70%)`, transition: 'background 0.8s ease', }} aria-hidden="true" @@ -110,15 +112,15 @@ export default function SubscriptionCardActive({