diff --git a/src/App.tsx b/src/App.tsx index 4d87f50..e02e997 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import TelegramCallback from './pages/TelegramCallback' import TelegramRedirect from './pages/TelegramRedirect' import DeepLinkRedirect from './pages/DeepLinkRedirect' import VerifyEmail from './pages/VerifyEmail' +import ResetPassword from './pages/ResetPassword' // User pages - lazy load const Dashboard = lazy(() => import('./pages/Dashboard')) @@ -118,6 +119,7 @@ function App() { } /> } /> } /> + } /> {/* Protected routes */} => { - const response = await apiClient.post('/cabinet/auth/email/verify', { token }) + // Register standalone email account (no Telegram required) + // Returns message - user must verify email before login + registerEmailStandalone: async (data: { + email: string + password: string + first_name?: string + language?: string + referral_code?: string + }): Promise => { + const response = await apiClient.post('/cabinet/auth/email/register/standalone', data) + return response.data + }, + + // Verify email and get auth tokens + verifyEmail: async (token: string): Promise => { + const response = await apiClient.post('/cabinet/auth/email/verify', { token }) return response.data }, diff --git a/src/api/branding.ts b/src/api/branding.ts index c0f6e02..d1f35e6 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -15,6 +15,10 @@ export interface FullscreenEnabled { enabled: boolean } +export interface EmailAuthEnabled { + enabled: boolean +} + const BRANDING_CACHE_KEY = 'cabinet_branding' const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded' @@ -157,4 +161,21 @@ export const brandingApi = { const response = await apiClient.patch('/cabinet/branding/fullscreen', { enabled }) return response.data }, + + // Get email auth enabled (public, no auth required) + getEmailAuthEnabled: async (): Promise => { + try { + const response = await apiClient.get('/cabinet/branding/email-auth') + return response.data + } catch { + // If endpoint doesn't exist, default to enabled + return { enabled: true } + } + }, + + // Update email auth enabled (admin only) + updateEmailAuthEnabled: async (enabled: boolean): Promise => { + const response = await apiClient.patch('/cabinet/branding/email-auth', { enabled }) + return response.data + }, } diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 4086f0c..3a95d5b 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -202,18 +202,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top onSuccess: (data) => { const redirectUrl = data.payment_url || (data as any).invoice_url if (redirectUrl) { - // In Telegram Mini App, try to open directly - const webApp = window.Telegram?.WebApp - if (webApp?.openLink) { - try { - webApp.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) - onClose() - return - } catch (e) { - console.warn('[TopUpModal] webApp.openLink failed:', e) - } - } - // Otherwise show the link for user to click + // Always show the payment link for user to click manually + // This ensures it works on all platforms including iOS Safari setPaymentUrl(redirectUrl) } }, diff --git a/src/components/admin/BrandingTab.tsx b/src/components/admin/BrandingTab.tsx index 82b45f1..443deac 100644 --- a/src/components/admin/BrandingTab.tsx +++ b/src/components/admin/BrandingTab.tsx @@ -35,6 +35,11 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { queryFn: brandingApi.getFullscreenEnabled, }) + const { data: emailAuthSettings } = useQuery({ + queryKey: ['email-auth-enabled'], + queryFn: brandingApi.getEmailAuthEnabled, + }) + // Mutations const updateBrandingMutation = useMutation({ mutationFn: brandingApi.updateName, @@ -77,6 +82,13 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { }, }) + const updateEmailAuthMutation = useMutation({ + mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] }) + }, + }) + const handleLogoUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { @@ -205,6 +217,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { disabled={updateFullscreenMutation.isPending} /> + +
+
+ {t('admin.settings.emailAuth')} +

{t('admin.settings.emailAuthDesc')}

