import { useState, useEffect, useMemo, useCallback } from 'react'; import { useNavigate, useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { useAuthStore } from '../store/auth'; import { useShallow } from 'zustand/shallow'; import { authApi } from '../api/auth'; import { isValidEmail } from '../utils/validation'; import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded, type BrandingInfo, type EmailAuthEnabled, } from '../api/branding'; import { getAndClearReturnUrl, tokenStorage } from '../utils/token'; import { getApiErrorMessage } from '../utils/api-error'; import { isInTelegramWebApp, getTelegramInitData, useTelegramSDK } from '../hooks/useTelegramSDK'; import { closeMiniApp } from '@telegram-apps/sdk-react'; import LanguageSwitcher from '../components/LanguageSwitcher'; import TelegramLoginButton from '../components/TelegramLoginButton'; import OAuthProviderIcon from '../components/OAuthProviderIcon'; import { saveOAuthState } from '../utils/oauth'; import { getPendingReferralCode } from '../utils/referral'; import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons'; import LegalFooter from '../components/LegalFooter'; export default function Login() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { isAuthenticated, isLoading: isAuthInitializing, loginWithTelegram, loginWithEmail, registerWithEmail, } = useAuthStore( useShallow((state) => ({ isAuthenticated: state.isAuthenticated, isLoading: state.isLoading, loginWithTelegram: state.loginWithTelegram, loginWithEmail: state.loginWithEmail, registerWithEmail: state.registerWithEmail, })), ); // Get referral code from localStorage (captured from ?ref= param at module level in auth store) const referralCode = getPendingReferralCode() || ''; const [authMode, setAuthMode] = useState<'login' | 'register'>(() => referralCode ? 'register' : 'login', ); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [firstName, setFirstName] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); const [isTelegramWebApp, setIsTelegramWebApp] = useState(false); const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded()); const [registeredEmail, setRegisteredEmail] = useState(null); const [showForgotPassword, setShowForgotPassword] = useState(false); const [forgotPasswordEmail, setForgotPasswordEmail] = useState(''); const [forgotPasswordSent, setForgotPasswordSent] = useState(false); const [forgotPasswordLoading, setForgotPasswordLoading] = useState(false); const [forgotPasswordError, setForgotPasswordError] = useState(''); const [showEmailForm, setShowEmailForm] = useState(true); // Telegram safe area insets const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK(); const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top); const safeBottom = Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom); // Получаем URL для возврата после авторизации const getReturnUrl = useCallback(() => { // Сначала проверяем state от React Router const stateFrom = (location.state as { from?: string })?.from; if (stateFrom && stateFrom !== '/login') { return stateFrom; } // Затем проверяем сохранённый URL в sessionStorage (от safeRedirectToLogin) const savedUrl = getAndClearReturnUrl(); if (savedUrl && savedUrl !== '/login') { return savedUrl; } // По умолчанию на главную return '/'; }, [location.state]); // Fetch branding with unified cache const cachedBranding = useMemo(() => getCachedBranding(), []); const { data: branding } = useQuery({ queryKey: ['branding'], queryFn: async () => { const data = await brandingApi.getBranding(); setCachedBranding(data); await preloadLogo(data); return data; }, staleTime: 60000, initialData: cachedBranding ?? undefined, initialDataUpdatedAt: 0, }); // Check if email auth is enabled const { data: emailAuthConfig } = useQuery({ queryKey: ['email-auth-enabled'], queryFn: brandingApi.getEmailAuthEnabled, staleTime: 60000, }); const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true; const { data: footerEnabled } = useQuery({ queryKey: ['footer-enabled'], queryFn: brandingApi.getFooterEnabled, staleTime: 60000, }); // Fetch enabled OAuth providers const { data: oauthData } = useQuery({ queryKey: ['oauth-providers'], queryFn: authApi.getOAuthProviders, staleTime: 60000, }); const oauthProviders = Array.isArray(oauthData?.providers) ? oauthData.providers : []; const [oauthLoading, setOauthLoading] = useState(null); const handleOAuthLogin = async (provider: string) => { setError(''); setOauthLoading(provider); try { const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider); // 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'); } saveOAuthState(state, provider); window.location.href = authorize_url; } catch { setError(t('auth.oauthError', 'Authorization was denied or failed')); setOauthLoading(null); } }; 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; // Set document title useEffect(() => { document.title = appName || 'VPN'; }, [appName]); useEffect(() => { if (isAuthenticated) { navigate(getReturnUrl(), { replace: true }); } }, [isAuthenticated, navigate, getReturnUrl]); // Try Telegram WebApp authentication on mount (with auto-retry on 401) // Wait for auth store initialization to complete to avoid race conditions // with stale tokens triggering interceptor refresh/redirect loops useEffect(() => { // Don't attempt Telegram auth until store initialization is done if (isAuthInitializing) return; const tryTelegramAuth = async () => { const initData = getTelegramInitData(); if (!isInTelegramWebApp() || !initData) return; setIsTelegramWebApp(true); setIsLoading(true); const MAX_RETRIES = 1; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { try { await loginWithTelegram(initData); navigate(getReturnUrl(), { replace: true }); return; } catch (err) { const error = err as { response?: { status?: number } }; const status = error.response?.status; const detail = getApiErrorMessage(err, ''); if (import.meta.env.DEV) console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail); if (status === 401 && attempt < MAX_RETRIES) { await new Promise((r) => setTimeout(r, 1500)); continue; } // Show backend error detail if available, otherwise generic message setError(detail || t('auth.telegramRequired')); } } setIsLoading(false); }; tryTelegramAuth(); }, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]); const handleRetryTelegramAuth = () => { // Clear ALL cached auth state to prevent stale token/initData loops tokenStorage.clearTokens(); sessionStorage.removeItem('tapps/launchParams'); sessionStorage.removeItem('telegram_init_data'); localStorage.removeItem('cabinet-auth'); localStorage.removeItem('tg_user_id'); try { // Close miniapp — Telegram will provide fresh initData on reopen closeMiniApp(); } catch { // If closeMiniApp fails, force a clean page reload window.location.reload(); } }; const handleEmailSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); setError(''); // Валидация email if (!email.trim() || !isValidEmail(email.trim())) { setError(t('auth.invalidEmail', 'Please enter a valid email address')); return; } if (authMode === 'register') { // Валидация для регистрации if (password !== confirmPassword) { setError(t('auth.passwordMismatch', 'Passwords do not match')); return; } if (password.length < 8) { setError(t('auth.passwordTooShort', 'Password must be at least 8 characters')); return; } } setIsLoading(true); try { if (authMode === 'login') { await loginWithEmail(email, password); navigate(getReturnUrl(), { replace: true }); } else { const result = await registerWithEmail( email, password, firstName || undefined, referralCode || undefined, ); // Show "check your email" screen setRegisteredEmail(result.email); } } catch (err: unknown) { const error = err as { response?: { status?: number } }; const status = error.response?.status; const detail = getApiErrorMessage(err, ''); if (status === 400 && detail.includes('already registered')) { setError(t('auth.emailAlreadyRegistered', 'This email is already registered')); } else if (status === 401 || status === 403) { if (detail.includes('verify your email')) { setError(t('auth.emailNotVerified', 'Please verify your email first')); } else { setError(t('auth.invalidCredentials', 'Invalid email or password')); } } else if (status === 429) { setError(t('auth.tooManyAttempts', 'Too many attempts. Please try again later')); } else { setError(detail || t('common.error')); } } finally { setIsLoading(false); } }; const handleForgotPassword = async (e: React.SyntheticEvent) => { e.preventDefault(); setForgotPasswordError(''); if (!forgotPasswordEmail.trim() || !isValidEmail(forgotPasswordEmail.trim())) { setForgotPasswordError(t('auth.invalidEmail', 'Please enter a valid email address')); return; } setForgotPasswordLoading(true); try { await authApi.forgotPassword(forgotPasswordEmail.trim()); setForgotPasswordSent(true); } catch (err: unknown) { setForgotPasswordError(getApiErrorMessage(err, t('common.error'))); } finally { setForgotPasswordLoading(false); } }; const closeForgotPasswordModal = () => { setShowForgotPassword(false); setForgotPasswordEmail(''); setForgotPasswordSent(false); setForgotPasswordError(''); }; return (
0 ? `${safeTop + 16}px` : 'calc(1rem + env(safe-area-inset-top, 0px))', paddingBottom: safeBottom > 0 ? `${safeBottom + 16}px` : 'calc(1rem + env(safe-area-inset-bottom, 0px))', }} > {/* Flat background — the previous two layered gradients (linear + accent radial halo) read as the airdrop / crypto aesthetic PRODUCT.md explicitly anti-references. Body bg-dark-950 carries the surface alone. */} {/* Language switcher */}
0 ? `${safeTop + 12}px` : 'calc(12px + env(safe-area-inset-top, 0px))', }} >
{/* Logo & branding */}
{/* Letter fallback */} {appLogo} {/* Logo image */} {branding?.has_custom_logo && logoUrl && ( {appName setLogoLoaded(true)} /> )}
{appName &&

{appName}

} {/* Referral Banner */} {referralCode && isEmailAuthEnabled && (
{t('auth.referralInvite')}
)}
{/* Check Email Screen */} {registeredEmail ? (

{t('auth.checkEmail', 'Check your email')}

{t('auth.verificationSent', 'We sent a verification link to:')}

{registeredEmail}

{t( 'auth.clickLinkToVerify', 'Click the link in the email to verify your account and log in.', )}

) : ( /* Main auth card */
{error && (
{error}
)} {/* Telegram auth section */}
{isLoading && isTelegramWebApp ? (

{t('auth.authenticating')}

) : isTelegramWebApp && error ? (

{t( 'auth.telegramReopenHint', 'If the problem persists, close and reopen the app', )}

) : ( )}
{/* OAuth providers - compact icon row */} {oauthProviders.length > 0 && ( <>
{t('auth.or', 'or')}
{oauthProviders.map((provider) => ( ))}
)} {/* Email auth section - collapsible */} {isEmailAuthEnabled && ( <>
{/* Collapsible email form */}
{showForgotPassword ? ( /* Forgot password screen - replaces login/register */ forgotPasswordSent ? (

{t('auth.checkEmail', 'Check your email')}

{t( 'auth.passwordResetSent', 'If an account exists with this email, we sent password reset instructions.', )}

) : (

{t( 'auth.forgotPasswordHint', 'Enter your email and we will send you instructions to reset your password.', )}

setForgotPasswordEmail(e.target.value)} placeholder="you@example.com" className="input" autoFocus />
{forgotPasswordError && (

{forgotPasswordError}

)}
) ) : ( /* Normal login / register */ <>
{authMode === 'register' && (
setFirstName(e.target.value)} />
)}
setEmail(e.target.value)} />
setPassword(e.target.value)} /> {authMode === 'register' && password.length > 0 && password.length < 8 && (

{t( 'auth.passwordTooShort', 'Password must be at least 8 characters', )}

)}
{authMode === 'register' && (
setConfirmPassword(e.target.value)} />
)}
{authMode === 'register' && (

{t( 'auth.verificationEmailNotice', 'After registration, a verification email will be sent to your address', )}

)} {authMode === 'login' && (
)} )}
)}
)} {footerEnabled && }
); }