mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "انقضای اشتراک",
|
||||||
|
|||||||
@@ -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": "Окончание подписки",
|
||||||
|
|||||||
@@ -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": "订阅到期",
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
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 { 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user