+
+ updateEmailAuthMutation.mutate(!(emailAuthSettings?.enabled ?? true))} + disabled={updateEmailAuthMutation.isPending} + /> +
diff --git a/src/locales/en.json b/src/locales/en.json index df51eb8..3a25fe2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -60,7 +60,9 @@ "logout": "Logout", "email": "Email", "password": "Password", - "confirmPassword": "Confirm password", + "confirmPassword": "Confirm Password", + "firstName": "First Name", + "firstNamePlaceholder": "Your name (optional)", "forgotPassword": "Forgot password?", "resetPassword": "Reset password", "loginWithTelegram": "Login with Telegram", @@ -76,7 +78,20 @@ "tryAgain": "Try Again", "welcomeBack": "Welcome back!", "loginTitle": "Login to Cabinet", - "loginSubtitle": "Sign in with Telegram or use your email" + "loginSubtitle": "Sign in with Telegram or use your email", + "referralInvite": "You've been invited via referral link!", + "passwordMismatch": "Passwords do not match", + "passwordTooShort": "Password must be at least 8 characters", + "invalidEmail": "Please enter a valid email address", + "emailAlreadyRegistered": "This email is already registered", + "invalidCredentials": "Invalid email or password", + "tooManyAttempts": "Too many attempts. Please try again later", + "verificationEmailNotice": "After registration, a verification email will be sent to your address", + "checkEmail": "Check your email", + "verificationSent": "We sent a verification link to:", + "clickLinkToVerify": "Click the link in the email to verify your account and log in.", + "backToLogin": "Back to login", + "emailNotVerified": "Please verify your email first" }, "emailVerification": { "title": "Email Verification", @@ -84,6 +99,7 @@ "pleaseWait": "Please wait while we verify your email address.", "success": "Email Verified!", "successMessage": "Your email has been successfully verified. You can now log in with your email and password.", + "redirecting": "Redirecting to dashboard...", "failed": "Verification Failed", "goToLogin": "Go to Login" }, @@ -1187,6 +1203,7 @@ "linkEmailDescription": "Link your email to log in without Telegram. After linking, you'll receive a verification email.", "linkEmail": "Link Email", "emailRequired": "Email is required", + "invalidEmail": "Please enter a valid email address", "passwordMinLength": "Password must be at least 8 characters", "passwordsMismatch": "Passwords do not match", "passwordPlaceholder": "Enter password", @@ -1199,6 +1216,8 @@ "resendVerification": "Resend Verification Email", "verificationResent": "Verification email resent!", "canLoginWithEmail": "You can now log in using your email and password.", + "emailAlreadyRegistered": "This email is already linked to another account. If this is your email, log in with it or contact support.", + "alreadyHaveEmail": "You already have a verified email linked.", "notifications": { "title": "Notification Settings", "subscriptionExpiry": "Subscription Expiry", diff --git a/src/locales/fa.json b/src/locales/fa.json index 39580de..cc418bc 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -73,7 +73,13 @@ "tryAgain": "تلاش مجدد", "welcomeBack": "خوش آمدید!", "loginTitle": "ورود به کابین", - "loginSubtitle": "با تلگرام یا ایمیل وارد شوید" + "loginSubtitle": "با تلگرام یا ایمیل وارد شوید", + "referralInvite": "شما از طریق لینک معرفی دعوت شده‌اید!", + "checkEmail": "ایمیل خود را بررسی کنید", + "verificationSent": "لینک تایید به این آدرس ارسال شد:", + "clickLinkToVerify": "روی لینک موجود در ایمیل کلیک کنید تا حساب خود را تایید و وارد شوید.", + "backToLogin": "بازگشت به ورود", + "emailNotVerified": "لطفاً ابتدا ایمیل خود را تایید کنید" }, "emailVerification": { "title": "تایید ایمیل", @@ -81,6 +87,7 @@ "pleaseWait": "لطفاً صبر کنید تا ایمیل شما تایید شود.", "success": "ایمیل تایید شد!", "successMessage": "ایمیل شما با موفقیت تایید شد. اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", + "redirecting": "در حال انتقال به داشبورد...", "failed": "تایید ناموفق", "goToLogin": "رفتن به صفحه ورود" }, @@ -669,6 +676,7 @@ "linkEmailDescription": "ایمیل خود را متصل کنید تا بدون تلگرام وارد شوید. پس از اتصال، ایمیل تایید دریافت خواهید کرد.", "linkEmail": "اتصال ایمیل", "emailRequired": "ایمیل الزامی است", + "invalidEmail": "لطفا یک آدرس ایمیل معتبر وارد کنید", "passwordMinLength": "رمز عبور باید حداقل 8 کاراکتر باشد", "passwordsMismatch": "رمزهای عبور مطابقت ندارند", "passwordPlaceholder": "رمز عبور را وارد کنید", @@ -681,6 +689,8 @@ "resendVerification": "ارسال مجدد ایمیل تایید", "verificationResent": "ایمیل تایید مجدداً ارسال شد!", "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", + "emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.", + "alreadyHaveEmail": "شما قبلاً یک ایمیل تایید شده متصل کرده‌اید.", "notifications": { "title": "تنظیمات اعلان", "subscriptionExpiry": "انقضای اشتراک", diff --git a/src/locales/ru.json b/src/locales/ru.json index f88820b..fc2dd4b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -61,6 +61,8 @@ "email": "Email", "password": "Пароль", "confirmPassword": "Подтвердите пароль", + "firstName": "Имя", + "firstNamePlaceholder": "Ваше имя (необязательно)", "forgotPassword": "Забыли пароль?", "resetPassword": "Сбросить пароль", "loginWithTelegram": "Войти через Telegram", @@ -76,7 +78,24 @@ "tryAgain": "Попробовать снова", "welcomeBack": "Добро пожаловать!", "loginTitle": "Вход в личный кабинет", - "loginSubtitle": "Войдите через Telegram или используйте email" + "loginSubtitle": "Войдите через Telegram или используйте email", + "referralInvite": "Вы приглашены по реферальной ссылке!", + "passwordMismatch": "Пароли не совпадают", + "passwordTooShort": "Пароль должен быть минимум 8 символов", + "invalidEmail": "Введите корректный email адрес", + "emailAlreadyRegistered": "Этот email уже зарегистрирован", + "invalidCredentials": "Неверный email или пароль", + "forgotPassword": "Забыли пароль?", + "forgotPasswordHint": "Введите email и мы отправим инструкции для сброса пароля.", + "passwordResetSent": "Если аккаунт с таким email существует, мы отправили инструкции по сбросу пароля.", + "sendResetLink": "Отправить ссылку", + "tooManyAttempts": "Слишком много попыток. Попробуйте позже", + "verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения", + "checkEmail": "Проверьте почту", + "verificationSent": "Мы отправили ссылку для подтверждения на:", + "clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.", + "backToLogin": "Вернуться ко входу", + "emailNotVerified": "Сначала подтвердите email" }, "emailVerification": { "title": "Подтверждение email", @@ -84,9 +103,19 @@ "pleaseWait": "Пожалуйста, подождите пока мы проверяем ваш email.", "success": "Email подтверждён!", "successMessage": "Ваш email успешно подтверждён. Теперь вы можете входить с помощью email и пароля.", + "redirecting": "Переходим в личный кабинет...", "failed": "Ошибка подтверждения", "goToLogin": "Перейти к входу" }, + "resetPassword": { + "title": "Новый пароль", + "enterNewPassword": "Введите новый пароль ниже.", + "success": "Пароль изменён!", + "redirectingToLogin": "Переходим на страницу входа...", + "setPassword": "Установить пароль", + "invalidToken": "Недействительная ссылка", + "tokenExpiredOrInvalid": "Ссылка для сброса пароля недействительна или истекла." + }, "dashboard": { "title": "Личный кабинет", "welcome": "Добро пожаловать, {{name}}!", @@ -693,6 +722,8 @@ "animatedBackgroundDesc": "Волны на фоне приложения", "autoFullscreen": "Авто-Fullscreen", "autoFullscreenDesc": "В Telegram WebApp", + "emailAuth": "Email авторизация", + "emailAuthDesc": "Регистрация и вход через email", "availableThemes": "Доступные темы", "darkTheme": "Тёмная", "lightTheme": "Светлая", @@ -1880,6 +1911,7 @@ "linkEmailDescription": "Привяжите email для входа без Telegram. После привязки на ваш email придёт письмо для подтверждения.", "linkEmail": "Привязать Email", "emailRequired": "Введите email", + "invalidEmail": "Введите корректный email адрес", "passwordMinLength": "Пароль должен быть минимум 8 символов", "passwordsMismatch": "Пароли не совпадают", "passwordPlaceholder": "Введите пароль", @@ -1892,6 +1924,8 @@ "resendVerification": "Отправить письмо повторно", "verificationResent": "Письмо отправлено повторно!", "canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.", + "emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.", + "alreadyHaveEmail": "У вас уже привязан подтверждённый email.", "notifications": { "title": "Настройки уведомлений", "subscriptionExpiry": "Окончание подписки", diff --git a/src/locales/zh.json b/src/locales/zh.json index 2f3b724..2f26e61 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -74,7 +74,13 @@ "tryAgain": "重试", "welcomeBack": "欢迎回来!", "loginTitle": "登录个人中心", - "loginSubtitle": "通过Telegram或邮箱登录" + "loginSubtitle": "通过Telegram或邮箱登录", + "referralInvite": "您已通过推荐链接受邀!", + "checkEmail": "请查收邮件", + "verificationSent": "我们已发送验证链接至:", + "clickLinkToVerify": "点击邮件中的链接验证账户并登录。", + "backToLogin": "返回登录", + "emailNotVerified": "请先验证邮箱" }, "emailVerification": { "title": "邮箱验证", @@ -82,6 +88,7 @@ "pleaseWait": "请稍候,我们正在验证您的邮箱。", "success": "邮箱已验证!", "successMessage": "您的邮箱已成功验证。现在您可以使用邮箱和密码登录。", + "redirecting": "正在跳转到个人中心...", "failed": "验证失败", "goToLogin": "前往登录" }, @@ -670,6 +677,7 @@ "linkEmailDescription": "绑定邮箱以便无需Telegram登录。绑定后将收到验证邮件。", "linkEmail": "绑定邮箱", "emailRequired": "请输入邮箱", + "invalidEmail": "请输入有效的电子邮件地址", "passwordMinLength": "密码至少8个字符", "passwordsMismatch": "密码不匹配", "passwordPlaceholder": "输入密码", @@ -682,6 +690,8 @@ "resendVerification": "重新发送验证邮件", "verificationResent": "验证邮件已重新发送!", "canLoginWithEmail": "现在您可以使用邮箱和密码登录。", + "emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。", + "alreadyHaveEmail": "您已经绑定了已验证的邮箱。", "notifications": { "title": "通知设置", "subscriptionExpiry": "订阅到期", diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index a3683a3..194f88c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useMemo, useCallback } from 'react' -import { useNavigate, useLocation } from 'react-router-dom' +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../store/auth' -import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded, type BrandingInfo } from '../api/branding' +import { authApi } from '../api/auth' +import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded, type BrandingInfo, type EmailAuthEnabled } from '../api/branding' import { getAndClearReturnUrl } from '../utils/token' import LanguageSwitcher from '../components/LanguageSwitcher' import TelegramLoginButton from '../components/TelegramLoginButton' @@ -12,14 +13,32 @@ export default function Login() { const { t } = useTranslation() const navigate = useNavigate() const location = useLocation() - const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore() - const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram') + const [searchParams] = useSearchParams() + const { isAuthenticated, loginWithTelegram, loginWithEmail, registerWithEmail } = useAuthStore() + + // Extract referral code from URL + const referralCode = searchParams.get('ref') || '' + + const [activeTab, setActiveTab] = useState<'telegram' | 'email'>(() => + referralCode ? 'email' : 'telegram' + ) + 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('') // Получаем URL для возврата после авторизации const getReturnUrl = useCallback(() => { @@ -52,7 +71,29 @@ export default function Login() { initialData: cachedBranding ?? undefined, }) + // 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 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) { + window.location.href = `https://t.me/${botUsername}?start=ref_${referralCode}` + } + }, [referralCode, emailAuthConfig, botUsername]) + + // If email auth is disabled but we initially set to email tab, switch back to telegram + useEffect(() => { + if (!isEmailAuthEnabled && activeTab === 'email') { + setActiveTab('telegram') + } + }, [isEmailAuthEnabled, activeTab]) 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 @@ -94,30 +135,93 @@ export default function Login() { tryTelegramAuth() }, [loginWithTelegram, navigate, t, getReturnUrl]) - const handleEmailLogin = async (e: React.FormEvent) => { + const handleEmailSubmit = async (e: React.FormEvent) => { e.preventDefault() setError('') + + // Валидация email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!email.trim() || !emailRegex.test(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 { - await loginWithEmail(email, password) - navigate(getReturnUrl(), { replace: true }) + 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; data?: { detail?: string } } } - // Show user-friendly error messages without exposing sensitive server details const status = error.response?.status - if (status === 401 || status === 403) { - setError(t('auth.invalidCredentials', 'Неверный email или пароль')) + const detail = error.response?.data?.detail + + 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', 'Слишком много попыток. Попробуйте позже')) + setError(t('auth.tooManyAttempts', 'Too many attempts. Please try again later')) } else { - setError(t('common.error')) + setError(detail || t('common.error')) } } finally { setIsLoading(false) } } + const handleForgotPassword = async (e: React.FormEvent) => { + e.preventDefault() + setForgotPasswordError('') + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!forgotPasswordEmail.trim() || !emailRegex.test(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) { + const error = err as { response?: { status?: number; data?: { detail?: string } } } + const detail = error.response?.data?.detail + setForgotPasswordError(detail || t('common.error')) + } finally { + setForgotPasswordLoading(false) + } + } + + const closeForgotPasswordModal = () => { + setShowForgotPassword(false) + setForgotPasswordEmail('') + setForgotPasswordSent(false) + setForgotPasswordError('') + } + return (
{/* Background gradient */} @@ -155,33 +259,80 @@ export default function Login() {

{t('auth.loginSubtitle')}

+ + {/* Referral Banner - only show when email auth is enabled */} + {referralCode && isEmailAuthEnabled && ( +
+
+ + + + {t('auth.referralInvite')} +
+
+ )}
- {/* Card */} -
- {/* Tabs */} -
+ {/* 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.')} +

-
+ ) : ( + /* Card */ +
+ {/* Tabs */} + {isEmailAuthEnabled ? ( +
+ + +
+ ) : ( +
+

Telegram

+
+ )} {error && (
@@ -189,7 +340,7 @@ export default function Login() {
)} - {activeTab === 'telegram' ? ( + {activeTab === 'telegram' || !isEmailAuthEnabled ? (

{t('auth.registerHint')} @@ -205,63 +356,228 @@ export default function Login() { )}

) : ( -
-
- - setEmail(e.target.value)} - /> +
+ {/* Login / Register toggle */} +
+ +
-
- - setPassword(e.target.value)} - /> -
- - -

- {t('auth.registerHint')} -

- +
+ + setEmail(e.target.value)} + /> +
+ +
+ + setPassword(e.target.value)} + /> +
+ + {/* Confirm password - only for registration */} + {authMode === 'register' && ( +
+ + setConfirmPassword(e.target.value)} + /> +
+ )} + + + + + {/* Verification notice for registration */} + {authMode === 'register' && ( +

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

+ )} + + {/* Forgot password link - only for login */} + {authMode === 'login' && ( +
+ +
+ )} +
)}
+ )}
+ + {/* Forgot Password Modal */} + {showForgotPassword && ( +
+
+
+ + + {forgotPasswordSent ? ( +
+
+ + + +
+

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

+

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

+ +
+ ) : ( + <> +

+ {t('auth.forgotPassword', 'Forgot password?')} +

+

+ {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} +
+ )} + + +
+ + )} +
+
+ )}
) } diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index a2d4b83..fb2aa67 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,9 +1,38 @@ import { useState } from 'react' +import { Link } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useAuthStore } from '../store/auth' import { authApi } from '../api/auth' import { notificationsApi, NotificationSettings, NotificationSettingsUpdate } from '../api/notifications' +import { referralApi } from '../api/referral' +import { brandingApi, type EmailAuthEnabled } from '../api/branding' + +// Icons +const CopyIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const ShareIcon = () => ( + + + + +) + +const ArrowRightIcon = () => ( + + + +) export default function Profile() { const { t } = useTranslation() @@ -15,6 +44,65 @@ export default function Profile() { const [confirmPassword, setConfirmPassword] = useState('') const [error, setError] = useState(null) const [success, setSuccess] = useState(null) + const [copied, setCopied] = useState(false) + + // Referral data + const { data: referralInfo } = useQuery({ + queryKey: ['referral-info'], + queryFn: referralApi.getReferralInfo, + }) + + const { data: referralTerms } = useQuery({ + queryKey: ['referral-terms'], + queryFn: referralApi.getReferralTerms, + }) + + const { data: branding } = useQuery({ + queryKey: ['branding'], + queryFn: brandingApi.getBranding, + staleTime: 60000, + }) + + // Check if email auth is enabled + const { data: emailAuthConfig } = useQuery({ + queryKey: ['email-auth-enabled'], + queryFn: brandingApi.getEmailAuthEnabled, + staleTime: 60000, + }) + const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true + + // Build referral link for cabinet + const referralLink = referralInfo?.referral_code + ? `${window.location.origin}/login?ref=${referralInfo.referral_code}` + : '' + + const copyReferralLink = () => { + if (referralLink) { + navigator.clipboard.writeText(referralLink) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + const shareReferralLink = () => { + if (!referralLink) return + const shareText = t('referral.shareMessage', { + percent: referralInfo?.commission_percent || 0, + botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet', + }) + + if (navigator.share) { + navigator.share({ + title: t('referral.title'), + text: shareText, + url: referralLink, + }).catch(() => {}) + return + } + + const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(referralLink)}&text=${encodeURIComponent(shareText)}` + window.open(telegramUrl, '_blank', 'noopener,noreferrer') + } const registerEmailMutation = useMutation({ mutationFn: ({ email, password }: { email: string; password: string }) => @@ -30,7 +118,14 @@ export default function Profile() { queryClient.invalidateQueries({ queryKey: ['user'] }) }, onError: (err: { response?: { data?: { detail?: string } } }) => { - setError(err.response?.data?.detail || t('common.error')) + const detail = err.response?.data?.detail + if (detail?.includes('already registered')) { + setError(t('profile.emailAlreadyRegistered')) + } else if (detail?.includes('already have a verified email')) { + setError(t('profile.alreadyHaveEmail')) + } else { + setError(detail || t('common.error')) + } setSuccess(null) }, }) @@ -74,8 +169,10 @@ export default function Profile() { setError(null) setSuccess(null) - if (!email.trim()) { - setError(t('profile.emailRequired')) + // Валидация email + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!email.trim() || !emailRegex.test(email.trim())) { + setError(t('profile.invalidEmail', 'Please enter a valid email address')) return } @@ -125,7 +222,52 @@ export default function Profile() {
- {/* Email Section */} + {/* Referral Link Widget */} + {referralTerms?.is_enabled && referralLink && ( +
+
+

{t('referral.yourLink')}

+ + {t('referral.title')} + + +
+
+
+ +
+
+ + +
+
+

+ {t('referral.shareHint', { percent: referralInfo?.commission_percent || 0 })} +

+
+ )} + + {/* Email Section - only show when email auth is enabled */} + {isEmailAuthEnabled && (

{t('profile.emailAuth')}

@@ -248,6 +390,7 @@ export default function Profile() {
)} + )} {/* Notification Settings */}
diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index 3febac8..c1e01c8 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -68,11 +68,10 @@ export default function Referral() { queryFn: referralApi.getReferralInfo, }) - // Build referral link using frontend env variable for correct bot username - const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME - const referralLink = info?.referral_code && botUsername - ? `https://t.me/${botUsername}?start=${info.referral_code}` - : info?.referral_link || '' + // Build referral link for cabinet registration + const referralLink = info?.referral_code + ? `${window.location.origin}/login?ref=${info.referral_code}` + : '' const { data: terms } = useQuery({ queryKey: ['referral-terms'], diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx new file mode 100644 index 0000000..debd7f2 --- /dev/null +++ b/src/pages/ResetPassword.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { useSearchParams, Link, useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { authApi } from '../api/auth' +import LanguageSwitcher from '../components/LanguageSwitcher' + +export default function ResetPassword() { + const { t } = useTranslation() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const token = searchParams.get('token') + + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [status, setStatus] = useState<'form' | 'loading' | 'success' | 'error'>('form') + const [error, setError] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + + if (!token) { + setError(t('resetPassword.invalidToken', 'Invalid or missing reset token')) + return + } + + if (password.length < 8) { + setError(t('auth.passwordTooShort', 'Password must be at least 8 characters')) + return + } + + if (password !== confirmPassword) { + setError(t('auth.passwordMismatch', 'Passwords do not match')) + return + } + + setStatus('loading') + + try { + await authApi.resetPassword(token, password) + setStatus('success') + setTimeout(() => navigate('/login', { replace: true }), 2000) + } catch (err: unknown) { + setStatus('error') + const error = err as { response?: { data?: { detail?: string } } } + setError(error.response?.data?.detail || t('common.error')) + } + } + + if (!token) { + return ( +
+
+
+ +
+
+
+
!
+

