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; +}