Merge pull request #91 from BEDOLAGA-DEV/email

Email
This commit is contained in:
Egor
2026-01-25 11:55:49 +03:00
committed by GitHub
15 changed files with 908 additions and 112 deletions

View File

@@ -13,6 +13,7 @@ import TelegramCallback from './pages/TelegramCallback'
import TelegramRedirect from './pages/TelegramRedirect' import TelegramRedirect from './pages/TelegramRedirect'
import DeepLinkRedirect from './pages/DeepLinkRedirect' import DeepLinkRedirect from './pages/DeepLinkRedirect'
import VerifyEmail from './pages/VerifyEmail' import VerifyEmail from './pages/VerifyEmail'
import ResetPassword from './pages/ResetPassword'
// User pages - lazy load // User pages - lazy load
const Dashboard = lazy(() => import('./pages/Dashboard')) const Dashboard = lazy(() => import('./pages/Dashboard'))
@@ -118,6 +119,7 @@ function App() {
<Route path="/connect" element={<DeepLinkRedirect />} /> <Route path="/connect" element={<DeepLinkRedirect />} />
<Route path="/add" element={<DeepLinkRedirect />} /> <Route path="/add" element={<DeepLinkRedirect />} />
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Protected routes */} {/* Protected routes */}
<Route <Route

View File

@@ -1,5 +1,5 @@
import apiClient from './client' import apiClient from './client'
import type { AuthResponse, TokenResponse, User } from '../types' import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types'
export const authApi = { export const authApi = {
// Telegram WebApp authentication // Telegram WebApp authentication
@@ -42,9 +42,22 @@ export const authApi = {
return response.data return response.data
}, },
// Verify email // Register standalone email account (no Telegram required)
verifyEmail: async (token: string): Promise<{ message: string }> => { // Returns message - user must verify email before login
const response = await apiClient.post('/cabinet/auth/email/verify', { token }) 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 return response.data
}, },

View File

@@ -15,6 +15,10 @@ export interface FullscreenEnabled {
enabled: boolean enabled: boolean
} }
export interface EmailAuthEnabled {
enabled: boolean
}
const BRANDING_CACHE_KEY = 'cabinet_branding' const BRANDING_CACHE_KEY = 'cabinet_branding'
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded' const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
@@ -157,4 +161,21 @@ export const brandingApi = {
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled }) const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled })
return response.data 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
},
} }

View File

