mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
@@ -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() {
|
||||
<Route path="/connect" element={<DeepLinkRedirect />} />
|
||||
<Route path="/add" element={<DeepLinkRedirect />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { AuthResponse, TokenResponse, User } from '../types'
|
||||
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types'
|
||||
|
||||
export const authApi = {
|
||||
// Telegram WebApp authentication
|
||||
@@ -42,9 +42,22 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Verify email
|
||||
verifyEmail: async (token: string): Promise<{ message: string }> => {
|
||||
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<RegisterResponse> => {
|
||||
const response = await apiClient.post<RegisterResponse>('/cabinet/auth/email/register/standalone', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Verify email and get auth tokens
|
||||
verifyEmail: async (token: string): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token })
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -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<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get email auth enabled (public, no auth required)
|
||||
getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => {
|
||||
try {
|
||||
const response = await apiClient.get<EmailAuthEnabled>('/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<EmailAuthEnabled> => {
|
||||
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', { enabled })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
@@ -205,6 +217,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
disabled={updateFullscreenMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={emailAuthSettings?.enabled ?? true}
|
||||
onChange={() => updateEmailAuthMutation.mutate(!(emailAuthSettings?.enabled ?? true))}
|
||||
disabled={updateEmailAuthMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "انقضای اشتراک",
|
||||
|
||||
@@ -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": "Окончание подписки",
|
||||
|
||||
@@ -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": "订阅到期",
|
||||
|
||||
@@ -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<string | null>(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<EmailAuthEnabled>({
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12 sm:px-6 lg:px-8">
|
||||
{/* Background gradient */}
|
||||
@@ -155,33 +259,80 @@ export default function Login() {
|
||||
<p className="mt-2 text-dark-400">
|
||||
{t('auth.loginSubtitle')}
|
||||
</p>
|
||||
|
||||
{/* Referral Banner - only show when email auth is enabled */}
|
||||
{referralCode && isEmailAuthEnabled && (
|
||||
<div className="mt-4 p-3 rounded-xl bg-accent-500/10 border border-accent-500/30">
|
||||
<div className="flex items-center gap-2 text-accent-400">
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">{t('auth.referralInvite')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="card">
|
||||
{/* Tabs */}
|
||||
<div className="flex mb-6">
|
||||
{/* Check Email Screen */}
|
||||
{registeredEmail ? (
|
||||
<div className="card text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl bg-success-500/20 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-dark-50 mb-2">
|
||||
{t('auth.checkEmail', 'Check your email')}
|
||||
</h2>
|
||||
<p className="text-dark-400 mb-4">
|
||||
{t('auth.verificationSent', 'We sent a verification link to:')}
|
||||
</p>
|
||||
<p className="text-accent-400 font-medium mb-6">{registeredEmail}</p>
|
||||
<p className="text-sm text-dark-500 mb-6">
|
||||
{t('auth.clickLinkToVerify', 'Click the link in the email to verify your account and log in.')}
|
||||
</p>
|
||||
<button
|
||||
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
|
||||
activeTab === 'telegram'
|
||||
? 'border-accent-500 text-accent-400'
|
||||
: 'border-transparent text-dark-500 hover:text-dark-300'
|
||||
}`}
|
||||
onClick={() => setActiveTab('telegram')}
|
||||
onClick={() => {
|
||||
setRegisteredEmail(null)
|
||||
setAuthMode('login')
|
||||
}}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
Telegram
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
|
||||
activeTab === 'email'
|
||||
? 'border-accent-500 text-accent-400'
|
||||
: 'border-transparent text-dark-500 hover:text-dark-300'
|
||||
}`}
|
||||
onClick={() => setActiveTab('email')}
|
||||
>
|
||||
Email
|
||||
{t('auth.backToLogin', 'Back to login')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Card */
|
||||
<div className="card">
|
||||
{/* Tabs */}
|
||||
{isEmailAuthEnabled ? (
|
||||
<div className="flex mb-6">
|
||||
<button
|
||||
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
|
||||
activeTab === 'telegram'
|
||||
? 'border-accent-500 text-accent-400'
|
||||
: 'border-transparent text-dark-500 hover:text-dark-300'
|
||||
}`}
|
||||
onClick={() => setActiveTab('telegram')}
|
||||
>
|
||||
Telegram
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${
|
||||
activeTab === 'email'
|
||||
? 'border-accent-500 text-accent-400'
|
||||
: 'border-transparent text-dark-500 hover:text-dark-300'
|
||||
}`}
|
||||
onClick={() => setActiveTab('email')}
|
||||
>
|
||||
Email
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 pb-3 border-b border-dark-700">
|
||||
<h2 className="text-lg font-medium text-dark-200 text-center">Telegram</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-500/10 border border-error-500/30 text-error-400 px-4 py-3 rounded-xl text-sm mb-6">
|
||||
@@ -189,7 +340,7 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'telegram' ? (
|
||||
{activeTab === 'telegram' || !isEmailAuthEnabled ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-center text-sm text-dark-400">
|
||||
{t('auth.registerHint')}
|
||||
@@ -205,63 +356,228 @@ export default function Login() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-5" onSubmit={handleEmailLogin}>
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="input"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-5">
|
||||
{/* Login / Register toggle */}
|
||||
<div className="flex bg-dark-800 rounded-lg p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
||||
authMode === 'login'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
onClick={() => setAuthMode('login')}
|
||||
>
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
||||
authMode === 'register'
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
onClick={() => setAuthMode('register')}
|
||||
>
|
||||
{t('auth.register', 'Register')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
t('auth.login')
|
||||
<form className="space-y-4" onSubmit={handleEmailSubmit}>
|
||||
{/* First name field - only for registration */}
|
||||
{authMode === 'register' && (
|
||||
<div>
|
||||
<label htmlFor="firstName" className="label">
|
||||
{t('auth.firstName', 'First Name')}
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
className="input"
|
||||
placeholder={t('auth.firstNamePlaceholder', 'Your name (optional)')}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-dark-500">
|
||||
{t('auth.registerHint')}
|
||||
</p>
|
||||
</form>
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
{t('auth.email')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="input"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete={authMode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
className="input"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm password - only for registration */}
|
||||
{authMode === 'register' && (
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="label">
|
||||
{t('auth.confirmPassword', 'Confirm Password')}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="input"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
authMode === 'login' ? t('auth.login') : t('auth.register', 'Register')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Verification notice for registration */}
|
||||
{authMode === 'register' && (
|
||||
<p className="text-center text-xs text-dark-500">
|
||||
{t('auth.verificationEmailNotice', 'After registration, a verification email will be sent to your address')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Forgot password link - only for login */}
|
||||
{authMode === 'login' && (
|
||||
<div className="text-center space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotPassword(true)}
|
||||
className="text-sm text-accent-400 hover:text-accent-300 transition-colors"
|
||||
>
|
||||
{t('auth.forgotPassword', 'Forgot password?')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
{showForgotPassword && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={closeForgotPasswordModal} />
|
||||
<div className="relative bg-dark-900 rounded-2xl p-6 w-full max-w-md border border-dark-700">
|
||||
<button
|
||||
onClick={closeForgotPasswordModal}
|
||||
className="absolute top-4 right-4 text-dark-400 hover:text-dark-200 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{forgotPasswordSent ? (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-success-500/20 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-dark-50 mb-2">
|
||||
{t('auth.checkEmail', 'Check your email')}
|
||||
</h3>
|
||||
<p className="text-dark-400 mb-4">
|
||||
{t('auth.passwordResetSent', 'If an account exists with this email, we sent password reset instructions.')}
|
||||
</p>
|
||||
<button onClick={closeForgotPasswordModal} className="btn-primary w-full">
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-xl font-bold text-dark-50 mb-2">
|
||||
{t('auth.forgotPassword', 'Forgot password?')}
|
||||
</h3>
|
||||
<p className="text-dark-400 mb-6">
|
||||
{t('auth.forgotPasswordHint', 'Enter your email and we will send you instructions to reset your password.')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="forgotEmail" className="label">Email</label>
|
||||
<input
|
||||
id="forgotEmail"
|
||||
type="email"
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(e) => setForgotPasswordEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{forgotPasswordError && (
|
||||
<div className="bg-error-500/10 border border-error-500/30 text-error-400 px-4 py-3 rounded-xl text-sm">
|
||||
{forgotPasswordError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotPasswordLoading}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{forgotPasswordLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
t('auth.sendResetLink', 'Send reset link')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ShareIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8l5-5m0 0l5 5m-5-5v12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ArrowRightIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Profile() {
|
||||
const { t } = useTranslation()
|
||||
@@ -15,6 +44,65 @@ export default function Profile() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(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<EmailAuthEnabled>({
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Section */}
|
||||
{/* Referral Link Widget */}
|
||||
{referralTerms?.is_enabled && referralLink && (
|
||||
<div className="bento-card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('referral.yourLink')}</h2>
|
||||
<Link to="/referral" className="text-accent-400 hover:text-accent-300 transition-colors flex items-center gap-1">
|
||||
<span className="text-sm">{t('referral.title')}</span>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={referralLink}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyReferralLink}
|
||||
className={`btn-primary px-4 py-2 flex items-center gap-2 text-sm ${
|
||||
copied ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<span>{copied ? t('referral.copied') : t('referral.copyLink')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={shareReferralLink}
|
||||
className="btn-secondary px-4 py-2 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ShareIcon />
|
||||
<span className="hidden sm:inline">{t('referral.shareButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-dark-500">
|
||||
{t('referral.shareHint', { percent: referralInfo?.commission_percent || 0 })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Section - only show when email auth is enabled */}
|
||||
{isEmailAuthEnabled && (
|
||||
<div className="bento-card">
|
||||
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2>
|
||||
|
||||
@@ -248,6 +390,7 @@ export default function Profile() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="bento-card">
|
||||
|
||||
@@ -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'],
|
||||
|
||||
175
src/pages/ResetPassword.tsx
Normal file
175
src/pages/ResetPassword.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12">
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div className="relative max-w-md w-full text-center">
|
||||
<div className="card">
|
||||
<div className="text-error-400 text-5xl mb-4">!</div>
|
||||
<h2 className="text-xl font-semibold text-dark-50 mb-2">
|
||||
{t('resetPassword.invalidToken', 'Invalid reset link')}
|
||||
</h2>
|
||||
<p className="text-dark-400 mb-6">
|
||||
{t('resetPassword.tokenExpiredOrInvalid', 'This password reset link is invalid or has expired.')}
|
||||
</p>
|
||||
<Link to="/login" className="btn-primary w-full inline-block">
|
||||
{t('auth.backToLogin', 'Back to login')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12">
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||
<div className="fixed inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-accent-500/10 via-transparent to-transparent" />
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="card">
|
||||
{status === 'success' ? (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-success-500/20 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-success-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-dark-50 mb-2">
|
||||
{t('resetPassword.success', 'Password changed!')}
|
||||
</h2>
|
||||
<p className="text-dark-400 mb-4">
|
||||
{t('resetPassword.redirectingToLogin', 'Redirecting to login...')}
|
||||
</p>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-bold text-dark-50 mb-2 text-center">
|
||||
{t('resetPassword.title', 'Set new password')}
|
||||
</h2>
|
||||
<p className="text-dark-400 mb-6 text-center">
|
||||
{t('resetPassword.enterNewPassword', 'Enter your new password below.')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
{t('auth.password', 'Password')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="label">
|
||||
{t('auth.confirmPassword', 'Confirm Password')}
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
disabled={status === 'loading'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-500/10 border border-error-500/30 text-error-400 px-4 py-3 rounded-xl text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
t('resetPassword.setPassword', 'Set new password')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Link to="/login" className="text-sm text-dark-400 hover:text-dark-200 transition-colors">
|
||||
{t('auth.backToLogin', 'Back to login')}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4 sm:py-12">
|
||||
@@ -54,12 +65,10 @@ export default function VerifyEmail() {
|
||||
<div className="text-green-500 text-5xl sm:text-6xl mb-4">✓</div>
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{t('emailVerification.success')}</h2>
|
||||
<p className="text-sm sm:text-base text-gray-500 mt-2">
|
||||
{t('emailVerification.successMessage')}
|
||||
{t('emailVerification.redirecting', 'Redirecting to dashboard...')}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link to="/login" className="btn-primary">
|
||||
{t('emailVerification.goToLogin')}
|
||||
</Link>
|
||||
<div className="mt-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<void>
|
||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>
|
||||
loginWithEmail: (email: string, password: string) => Promise<void>
|
||||
registerWithEmail: (email: string, password: string, firstName?: string, referralCode?: string) => Promise<RegisterResponse>
|
||||
}
|
||||
|
||||
// Блокировка для предотвращения race condition при инициализации
|
||||
@@ -260,6 +261,19 @@ export const useAuthStore = create<AuthState>()(
|
||||
})
|
||||
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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user