+ {t('resetPassword.invalidToken', 'Invalid reset link')} +

+

+ {t('resetPassword.tokenExpiredOrInvalid', 'This password reset link is invalid or has expired.')} +

+ + {t('auth.backToLogin', 'Back to login')} + +
+
+
+ ) + } + + return ( +
+
+
+
+ +
+ +
+
+ {status === 'success' ? ( +
+
+ + + +
+

+ {t('resetPassword.success', 'Password changed!')} +

+

+ {t('resetPassword.redirectingToLogin', 'Redirecting to login...')} +

+
+
+ ) : ( + <> +

+ {t('resetPassword.title', 'Set new password')} +

+

+ {t('resetPassword.enterNewPassword', 'Enter your new password below.')} +

+ +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="input" + autoComplete="new-password" + disabled={status === 'loading'} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + className="input" + autoComplete="new-password" + disabled={status === 'loading'} + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +
+ + {t('auth.backToLogin', 'Back to login')} + +
+ + )} +
+
+
+ ) +} diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index ee670f6..b44eda0 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -1,14 +1,18 @@ import { useEffect, useState } from 'react' -import { useSearchParams, Link } from 'react-router-dom' +import { useSearchParams, Link, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { authApi } from '../api/auth' +import { useAuthStore } from '../store/auth' +import { tokenStorage } from '../utils/token' import LanguageSwitcher from '../components/LanguageSwitcher' export default function VerifyEmail() { const { t } = useTranslation() + const navigate = useNavigate() const [searchParams] = useSearchParams() const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') const [error, setError] = useState('') + const { setTokens, setUser, checkAdminStatus } = useAuthStore() useEffect(() => { const token = searchParams.get('token') @@ -21,8 +25,15 @@ export default function VerifyEmail() { const verify = async () => { try { - await authApi.verifyEmail(token) + const response = await authApi.verifyEmail(token) + // Save tokens and log user in + tokenStorage.setTokens(response.access_token, response.refresh_token) + setTokens(response.access_token, response.refresh_token) + setUser(response.user) + checkAdminStatus() setStatus('success') + // Redirect to dashboard after short delay + setTimeout(() => navigate('/', { replace: true }), 1500) } catch (err: unknown) { setStatus('error') const error = err as { response?: { data?: { detail?: string } } } @@ -31,7 +42,7 @@ export default function VerifyEmail() { } verify() - }, [searchParams, t]) + }, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus]) return (
@@ -54,12 +65,10 @@ export default function VerifyEmail() {

{t('emailVerification.success')}

- {t('emailVerification.successMessage')} + {t('emailVerification.redirecting', 'Redirecting to dashboard...')}

-
- - {t('emailVerification.goToLogin')} - +
+
)} diff --git a/src/store/auth.ts b/src/store/auth.ts index a31b075..f62dc2f 100644 --- a/src/store/auth.ts +++ b/src/store/auth.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import type { User } from '../types' +import type { RegisterResponse, User } from '../types' import { authApi } from '../api/auth' import { apiClient } from '../api/client' import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token' @@ -33,6 +33,7 @@ interface AuthState { loginWithTelegram: (initData: string) => Promise loginWithTelegramWidget: (data: TelegramWidgetData) => Promise loginWithEmail: (email: string, password: string) => Promise + registerWithEmail: (email: string, password: string, firstName?: string, referralCode?: string) => Promise } // Блокировка для предотвращения race condition при инициализации @@ -260,6 +261,19 @@ export const useAuthStore = create()( }) get().checkAdminStatus() }, + + registerWithEmail: async (email, password, firstName, referralCode) => { + // Registration now returns message, not tokens + // User must verify email before they can login + const response = await authApi.registerEmailStandalone({ + email, + password, + first_name: firstName, + language: navigator.language.split('-')[0] || 'ru', + referral_code: referralCode, + }) + return response + }, }), { name: 'cabinet-auth', diff --git a/src/types/index.ts b/src/types/index.ts index ebebcba..a93e836 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,7 @@ // User types export interface User { id: number - telegram_id: number + telegram_id: number | null // Nullable для email-only пользователей username: string | null first_name: string | null last_name: string | null @@ -12,6 +12,7 @@ export interface User { referral_code: string | null language: string created_at: string + auth_type: 'telegram' | 'email' // Тип аутентификации } // Auth types @@ -30,6 +31,12 @@ export interface TokenResponse { expires_in: number } +export interface RegisterResponse { + message: string + email: string + requires_verification: boolean +} + // Subscription types export interface ServerInfo { uuid: string