@@ -35,6 +35,11 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
queryFn: brandingApi.getFullscreenEnabled, queryFn: brandingApi.getFullscreenEnabled,
}) })
const { data: emailAuthSettings } = useQuery({
queryKey: ['email-auth-enabled'],
queryFn: brandingApi.getEmailAuthEnabled,
})
// Mutations // Mutations
const updateBrandingMutation = useMutation({ const updateBrandingMutation = useMutation({
mutationFn: brandingApi.updateName, 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 handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (file) { if (file) {
@@ -205,6 +217,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
disabled={updateFullscreenMutation.isPending} disabled={updateFullscreenMutation.isPending}
/> />
</div> </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> </div>
</div> </div>

View File

@@ -60,7 +60,9 @@
"logout": "Logout", "logout": "Logout",
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"confirmPassword": "Confirm password", "confirmPassword": "Confirm Password",
"firstName": "First Name",
"firstNamePlaceholder": "Your name (optional)",
"forgotPassword": "Forgot password?", "forgotPassword": "Forgot password?",
"resetPassword": "Reset password", "resetPassword": "Reset password",
"loginWithTelegram": "Login with Telegram", "loginWithTelegram": "Login with Telegram",
@@ -76,7 +78,20 @@
"tryAgain": "Try Again", "tryAgain": "Try Again",
"welcomeBack": "Welcome back!", "welcomeBack": "Welcome back!",
"loginTitle": "Login to Cabinet", "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": { "emailVerification": {
"title": "Email Verification", "title": "Email Verification",
@@ -84,6 +99,7 @@
"pleaseWait": "Please wait while we verify your email address.", "pleaseWait": "Please wait while we verify your email address.",
"success": "Email Verified!", "success": "Email Verified!",
"successMessage": "Your email has been successfully verified. You can now log in with your email and password.", "successMessage": "Your email has been successfully verified. You can now log in with your email and password.",
"redirecting": "Redirecting to dashboard...",
"failed": "Verification Failed", "failed": "Verification Failed",
"goToLogin": "Go to Login" "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.", "linkEmailDescription": "Link your email to log in without Telegram. After linking, you'll receive a verification email.",
"linkEmail": "Link Email", "linkEmail": "Link Email",
"emailRequired": "Email is required", "emailRequired": "Email is required",
"invalidEmail": "Please enter a valid email address",
"passwordMinLength": "Password must be at least 8 characters", "passwordMinLength": "Password must be at least 8 characters",
"passwordsMismatch": "Passwords do not match", "passwordsMismatch": "Passwords do not match",
"passwordPlaceholder": "Enter password", "passwordPlaceholder": "Enter password",
@@ -1199,6 +1216,8 @@
"resendVerification": "Resend Verification Email", "resendVerification": "Resend Verification Email",
"verificationResent": "Verification email resent!", "verificationResent": "Verification email resent!",
"canLoginWithEmail": "You can now log in using your email and password.", "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": { "notifications": {
"title": "Notification Settings", "title": "Notification Settings",
"subscriptionExpiry": "Subscription Expiry", "subscriptionExpiry": "Subscription Expiry",

View File

@@ -73,7 +73,13 @@
"tryAgain": "تلاش مجدد", "tryAgain": "تلاش مجدد",
"welcomeBack": "خوش آمدید!", "welcomeBack": "خوش آمدید!",
"loginTitle": "ورود به کابین", "loginTitle": "ورود به کابین",
"loginSubtitle": "با تلگرام یا ایمیل وارد شوید" "loginSubtitle": "با تلگرام یا ایمیل وارد شوید",
"referralInvite": "شما از طریق لینک معرفی دعوت شده‌اید!",
"checkEmail": "ایمیل خود را بررسی کنید",
"verificationSent": "لینک تایید به این آدرس ارسال شد:",
"clickLinkToVerify": "روی لینک موجود در ایمیل کلیک کنید تا حساب خود را تایید و وارد شوید.",
"backToLogin": "بازگشت به ورود",
"emailNotVerified": "لطفاً ابتدا ایمیل خود را تایید کنید"
}, },
"emailVerification": { "emailVerification": {
"title": "تایید ایمیل", "title": "تایید ایمیل",
@@ -81,6 +87,7 @@
"pleaseWait": "لطفاً صبر کنید تا ایمیل شما تایید شود.", "pleaseWait": "لطفاً صبر کنید تا ایمیل شما تایید شود.",
"success": "ایمیل تایید شد!", "success": "ایمیل تایید شد!",
"successMessage": "ایمیل شما با موفقیت تایید شد. اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", "successMessage": "ایمیل شما با موفقیت تایید شد. اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.",
"redirecting": "در حال انتقال به داشبورد...",
"failed": "تایید ناموفق", "failed": "تایید ناموفق",
"goToLogin": "رفتن به صفحه ورود" "goToLogin": "رفتن به صفحه ورود"
}, },
@@ -669,6 +676,7 @@
"linkEmailDescription": "ایمیل خود را متصل کنید تا بدون تلگرام وارد شوید. پس از اتصال، ایمیل تایید دریافت خواهید کرد.", "linkEmailDescription": "ایمیل خود را متصل کنید تا بدون تلگرام وارد شوید. پس از اتصال، ایمیل تایید دریافت خواهید کرد.",
"linkEmail": "اتصال ایمیل", "linkEmail": "اتصال ایمیل",
"emailRequired": "ایمیل الزامی است", "emailRequired": "ایمیل الزامی است",
"invalidEmail": "لطفا یک آدرس ایمیل معتبر وارد کنید",
"passwordMinLength": "رمز عبور باید حداقل 8 کاراکتر باشد", "passwordMinLength": "رمز عبور باید حداقل 8 کاراکتر باشد",
"passwordsMismatch": "رمزهای عبور مطابقت ندارند", "passwordsMismatch": "رمزهای عبور مطابقت ندارند",
"passwordPlaceholder": "رمز عبور را وارد کنید", "passwordPlaceholder": "رمز عبور را وارد کنید",
@@ -681,6 +689,8 @@
"resendVerification": "ارسال مجدد ایمیل تایید", "resendVerification": "ارسال مجدد ایمیل تایید",
"verificationResent": "ایمیل تایید مجدداً ارسال شد!", "verificationResent": "ایمیل تایید مجدداً ارسال شد!",
"canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.",
"emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.",
"alreadyHaveEmail": "شما قبلاً یک ایمیل تایید شده متصل کرده‌اید.",
"notifications": { "notifications": {
"title": "تنظیمات اعلان", "title": "تنظیمات اعلان",
"subscriptionExpiry": "انقضای اشتراک", "subscriptionExpiry": "انقضای اشتراک",

View File

@@ -61,6 +61,8 @@
"email": "Email", "email": "Email",
"password": "Пароль", "password": "Пароль",
"confirmPassword": "Подтвердите пароль", "confirmPassword": "Подтвердите пароль",
"firstName": "Имя",
"firstNamePlaceholder": "Ваше имя (необязательно)",
"forgotPassword": "Забыли пароль?", "forgotPassword": "Забыли пароль?",
"resetPassword": "Сбросить пароль", "resetPassword": "Сбросить пароль",
"loginWithTelegram": "Войти через Telegram", "loginWithTelegram": "Войти через Telegram",
@@ -76,7 +78,24 @@
"tryAgain": "Попробовать снова", "tryAgain": "Попробовать снова",
"welcomeBack": "Добро пожаловать!", "welcomeBack": "Добро пожаловать!",
"loginTitle": "Вход в личный кабинет", "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": { "emailVerification": {
"title": "Подтверждение email", "title": "Подтверждение email",
@@ -84,9 +103,19 @@
"pleaseWait": "Пожалуйста, подождите пока мы проверяем ваш email.", "pleaseWait": "Пожалуйста, подождите пока мы проверяем ваш email.",
"success": "Email подтверждён!", "success": "Email подтверждён!",
"successMessage": "Ваш email успешно подтверждён. Теперь вы можете входить с помощью email и пароля.", "successMessage": "Ваш email успешно подтверждён. Теперь вы можете входить с помощью email и пароля.",
"redirecting": "Переходим в личный кабинет...",
"failed": "Ошибка подтверждения", "failed": "Ошибка подтверждения",
"goToLogin": "Перейти к входу" "goToLogin": "Перейти к входу"
}, },
"resetPassword": {
"title": "Новый пароль",
"enterNewPassword": "Введите новый пароль ниже.",
"success": "Пароль изменён!",
"redirectingToLogin": "Переходим на страницу входа...",
"setPassword": "Установить пароль",
"invalidToken": "Недействительная ссылка",
"tokenExpiredOrInvalid": "Ссылка для сброса пароля недействительна или истекла."
},
"dashboard": { "dashboard": {
"title": "Личный кабинет", "title": "Личный кабинет",
"welcome": "Добро пожаловать, {{name}}!", "welcome": "Добро пожаловать, {{name}}!",
@@ -693,6 +722,8 @@
"animatedBackgroundDesc": "Волны на фоне приложения", "animatedBackgroundDesc": "Волны на фоне приложения",
"autoFullscreen": "Авто-Fullscreen", "autoFullscreen": "Авто-Fullscreen",
"autoFullscreenDesc": "В Telegram WebApp", "autoFullscreenDesc": "В Telegram WebApp",
"emailAuth": "Email авторизация",
"emailAuthDesc": "Регистрация и вход через email",
"availableThemes": "Доступные темы", "availableThemes": "Доступные темы",
"darkTheme": "Тёмная", "darkTheme": "Тёмная",
"lightTheme": "Светлая", "lightTheme": "Светлая",
@@ -1880,6 +1911,7 @@
"linkEmailDescription": "Привяжите email для входа без Telegram. После привязки на ваш email придёт письмо для подтверждения.", "linkEmailDescription": "Привяжите email для входа без Telegram. После привязки на ваш email придёт письмо для подтверждения.",
"linkEmail": "Привязать Email", "linkEmail": "Привязать Email",
"emailRequired": "Введите email", "emailRequired": "Введите email",
"invalidEmail": "Введите корректный email адрес",
"passwordMinLength": "Пароль должен быть минимум 8 символов", "passwordMinLength": "Пароль должен быть минимум 8 символов",
"passwordsMismatch": "Пароли не совпадают", "passwordsMismatch": "Пароли не совпадают",
"passwordPlaceholder": "Введите пароль", "passwordPlaceholder": "Введите пароль",
@@ -1892,6 +1924,8 @@
"resendVerification": "Отправить письмо повторно", "resendVerification": "Отправить письмо повторно",
"verificationResent": "Письмо отправлено повторно!", "verificationResent": "Письмо отправлено повторно!",
"canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.", "canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.",
"emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.",
"alreadyHaveEmail": "У вас уже привязан подтверждённый email.",
"notifications": { "notifications": {
"title": "Настройки уведомлений", "title": "Настройки уведомлений",
"subscriptionExpiry": "Окончание подписки", "subscriptionExpiry": "Окончание подписки",

View File

@@ -74,7 +74,13 @@
"tryAgain": "重试", "tryAgain": "重试",
"welcomeBack": "欢迎回来!", "welcomeBack": "欢迎回来!",
"loginTitle": "登录个人中心", "loginTitle": "登录个人中心",
"loginSubtitle": "通过Telegram或邮箱登录" "loginSubtitle": "通过Telegram或邮箱登录",
"referralInvite": "您已通过推荐链接受邀!",
"checkEmail": "请查收邮件",
"verificationSent": "我们已发送验证链接至:",
"clickLinkToVerify": "点击邮件中的链接验证账户并登录。",
"backToLogin": "返回登录",
"emailNotVerified": "请先验证邮箱"
}, },
"emailVerification": { "emailVerification": {
"title": "邮箱验证", "title": "邮箱验证",
@@ -82,6 +88,7 @@
"pleaseWait": "请稍候,我们正在验证您的邮箱。", "pleaseWait": "请稍候,我们正在验证您的邮箱。",
"success": "邮箱已验证!", "success": "邮箱已验证!",
"successMessage": "您的邮箱已成功验证。现在您可以使用邮箱和密码登录。", "successMessage": "您的邮箱已成功验证。现在您可以使用邮箱和密码登录。",
"redirecting": "正在跳转到个人中心...",
"failed": "验证失败", "failed": "验证失败",
"goToLogin": "前往登录" "goToLogin": "前往登录"
}, },
@@ -670,6 +677,7 @@
"linkEmailDescription": "绑定邮箱以便无需Telegram登录。绑定后将收到验证邮件。", "linkEmailDescription": "绑定邮箱以便无需Telegram登录。绑定后将收到验证邮件。",
"linkEmail": "绑定邮箱", "linkEmail": "绑定邮箱",
"emailRequired": "请输入邮箱", "emailRequired": "请输入邮箱",
"invalidEmail": "请输入有效的电子邮件地址",
"passwordMinLength": "密码至少8个字符", "passwordMinLength": "密码至少8个字符",
"passwordsMismatch": "密码不匹配", "passwordsMismatch": "密码不匹配",
"passwordPlaceholder": "输入密码", "passwordPlaceholder": "输入密码",
@@ -682,6 +690,8 @@
"resendVerification": "重新发送验证邮件", "resendVerification": "重新发送验证邮件",
"verificationResent": "验证邮件已重新发送!", "verificationResent": "验证邮件已重新发送!",
"canLoginWithEmail": "现在您可以使用邮箱和密码登录。", "canLoginWithEmail": "现在您可以使用邮箱和密码登录。",
"emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。",
"alreadyHaveEmail": "您已经绑定了已验证的邮箱。",
"notifications": { "notifications": {
"title": "通知设置", "title": "通知设置",
"subscriptionExpiry": "订阅到期", "subscriptionExpiry": "订阅到期",

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useMemo, useCallback } from 'react' 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 { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth' 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 { getAndClearReturnUrl } from '../utils/token'
import LanguageSwitcher from '../components/LanguageSwitcher' import LanguageSwitcher from '../components/LanguageSwitcher'
import TelegramLoginButton from '../components/TelegramLoginButton' import TelegramLoginButton from '../components/TelegramLoginButton'
@@ -12,14 +13,32 @@ export default function Login() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { isAuthenticated, loginWithTelegram, loginWithEmail } = useAuthStore() const [searchParams] = useSearchParams()
const [activeTab, setActiveTab] = useState<'telegram' | 'email'>('telegram') 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 [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [firstName, setFirstName] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded()) 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 для возврата после авторизации // Получаем URL для возврата после авторизации
const getReturnUrl = useCallback(() => { const getReturnUrl = useCallback(() => {
@@ -52,7 +71,29 @@ export default function Login() {
initialData: cachedBranding ?? undefined, 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 || '' 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 appName = branding ? branding.name : (import.meta.env.VITE_APP_NAME || 'VPN')
const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V' const appLogo = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
@@ -94,30 +135,93 @@ export default function Login() {
tryTelegramAuth() tryTelegramAuth()
}, [loginWithTelegram, navigate, t, getReturnUrl]) }, [loginWithTelegram, navigate, t, getReturnUrl])
const handleEmailLogin = async (e: React.FormEvent) => { const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') 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) setIsLoading(true)
try { try {
await loginWithEmail(email, password) if (authMode === 'login') {
navigate(getReturnUrl(), { replace: true }) 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) { } catch (err: unknown) {
const error = err as { response?: { status?: number; data?: { detail?: string } } } 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 const status = error.response?.status
if (status === 401 || status === 403) { const detail = error.response?.data?.detail
setError(t('auth.invalidCredentials', 'Неверный email или пароль'))
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) { } else if (status === 429) {
setError(t('auth.tooManyAttempts', 'Слишком много попыток. Попробуйте позже')) setError(t('auth.tooManyAttempts', 'Too many attempts. Please try again later'))
} else { } else {
setError(t('common.error')) setError(detail || t('common.error'))
} }
} finally { } finally {
setIsLoading(false) 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 ( return (
<div className="min-h-screen flex items-center justify-center py-8 px-4 sm:py-12 sm:px-6 lg:px-8"> <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 */} {/* Background gradient */}
@@ -155,33 +259,80 @@ export default function Login() {
<p className="mt-2 text-dark-400"> <p className="mt-2 text-dark-400">
{t('auth.loginSubtitle')} {t('auth.loginSubtitle')}
</p> </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> </div>
{/* Card */} {/* Check Email Screen */}
<div className="card"> {registeredEmail ? (
{/* Tabs */} <div className="card text-center">
<div className="flex mb-6"> <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 <button
className={`flex-1 py-3 text-sm font-medium transition-all border-b-2 ${ onClick={() => {
activeTab === 'telegram' setRegisteredEmail(null)
? 'border-accent-500 text-accent-400' setAuthMode('login')
: 'border-transparent text-dark-500 hover:text-dark-300' }}
}`} className="btn-secondary w-full"
onClick={() => setActiveTab('telegram')}
> >
Telegram {t('auth.backToLogin', 'Back to login')}
</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> </button>
</div> </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 && ( {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"> <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> </div>
)} )}
{activeTab === 'telegram' ? ( {activeTab === 'telegram' || !isEmailAuthEnabled ? (
<div className="space-y-6"> <div className="space-y-6">
<p className="text-center text-sm text-dark-400"> <p className="text-center text-sm text-dark-400">
{t('auth.registerHint')} {t('auth.registerHint')}
@@ -205,63 +356,228 @@ export default function Login() {
)} )}
</div> </div>
) : ( ) : (
<form className="space-y-5" onSubmit={handleEmailLogin}> <div className="space-y-5">
<div> {/* Login / Register toggle */}
<label htmlFor="email" className="label"> <div className="flex bg-dark-800 rounded-lg p-1">
{t('auth.email')} <button
</label> type="button"
<input className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
id="email" authMode === 'login'
name="email" ? 'bg-accent-500 text-white'
type="email" : 'text-dark-400 hover:text-dark-200'
autoComplete="email" }`}
required onClick={() => setAuthMode('login')}
className="input" >
placeholder="you@example.com" {t('auth.login')}
value={email} </button>
onChange={(e) => setEmail(e.target.value)} <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>
<div> <form className="space-y-4" onSubmit={handleEmailSubmit}>
<label htmlFor="password" className="label"> {/* First name field - only for registration */}
{t('auth.password')} {authMode === 'register' && (
</label> <div>
<input <label htmlFor="firstName" className="label">
id="password" {t('auth.firstName', 'First Name')}
name="password" </label>
type="password" <input
autoComplete="current-password" id="firstName"
required name="firstName"
className="input" type="text"
placeholder="••••••••" autoComplete="given-name"
value={password} className="input"
onChange={(e) => setPassword(e.target.value)} placeholder={t('auth.firstNamePlaceholder', 'Your name (optional)')}
/> value={firstName}
</div> onChange={(e) => setFirstName(e.target.value)}
/>
<button </div>
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')
)} )}
</button>
<p className="text-center text-xs text-dark-500"> <div>
{t('auth.registerHint')} <label htmlFor="email" className="label">
</p> {t('auth.email')}
</form> </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>
)}
</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> </div>
) )
} }

View File

@@ -1,9 +1,38 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth'
import { authApi } from '../api/auth' import { authApi } from '../api/auth'
import { notificationsApi, NotificationSettings, NotificationSettingsUpdate } from '../api/notifications' 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() { export default function Profile() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -15,6 +44,65 @@ export default function Profile() {
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = 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({ const registerEmailMutation = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) => mutationFn: ({ email, password }: { email: string; password: string }) =>
@@ -30,7 +118,14 @@ export default function Profile() {
queryClient.invalidateQueries({ queryKey: ['user'] }) queryClient.invalidateQueries({ queryKey: ['user'] })
}, },
onError: (err: { response?: { data?: { detail?: string } } }) => { 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) setSuccess(null)
}, },
}) })
@@ -74,8 +169,10 @@ export default function Profile() {
setError(null) setError(null)
setSuccess(null) setSuccess(null)
if (!email.trim()) { // Валидация email
setError(t('profile.emailRequired')) const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!email.trim() || !emailRegex.test(email.trim())) {
setError(t('profile.invalidEmail', 'Please enter a valid email address'))
return return
} }
@@ -125,7 +222,52 @@ export default function Profile() {
</div> </div>
</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"> <div className="bento-card">
<h2 className="text-lg font-semibold text-dark-100 mb-6">{t('profile.emailAuth')}</h2> <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>
)} )}
</div> </div>
)}
{/* Notification Settings */} {/* Notification Settings */}
<div className="bento-card"> <div className="bento-card">

View File

@@ -68,11 +68,10 @@ export default function Referral() {
queryFn: referralApi.getReferralInfo, queryFn: referralApi.getReferralInfo,
}) })
// Build referral link using frontend env variable for correct bot username // Build referral link for cabinet registration
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME const referralLink = info?.referral_code
const referralLink = info?.referral_code && botUsername ? `${window.location.origin}/login?ref=${info.referral_code}`
? `https://t.me/${botUsername}?start=${info.referral_code}` : ''
: info?.referral_link || ''
const { data: terms } = useQuery({ const { data: terms } = useQuery({
queryKey: ['referral-terms'], queryKey: ['referral-terms'],

175
src/pages/ResetPassword.tsx Normal file
View 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>
)
}

View File

@@ -1,14 +1,18 @@
import { useEffect, useState } from 'react' 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 { useTranslation } from 'react-i18next'
import { authApi } from '../api/auth' import { authApi } from '../api/auth'
import { useAuthStore } from '../store/auth'
import { tokenStorage } from '../utils/token'
import LanguageSwitcher from '../components/LanguageSwitcher' import LanguageSwitcher from '../components/LanguageSwitcher'
export default function VerifyEmail() { export default function VerifyEmail() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState('') const [error, setError] = useState('')
const { setTokens, setUser, checkAdminStatus } = useAuthStore()
useEffect(() => { useEffect(() => {
const token = searchParams.get('token') const token = searchParams.get('token')
@@ -21,8 +25,15 @@ export default function VerifyEmail() {
const verify = async () => { const verify = async () => {
try { 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') setStatus('success')
// Redirect to dashboard after short delay
setTimeout(() => navigate('/', { replace: true }), 1500)
} catch (err: unknown) { } catch (err: unknown) {
setStatus('error') setStatus('error')
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
@@ -31,7 +42,7 @@ export default function VerifyEmail() {
} }
verify() verify()
}, [searchParams, t]) }, [searchParams, t, navigate, setTokens, setUser, checkAdminStatus])
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-8 px-4 sm:py-12"> <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> <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> <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"> <p className="text-sm sm:text-base text-gray-500 mt-2">
{t('emailVerification.successMessage')} {t('emailVerification.redirecting', 'Redirecting to dashboard...')}
</p> </p>
<div className="mt-6"> <div className="mt-4">
<Link to="/login" className="btn-primary"> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600 mx-auto"></div>
{t('emailVerification.goToLogin')}
</Link>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import type { User } from '../types' import type { RegisterResponse, User } from '../types'
import { authApi } from '../api/auth' import { authApi } from '../api/auth'
import { apiClient } from '../api/client' import { apiClient } from '../api/client'
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token' import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token'
@@ -33,6 +33,7 @@ interface AuthState {
loginWithTelegram: (initData: string) => Promise<void> loginWithTelegram: (initData: string) => Promise<void>
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void> loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>
loginWithEmail: (email: string, password: string) => Promise<void> loginWithEmail: (email: string, password: string) => Promise<void>
registerWithEmail: (email: string, password: string, firstName?: string, referralCode?: string) => Promise<RegisterResponse>
} }
// Блокировка для предотвращения race condition при инициализации // Блокировка для предотвращения race condition при инициализации
@@ -260,6 +261,19 @@ export const useAuthStore = create<AuthState>()(
}) })
get().checkAdminStatus() 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', name: 'cabinet-auth',

View File

@@ -1,7 +1,7 @@
// User types // User types
export interface User { export interface User {
id: number id: number
telegram_id: number telegram_id: number | null // Nullable для email-only пользователей
username: string | null username: string | null
first_name: string | null first_name: string | null
last_name: string | null last_name: string | null
@@ -12,6 +12,7 @@ export interface User {
referral_code: string | null referral_code: string | null
language: string language: string
created_at: string created_at: string
auth_type: 'telegram' | 'email' // Тип аутентификации
} }
// Auth types // Auth types
@@ -30,6 +31,12 @@ export interface TokenResponse {
expires_in: number expires_in: number
} }
export interface RegisterResponse {
message: string
email: string
requires_verification: boolean
}
// Subscription types // Subscription types
export interface ServerInfo { export interface ServerInfo {
uuid: string uuid: string