fix: referral system — stop cabinet redirect to Telegram, fix deep link code handling

- Remove redirect to Telegram bot when email auth disabled + referral code present
  (cabinet links should stay on cabinet, bot links should go to bot)
- Remove referral_code from deep link auth (existing users can't get referrals)
- Don't consume referralCode in deep link path — leave it in localStorage for
  OIDC/widget auth methods that actually send it to the backend
- Consume campaign slug once into ref to survive retries (codesConsumedRef pattern)
- Update loginWithDeepLink to only accept (token, campaignSlug) — no referralCode
- Update pollDeepLinkToken API to match backend schema change
This commit is contained in:
Fringg
2026-03-21 15:06:16 +03:00
parent a87085e2b2
commit 3c034d2e70
4 changed files with 24 additions and 17 deletions

View File

@@ -290,7 +290,7 @@ export const authApi = {
return response.data;
},
pollDeepLinkToken: async (token: string): Promise<AuthResponse> => {
pollDeepLinkToken: async (token: string, campaignSlug?: string | null): Promise<AuthResponse> => {
// validateStatus: only treat 200 as success.
// Server returns 202 for "pending" and 410 for "expired" —
// these must reject so the polling catch-block can handle them.
@@ -299,7 +299,7 @@ export const authApi = {
// which triggers checkAdminStatus() → 401 → safeRedirectToLogin() → infinite reload.
const response = await apiClient.post<AuthResponse>(
'/cabinet/auth/deeplink/poll',
{ token },
{ token, campaign_slug: campaignSlug || undefined },
{ validateStatus: (status) => status === 200 },
);
return response.data;

View File

@@ -6,6 +6,7 @@ import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
import { useNavigate } from 'react-router';
import { consumeCampaignSlug } from '../utils/campaign';
interface TelegramLoginButtonProps {
referralCode?: string;
@@ -34,6 +35,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
const loginWithDeepLink = useAuthStore((s) => s.loginWithDeepLink);
// Capture campaign slug once on mount (before any retry clears it)
const capturedCampaignRef = useRef<string | null>(null);
const codesConsumedRef = useRef(false);
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'],
queryFn: brandingApi.getTelegramWidgetConfig,
@@ -239,6 +244,17 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
}
try {
// Consume campaign slug ONCE (first call only).
// Clears localStorage on first call, so subsequent retries reuse the ref.
// Note: referral code is NOT consumed here — deep link auth is for existing
// bot users where referrals don't apply. Leaving it in localStorage allows
// other auth methods (OIDC, widget) to pick it up if the user switches paths.
if (!codesConsumedRef.current) {
capturedCampaignRef.current = consumeCampaignSlug();
codesConsumedRef.current = true;
}
const capturedCampaign = capturedCampaignRef.current;
const response = await authApi.requestDeepLinkToken();
const { token, bot_username, expires_in } = response;
setDeepLinkToken(token);
@@ -249,7 +265,8 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
const poll = async () => {
if (!mountedRef.current) return;
try {
await loginWithDeepLink(token);
// Deep link auth is for existing bot users — only campaign_slug applies
await loginWithDeepLink(token, capturedCampaign);
// Success — auth store is updated, navigate
if (expireTimeoutRef.current) {
clearTimeout(expireTimeoutRef.current);

View File

@@ -22,7 +22,7 @@ import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
import OAuthProviderIcon from '../components/OAuthProviderIcon';
import { saveOAuthState } from '../utils/oauth';
import { consumeReferralCode, getPendingReferralCode } from '../utils/referral';
import { getPendingReferralCode } from '../utils/referral';
export default function Login() {
const { t } = useTranslation();
@@ -146,16 +146,6 @@ export default function Login() {
}
};
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
// If email auth is disabled but user came with ref param, redirect to bot
useEffect(() => {
if (referralCode && emailAuthConfig?.enabled === false && botUsername) {
consumeReferralCode();
window.location.href = `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`;
}
}, [referralCode, emailAuthConfig, botUsername]);
const appName = branding ? branding.name : import.meta.env.VITE_APP_NAME || 'VPN';
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V';
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;

View File

@@ -45,7 +45,7 @@ interface AuthState {
state: string,
deviceId?: string | null,
) => Promise<void>;
loginWithDeepLink: (token: string) => Promise<void>;
loginWithDeepLink: (token: string, campaignSlug?: string | null) => Promise<void>;
registerWithEmail: (
email: string,
password: string,
@@ -322,8 +322,8 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus();
},
loginWithDeepLink: async (token) => {
const response = await authApi.pollDeepLinkToken(token);
loginWithDeepLink: async (token, campaignSlug) => {
const response = await authApi.pollDeepLinkToken(token, campaignSlug);
if (!response.access_token || !response.refresh_token) {
throw new Error('Invalid auth response: missing tokens');
}