= {
GET: 'bg-blue-500/20 text-blue-400',
- POST: 'bg-green-500/20 text-green-400',
+ POST: 'bg-success-500/20 text-success-400',
PUT: 'bg-amber-500/20 text-amber-400',
PATCH: 'bg-amber-500/20 text-amber-400',
DELETE: 'bg-red-500/20 text-red-400',
diff --git a/src/pages/AdminBroadcastDetail.tsx b/src/pages/AdminBroadcastDetail.tsx
index 1feb385..1bdf2c5 100644
--- a/src/pages/AdminBroadcastDetail.tsx
+++ b/src/pages/AdminBroadcastDetail.tsx
@@ -93,7 +93,7 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
}
return (
-
+
+
diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx
index 304edc5..a846752 100644
--- a/src/pages/AdminDashboard.tsx
+++ b/src/pages/AdminDashboard.tsx
@@ -1062,12 +1062,17 @@ export default function AdminDashboard() {
className="border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
>
|
-
- {payment.display_name}
-
- {payment.username && (
- @{payment.username}
- )}
+
|
{payment.type_display}
-
+
+
{formatAmount(payment.amount_rubles)} {currencySymbol}
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/AdminPolicies.tsx b/src/pages/AdminPolicies.tsx
index bc11e36..b83c4f2 100644
--- a/src/pages/AdminPolicies.tsx
+++ b/src/pages/AdminPolicies.tsx
@@ -145,7 +145,7 @@ function EffectBadge({ effect, className }: EffectBadgeProps) {
@@ -329,7 +329,7 @@ export default function AdminPolicies() {
{t('admin.policies.stats.total')}
-
+
{sortedPolicies.filter((p) => p.effect === 'allow').length}
{t('admin.policies.stats.allow')}
diff --git a/src/pages/AdminPolicyEdit.tsx b/src/pages/AdminPolicyEdit.tsx
index a0fc77b..7a105a6 100644
--- a/src/pages/AdminPolicyEdit.tsx
+++ b/src/pages/AdminPolicyEdit.tsx
@@ -444,7 +444,7 @@ export default function AdminPolicyEdit() {
onClick={() => setFormData((prev) => ({ ...prev, effect: 'allow' }))}
className={`flex-1 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
formData.effect === 'allow'
- ? 'border-green-500/50 bg-green-500/10 text-green-400'
+ ? 'border-success-500/50 bg-success-500/10 text-success-400'
: 'border-dark-600 bg-dark-900 text-dark-400 hover:border-dark-500'
}`}
>
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:*',
],
diff --git a/src/pages/AdminUpdates.tsx b/src/pages/AdminUpdates.tsx
index 283831f..12e6620 100644
--- a/src/pages/AdminUpdates.tsx
+++ b/src/pages/AdminUpdates.tsx
@@ -183,8 +183,8 @@ function VersionBadge({ hasUpdate }: { hasUpdate: boolean }) {
}
return (
-
-
+
+
{t('adminUpdates.upToDate')}
);
diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx
new file mode 100644
index 0000000..94f3ada
--- /dev/null
+++ b/src/pages/ConnectedAccounts.tsx
@@ -0,0 +1,378 @@
+import { useState, useEffect, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router';
+import { useQuery, useMutation, useQueryClient } 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 ProviderIcon from '../components/ProviderIcon';
+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';
+
+const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk'];
+
+const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider);
+
+const isLinkableProvider = (provider: string): boolean =>
+ isOAuthProvider(provider) || provider === 'telegram';
+
+// SessionStorage key for Telegram link CSRF state
+export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state';
+
+/** Compact Telegram Login Widget for account linking (browser only). */
+function TelegramLinkWidget() {
+ const containerRef = useRef(null);
+ const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
+
+ useEffect(() => {
+ if (!containerRef.current || !botUsername) return;
+
+ const container = containerRef.current;
+ while (container.firstChild) {
+ container.removeChild(container.firstChild);
+ }
+
+ // Generate CSRF state token and store in sessionStorage
+ const csrfState = crypto.randomUUID();
+ sessionStorage.setItem(LINK_TELEGRAM_STATE_KEY, csrfState);
+
+ const redirectUrl = `${window.location.origin}/auth/link/telegram/callback?csrf_state=${encodeURIComponent(csrfState)}`;
+
+ const script = document.createElement('script');
+ script.src = 'https://telegram.org/js/telegram-widget.js?22';
+ script.setAttribute('data-telegram-login', botUsername);
+ script.setAttribute('data-size', 'small');
+ script.setAttribute('data-radius', '8');
+ script.setAttribute('data-auth-url', redirectUrl);
+ script.setAttribute('data-request-access', 'write');
+ script.async = true;
+
+ container.appendChild(script);
+
+ return () => {
+ while (container.firstChild) {
+ container.removeChild(container.firstChild);
+ }
+ };
+ }, [botUsername]);
+
+ if (!botUsername) {
+ return null;
+ }
+
+ return ;
+}
+
+function LoadingSkeleton() {
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+
+
+ ))}
+
+ );
+}
+
+export default function ConnectedAccounts() {
+ const { t } = useTranslation();
+ const { showToast } = useToast();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+
+ const [confirmingUnlink, setConfirmingUnlink] = useState(null);
+ const [linkingProvider, setLinkingProvider] = useState(null);
+ const [waitingExternalLink, setWaitingExternalLink] = useState(false);
+ const pendingLinkProvider = useRef(null);
+ const blurTimeoutRef = useRef>(undefined);
+
+ const inTelegram = useIsTelegram();
+ const platform = usePlatform();
+
+ useEffect(() => {
+ return () => {
+ if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);
+ };
+ }, []);
+
+ const { data, isLoading, isError } = useQuery({
+ 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 with timeout feedback
+ useEffect(() => {
+ if (!waitingExternalLink) return;
+ const timeout = setTimeout(() => {
+ setWaitingExternalLink(false);
+ 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]);
+
+ const unlinkMutation = useMutation({
+ mutationFn: (provider: string) => authApi.unlinkProvider(provider),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
+ showToast({
+ type: 'success',
+ message: t('profile.accounts.unlinkSuccess'),
+ });
+ },
+ onError: () => {
+ showToast({
+ type: 'error',
+ message: t('profile.accounts.unlinkError'),
+ });
+ },
+ onSettled: () => {
+ setConfirmingUnlink(null);
+ },
+ });
+
+ 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 handleLinkOAuth = async (provider: string) => {
+ if (linkingProvider) return;
+ 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);
+ // 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({
+ 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.
+ sessionStorage.setItem(LINK_OAUTH_STATE_KEY, state);
+ sessionStorage.setItem(LINK_OAUTH_PROVIDER_KEY, provider);
+ window.location.href = authorize_url;
+ }
+ } catch (err: unknown) {
+ showToast({
+ type: 'error',
+ message: getErrorDetail(err) || t('profile.accounts.linkError'),
+ });
+ setLinkingProvider(null);
+ }
+ };
+
+ const handleLinkTelegram = async () => {
+ if (linkingProvider) return;
+ const initData = getTelegramInitData();
+ if (!initData) return;
+
+ setLinkingProvider('telegram');
+ try {
+ const response = await authApi.linkTelegram({ init_data: initData });
+ if (response.merge_required && response.merge_token) {
+ navigate(`/merge/${response.merge_token}`, { replace: true });
+ } else {
+ queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
+ showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
+ }
+ } catch (err: unknown) {
+ showToast({ type: 'error', message: getErrorDetail(err) || t('profile.accounts.linkError') });
+ } finally {
+ setLinkingProvider(null);
+ }
+ };
+
+ const handleLink = async (provider: string) => {
+ if (provider === 'telegram') {
+ await handleLinkTelegram();
+ } else {
+ await handleLinkOAuth(provider);
+ }
+ };
+
+ const handleUnlink = (provider: string) => {
+ if (confirmingUnlink === provider) {
+ setConfirmingUnlink(null);
+ unlinkMutation.mutate(provider);
+ } else {
+ setConfirmingUnlink(provider);
+ }
+ };
+
+ const renderLinkButton = (provider: LinkedProvider) => {
+ if (provider.provider === 'telegram') {
+ if (inTelegram && getTelegramInitData()) {
+ // Mini App: one-click button
+ return (
+
+ );
+ }
+ // Browser: Telegram Login Widget
+ return ;
+ }
+
+ if (isOAuthProvider(provider.provider)) {
+ return (
+
+ );
+ }
+
+ return null;
+ };
+
+ return (
+
+ {/* Page title */}
+
+
+ {t('profile.accounts.title')}
+
+ {t('profile.accounts.subtitle')}
+
+
+ {/* Loading state */}
+ {isLoading && (
+
+
+
+ )}
+
+ {/* Error state */}
+ {isError && (
+
+
+ {t('common.error')}
+
+
+ )}
+
+ {/* Provider cards */}
+ {data?.providers.map((provider) => (
+
+
+
+
+
+
+
+ {t(`profile.accounts.providers.${provider.provider}`)}
+
+ {provider.identifier && (
+ {provider.identifier}
+ )}
+
+
+
+ {provider.linked ? (
+ <>
+ {t('profile.accounts.linked')}
+ {canUnlink(provider) && (
+
+ )}
+ >
+ ) : (
+ isLinkableProvider(provider.provider) && renderLinkButton(provider)
+ )}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 8da5359..b355b6d 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -263,7 +263,6 @@ export default function Dashboard() {
{/* Stats Grid */}
{
+ 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 (err: unknown) {
+ showToast({
+ type: 'error',
+ message: getErrorDetail(err) || t('profile.accounts.linkError'),
+ });
+ navigate('/profile/accounts', { replace: true });
+ }
+ };
+
+ linkAccount();
+ }, [searchParams, navigate, showToast, t]);
+
+ return (
+
+
+
+
+
+ {t('profile.accounts.linkingTelegram')}
+
+ {t('common.loading')}
+
+
+ );
+}
diff --git a/src/pages/MergeAccounts.tsx b/src/pages/MergeAccounts.tsx
new file mode 100644
index 0000000..846a5ff
--- /dev/null
+++ b/src/pages/MergeAccounts.tsx
@@ -0,0 +1,582 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate, Link } from 'react-router';
+import { useTranslation } from 'react-i18next';
+import { useQuery, useMutation, useQueryClient } 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 ProviderIcon from '../components/ProviderIcon';
+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 (
+
+ );
+}
+
+// -- Helpers --
+
+function ProviderBadgeIcon({ provider }: { provider: string }) {
+ return ;
+}
+
+function formatCountdown(seconds: number): string {
+ 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 {
+ 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 (
+
+ );
+}
+
+// -- 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.error')}
+
+
+
+
+ {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 queryClient = useQueryClient();
+
+ 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: () => {
+ if (!mergeToken) return Promise.reject(new Error('Missing merge token'));
+ return authApi.getMergePreview(mergeToken);
+ },
+ enabled: !!mergeToken,
+ retry: false,
+ staleTime: Infinity,
+ refetchOnWindowFocus: false,
+ });
+
+ // Auto-select subscription when data loads (only once)
+ useEffect(() => {
+ if (!data) return;
+ // Don't overwrite if user already made a selection
+ if (selectedUserId !== null) 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, selectedUserId]);
+
+ // Countdown timer (wall-clock based to avoid drift)
+ useEffect(() => {
+ if (!data) return;
+ const startTime = Date.now();
+ const totalSeconds = data.expires_in_seconds;
+
+ const tick = () => {
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
+ const remaining = totalSeconds - elapsed;
+ if (remaining <= 0) {
+ setExpiresIn(0);
+ setIsExpired(true);
+ clearInterval(interval);
+ } else {
+ setExpiresIn(remaining);
+ }
+ };
+
+ tick();
+ const interval = setInterval(tick, 1000);
+ return () => clearInterval(interval);
+ }, [data]);
+
+ // Execute merge
+ const mergeMutation = useMutation({
+ 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) {
+ showToast({ type: 'error', message: t('merge.error') });
+ return;
+ }
+
+ if (!response.access_token || !response.refresh_token) {
+ showToast({ type: 'error', message: t('merge.error') });
+ return;
+ }
+
+ const { setTokens, setUser, checkAdminStatus } = useAuthStore.getState();
+ setTokens(response.access_token, response.refresh_token);
+ if (response.user) {
+ setUser(response.user);
+ }
+ try {
+ await checkAdminStatus();
+ } catch {
+ // Non-critical — admin status will be checked on next navigation
+ }
+
+ queryClient.clear();
+ showToast({ type: 'success', message: t('merge.success') });
+ navigate('/profile/accounts', { replace: true });
+ },
+ onError: () => {
+ showToast({
+ type: 'error',
+ message: t('merge.error'),
+ });
+ },
+ });
+
+ const handleMerge = () => {
+ if (!selectedUserId || mergeMutation.isPending || isExpired) return;
+ mergeMutation.mutate();
+ };
+
+ const handleCancel = () => {
+ navigate('/profile/accounts', { replace: true });
+ };
+
+ // 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;
+
+ // Missing token param
+ if (!mergeToken) {
+ return ;
+ }
+
+ // 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')}
+
+
+ )}
+
+ {/* Account cards */}
+
+
+ setSelectedUserId(data.primary.id)}
+ showRadio={!!bothHaveSubscriptions}
+ />
+
+
+
+ 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/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx
index ffccf93..0981ce9 100644
--- a/src/pages/OAuthCallback.tsx
+++ b/src/pages/OAuthCallback.tsx
@@ -2,8 +2,14 @@ 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 type { ServerCompleteResponse } from '../types';
-// SessionStorage helpers for OAuth state
+// 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';
const OAUTH_PROVIDER_KEY = 'oauth_provider';
@@ -12,73 +18,227 @@ export function saveOAuthState(state: string, provider: string): void {
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
}
-export function getAndClearOAuthState(): { state: string; provider: string } | null {
- const state = sessionStorage.getItem(OAUTH_STATE_KEY);
- const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
- sessionStorage.removeItem(OAUTH_STATE_KEY);
- sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
+/** 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);
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';
+
+export function getErrorDetail(err: unknown): string | null {
+ if (err && typeof err === 'object' && 'response' in err) {
+ const resp = (err as { response?: { data?: { detail?: unknown } } }).response;
+ const detail = resp?.data?.detail;
+ if (typeof detail === 'string') return detail;
+ if (detail && typeof detail === 'object' && 'message' in detail) {
+ const msg = (detail as Record).message;
+ if (typeof msg === 'string') return msg;
+ }
+ }
+ if (err instanceof Error) return err.message;
+ return null;
+}
+
export default function OAuthCallback() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState('');
+ const [errorMode, setErrorMode] = useState('login');
+ const [serverLinkResult, setServerCompleteResponse] = 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 (isAuthenticated) {
- navigate('/', { replace: true });
- return;
+ if (serverLinkResult?.merge_required && serverLinkResult.merge_token) {
+ navigate(`/merge/${serverLinkResult.merge_token}`, { replace: true });
}
+ }, [serverLinkResult, navigate]);
- // Prevent double-fire from React StrictMode or dependency changes
+ useEffect(() => {
+ // 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 provider: string | undefined;
+ let state: string | undefined;
+
+ const linkSaved = peekLinkOAuthState();
+ if (linkSaved && linkSaved.state === urlState) {
+ clearLinkOAuthState();
+ mode = 'link-browser';
+ provider = linkSaved.provider;
+ state = linkSaved.state;
+ } else {
+ // Peek at login state first; only clear if it matches URL state
+ const loginState = sessionStorage.getItem(OAUTH_STATE_KEY);
+ const loginProvider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
+ if (loginState && loginProvider && loginState === urlState) {
+ sessionStorage.removeItem(OAUTH_STATE_KEY);
+ sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
+ mode = 'login';
+ provider = loginProvider;
+ state = loginState;
+ }
+ }
+
+ const handle = async () => {
+ // Clear sensitive OAuth params (code, state) from URL immediately for all modes
+ window.history.replaceState({}, '', '/auth/oauth/callback');
+
+ if (mode === 'link-browser' && provider && state) {
+ // Browser linking: user is authenticated, complete via JWT-protected endpoint
+ try {
+ const response = await authApi.linkProviderCallback(
+ provider,
+ code,
+ state,
+ deviceId ?? undefined,
+ );
+ if (response.merge_required && response.merge_token) {
+ navigate(`/merge/${response.merge_token}`, { replace: true });
+ } else {
+ navigate('/profile/accounts', { replace: true });
+ }
+ } catch (err: unknown) {
+ setErrorMode('link-browser');
+ setError(getErrorDetail(err) || 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.'));
- return;
- }
-
- // Validate state match
- if (saved.state !== urlState) {
- setError(t('auth.oauthError', 'Authorization was denied or failed'));
+ if (mode === 'login' && provider && state) {
+ // Login flow
+ if (isAuthenticated) {
+ navigate('/', { replace: true });
+ return;
+ }
+ try {
+ await loginWithOAuth(provider, code, state, deviceId);
+ navigate('/', { replace: true });
+ } catch (err: unknown) {
+ const detail = getErrorDetail(err);
+ setError(detail || 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.
try {
- await loginWithOAuth(saved.provider, code, urlState, deviceId);
- navigate('/', { replace: true });
+ // Provider is resolved server-side from the state token in Redis.
+ const response = await authApi.linkServerComplete(code, urlState, deviceId ?? undefined);
+ setServerCompleteResponse(response);
} 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'));
+ setErrorMode('link-server');
+ setError(getErrorDetail(err) || t('profile.accounts.linkError'));
}
};
- authenticate();
+ handle();
}, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]);
+ // Server-complete result: show success with "Return to Telegram" link
+ // (merge redirect is handled by the useEffect above)
+ if (
+ serverLinkResult &&
+ serverLinkResult.success &&
+ !(serverLinkResult.merge_required && serverLinkResult.merge_token)
+ ) {
+ const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
+ const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
+
+ return (
+
+ );
+ }
+
if (error) {
+ const isServerMode = errorMode === 'link-server';
+ const isLinkBrowserMode = errorMode === 'link-browser';
+ const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
+ const telegramLink = botUsername ? `https://t.me/${botUsername}` : '';
+
+ const errorAction =
+ isServerMode && telegramLink ? (
+
+ {t('profile.accounts.openTelegram')}
+
+ ) : isLinkBrowserMode ? (
+
+ ) : (
+
+ );
+
return (
@@ -101,12 +261,7 @@ export default function OAuthCallback() {
{t('auth.loginFailed')}
{error}
-
+ {errorAction}
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/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 330b62e..761e3e7 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -447,7 +447,7 @@ export default function Subscription() {
style={{
background: g.cardBg,
border: subscription.is_trial
- ? '1px solid rgba(62,219,176,0.15)'
+ ? '1px solid rgba(var(--color-accent-400), 0.15)'
: isDark
? `1px solid ${g.cardBorder}`
: `1px solid ${zone.mainHex}25`,
@@ -535,21 +535,21 @@ export default function Subscription() {
className="mb-6 rounded-[14px] p-4"
style={{
background:
- 'linear-gradient(135deg, rgba(62,219,176,0.08), rgba(62,219,176,0.03))',
- border: '1px solid rgba(62,219,176,0.12)',
+ 'linear-gradient(135deg, rgba(var(--color-accent-400), 0.08), rgba(var(--color-accent-400), 0.03))',
+ border: '1px solid rgba(var(--color-accent-400), 0.12)',
}}
>
-
+
{t('subscription.trialInfo.title')}
@@ -569,7 +572,7 @@ export default function Subscription() {
{subscription.days_left > 0
? t('subscription.days', { count: subscription.days_left })
@@ -582,7 +585,7 @@ export default function Subscription() {
{subscription.traffic_limit_gb || '∞'} {t('common.units.gb')}
@@ -593,7 +596,7 @@ export default function Subscription() {
{subscription.device_limit}
@@ -749,9 +752,11 @@ export default function Subscription() {
onClick={copyUrl}
className="flex h-auto items-center rounded-[10px] px-3 transition-colors duration-300"
style={{
- background: copied ? 'rgba(62,219,176,0.12)' : g.innerBorder,
- border: copied ? '1px solid rgba(62,219,176,0.2)' : `1px solid ${g.trackBg}`,
- color: copied ? '#3EDBB0' : g.textMuted,
+ background: copied ? 'rgba(var(--color-accent-400), 0.12)' : g.innerBorder,
+ border: copied
+ ? '1px solid rgba(var(--color-accent-400), 0.2)'
+ : `1px solid ${g.trackBg}`,
+ color: copied ? 'rgb(var(--color-accent-400))' : g.textMuted,
}}
title={t('subscription.copyLink')}
>
@@ -979,12 +984,12 @@ export default function Subscription() {
className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-colors duration-300"
style={{
background: subscription.is_daily_paused
- ? 'rgba(62,219,176,0.12)'
+ ? 'rgba(var(--color-accent-400), 0.12)'
: 'rgba(255,184,0,0.12)',
border: subscription.is_daily_paused
- ? '1px solid rgba(62,219,176,0.2)'
+ ? '1px solid rgba(var(--color-accent-400), 0.2)'
: '1px solid rgba(255,184,0,0.2)',
- color: subscription.is_daily_paused ? '#3EDBB0' : '#FFB800',
+ color: subscription.is_daily_paused ? 'rgb(var(--color-accent-400))' : '#FFB800',
}}
>
{pauseMutation.isPending ? (
diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx
index e5c96ab..040a26e 100644
--- a/src/pages/SubscriptionPurchase.tsx
+++ b/src/pages/SubscriptionPurchase.tsx
@@ -412,7 +412,8 @@ export default function SubscriptionPurchase() {
diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx
index e9f0a6c..4ade266 100644
--- a/src/pages/VerifyEmail.tsx
+++ b/src/pages/VerifyEmail.tsx
@@ -86,7 +86,7 @@ export default function VerifyEmail() {
{status === 'success' && (
- ✓
+ ✓
{t('emailVerification.success')}
diff --git a/src/types/index.ts b/src/types/index.ts
index 85882e7..2a230ea 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -636,3 +636,61 @@ 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;
+}
+
+export interface ServerCompleteResponse extends LinkCallbackResponse {
+ provider: string;
+}
+
+// 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;
+}
diff --git a/src/utils/trafficZone.ts b/src/utils/trafficZone.ts
index f2c9b2d..78fa0af 100644
--- a/src/utils/trafficZone.ts
+++ b/src/utils/trafficZone.ts
@@ -10,6 +10,10 @@ interface TrafficZoneResult {
labelKey: string;
gradientFrom: string;
gradientTo: string;
+ /** CSS variable for the main zone color: `rgb(var(--color-accent-400))` */
+ mainVar: string;
+ /** Raw CSS variable reference for opacity manipulation: `var(--color-accent-400)` */
+ mainVarRaw: string;
/** Key into ThemeColors for resolving mainHex at runtime */
colorKey: TrafficColorKey;
}
@@ -22,6 +26,8 @@ const ZONES: Record > = {
labelKey: 'dashboard.zone.normal',
gradientFrom: 'rgb(var(--color-accent-500))',
gradientTo: 'rgb(var(--color-accent-400))',
+ mainVar: 'rgb(var(--color-accent-400))',
+ mainVarRaw: 'var(--color-accent-400)',
colorKey: 'accent',
},
warning: {
@@ -31,6 +37,8 @@ const ZONES: Record> = {
labelKey: 'dashboard.zone.warning',
gradientFrom: 'rgb(var(--color-warning-500))',
gradientTo: 'rgb(var(--color-warning-400))',
+ mainVar: 'rgb(var(--color-warning-400))',
+ mainVarRaw: 'var(--color-warning-400)',
colorKey: 'warning',
},
danger: {
@@ -40,6 +48,8 @@ const ZONES: Record> = {
labelKey: 'dashboard.zone.danger',
gradientFrom: 'rgb(var(--color-warning-600))',
gradientTo: 'rgb(var(--color-warning-400))',
+ mainVar: 'rgb(var(--color-warning-400))',
+ mainVarRaw: 'var(--color-warning-400)',
colorKey: 'warning',
},
critical: {
@@ -49,6 +59,8 @@ const ZONES: Record> = {
labelKey: 'dashboard.zone.critical',
gradientFrom: 'rgb(var(--color-error-500))',
gradientTo: 'rgb(var(--color-error-400))',
+ mainVar: 'rgb(var(--color-error-400))',
+ mainVarRaw: 'var(--color-error-400)',
colorKey: 'error',
},
};
diff --git a/tailwind.config.js b/tailwind.config.js
index 834c809..6977e02 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -273,8 +273,8 @@ export default {
'50%': { opacity: '0.5', transform: 'scale(0.7)' },
},
trialGlow: {
- '0%, 100%': { boxShadow: '0 0 15px rgba(62, 219, 176, 0.06)' },
- '50%': { boxShadow: '0 0 30px rgba(62, 219, 176, 0.12)' },
+ '0%, 100%': { boxShadow: '0 0 15px rgba(var(--color-accent-400), 0.06)' },
+ '50%': { boxShadow: '0 0 30px rgba(var(--color-accent-400), 0.12)' },
},
},
transitionTimingFunction: {
|