From 2f1083d00578bc7da88415eb004b1aac4b5b261b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 15:52:51 +0300 Subject: [PATCH 1/4] Update auth.ts --- src/api/auth.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/api/auth.ts b/src/api/auth.ts index d7104e9..f5c7b9b 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -108,4 +108,20 @@ export const authApi = { const response = await apiClient.get('/cabinet/auth/me'); return response.data; }, + + // Request email change - sends verification code to new email + requestEmailChange: async (newEmail: string): Promise<{ message: string }> => { + const response = await apiClient.post('/cabinet/auth/email/change', { + new_email: newEmail, + }); + return response.data; + }, + + // Verify email change with code + verifyEmailChange: async (code: string): Promise<{ message: string; email: string }> => { + const response = await apiClient.post('/cabinet/auth/email/change/verify', { + code, + }); + return response.data; + }, }; From 23e9444cf4c403c3b8dca7d86c4c46f4ada5ba7c Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 15:53:23 +0300 Subject: [PATCH 2/4] Update Profile.tsx --- src/pages/Profile.tsx | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 5917286..cd0d164 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -11,6 +11,7 @@ import { } from '../api/notifications'; import { referralApi } from '../api/referral'; import { brandingApi, type EmailAuthEnabled } from '../api/branding'; +import ChangeEmailModal from '../components/ChangeEmailModal'; // Icons const CopyIcon = () => ( @@ -42,6 +43,16 @@ const ArrowRightIcon = () => ( ); +const PencilIcon = () => ( + + + +); + export default function Profile() { const { t } = useTranslation(); const { user, setUser } = useAuthStore(); @@ -53,6 +64,7 @@ export default function Profile() { const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [copied, setCopied] = useState(false); + const [showChangeEmailModal, setShowChangeEmailModal] = useState(false); // Referral data const { data: referralInfo } = useQuery({ @@ -311,7 +323,16 @@ export default function Profile() { )} {user.email_verified && ( -

{t('profile.canLoginWithEmail')}

+
+

{t('profile.canLoginWithEmail')}

+ +
)} ) : ( @@ -618,6 +639,14 @@ export default function Profile() {

{t('profile.notifications.unavailable')}

)} + + {/* Change Email Modal */} + {showChangeEmailModal && user?.email && ( + setShowChangeEmailModal(false)} + currentEmail={user.email} + /> + )} ); } From 760761729bd2da4f0c0be2e415471e2b8787fd49 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 15:54:31 +0300 Subject: [PATCH 3/4] Add files via upload --- src/components/ChangeEmailModal.tsx | 504 ++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 src/components/ChangeEmailModal.tsx diff --git a/src/components/ChangeEmailModal.tsx b/src/components/ChangeEmailModal.tsx new file mode 100644 index 0000000..bfbd11e --- /dev/null +++ b/src/components/ChangeEmailModal.tsx @@ -0,0 +1,504 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { authApi } from '../api/auth'; +import { useAuthStore } from '../store/auth'; +import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; + +// Icons +const CloseIcon = () => ( + + + +); + +const EmailIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +interface ChangeEmailModalProps { + onClose: () => void; + currentEmail: string; +} + +type Step = 'email' | 'code' | 'success'; + +function useIsMobile() { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false; + return window.innerWidth < 640; + }); + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 640); + window.addEventListener('resize', check); + return () => window.removeEventListener('resize', check); + }, []); + return isMobile; +} + +const RESEND_COOLDOWN = 60; // seconds + +export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { setUser } = useAuthStore(); + const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp(); + const isMobileScreen = useIsMobile(); + + const emailInputRef = useRef(null); + const codeInputRef = useRef(null); + + const [step, setStep] = useState('email'); + const [newEmail, setNewEmail] = useState(''); + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [resendCooldown, setResendCooldown] = useState(0); + + const safeBottom = isTelegramWebApp + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) + : 0; + + const handleClose = useCallback(() => { + onClose(); + }, [onClose]); + + // Keyboard: Escape to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleClose]); + + // Telegram back button + useEffect(() => { + if (!webApp?.BackButton) return; + webApp.BackButton.show(); + webApp.BackButton.onClick(handleClose); + return () => { + webApp.BackButton.offClick(handleClose); + webApp.BackButton.hide(); + }; + }, [webApp, handleClose]); + + // Scroll lock + useEffect(() => { + const scrollY = window.scrollY; + const preventScroll = (e: TouchEvent) => { + const target = e.target as HTMLElement; + if (target.closest('[data-modal-content]')) return; + e.preventDefault(); + }; + const preventWheel = (e: WheelEvent) => { + const target = e.target as HTMLElement; + if (target.closest('[data-modal-content]')) return; + e.preventDefault(); + }; + document.addEventListener('touchmove', preventScroll, { passive: false }); + document.addEventListener('wheel', preventWheel, { passive: false }); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('touchmove', preventScroll); + document.removeEventListener('wheel', preventWheel); + document.body.style.overflow = ''; + window.scrollTo(0, scrollY); + }; + }, []); + + // Resend cooldown timer + useEffect(() => { + if (resendCooldown <= 0) return; + const timer = setInterval(() => { + setResendCooldown((prev) => Math.max(0, prev - 1)); + }, 1000); + return () => clearInterval(timer); + }, [resendCooldown]); + + // Auto-focus inputs + useEffect(() => { + const timer = setTimeout(() => { + if (step === 'email' && emailInputRef.current) { + emailInputRef.current.focus(); + } else if (step === 'code' && codeInputRef.current) { + codeInputRef.current.focus(); + } + }, 100); + return () => clearTimeout(timer); + }, [step]); + + // Request email change mutation + const requestChangeMutation = useMutation({ + mutationFn: (email: string) => authApi.requestEmailChange(email), + onSuccess: () => { + setError(null); + setStep('code'); + setResendCooldown(RESEND_COOLDOWN); + }, + onError: (err: { response?: { data?: { detail?: string } } }) => { + const detail = err.response?.data?.detail; + if (detail?.includes('already registered') || detail?.includes('already in use')) { + setError(t('profile.changeEmail.emailAlreadyUsed')); + } else if (detail?.includes('same as current')) { + setError(t('profile.changeEmail.sameEmail')); + } else if (detail?.includes('rate limit') || detail?.includes('too many')) { + setError(t('profile.changeEmail.tooManyRequests')); + } else { + setError(detail || t('common.error')); + } + }, + }); + + // Verify code mutation + const verifyCodeMutation = useMutation({ + mutationFn: (verificationCode: string) => authApi.verifyEmailChange(verificationCode), + onSuccess: async () => { + setError(null); + setStep('success'); + // Refresh user data + const updatedUser = await authApi.getMe(); + setUser(updatedUser); + queryClient.invalidateQueries({ queryKey: ['user'] }); + }, + onError: (err: { response?: { data?: { detail?: string } } }) => { + const detail = err.response?.data?.detail; + if (detail?.includes('invalid') || detail?.includes('wrong')) { + setError(t('profile.changeEmail.invalidCode')); + } else if (detail?.includes('expired')) { + setError(t('profile.changeEmail.codeExpired')); + } else { + setError(detail || t('common.error')); + } + }, + }); + + const validateEmail = (email: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email.trim()); + }; + + const handleSendCode = () => { + setError(null); + if (!newEmail.trim()) { + setError(t('profile.emailRequired')); + return; + } + if (!validateEmail(newEmail)) { + setError(t('profile.invalidEmail')); + return; + } + if (newEmail.toLowerCase().trim() === currentEmail.toLowerCase()) { + setError(t('profile.changeEmail.sameEmail')); + return; + } + requestChangeMutation.mutate(newEmail.trim()); + }; + + const handleVerifyCode = () => { + setError(null); + if (!code.trim()) { + setError(t('profile.changeEmail.enterCode')); + return; + } + if (code.trim().length < 4) { + setError(t('profile.changeEmail.invalidCode')); + return; + } + verifyCodeMutation.mutate(code.trim()); + }; + + const handleResendCode = () => { + if (resendCooldown > 0) return; + requestChangeMutation.mutate(newEmail.trim()); + }; + + const handleBack = () => { + setStep('email'); + setCode(''); + setError(null); + }; + + const isPending = requestChangeMutation.isPending || verifyCodeMutation.isPending; + + // Content JSX + const contentJSX = ( +
+ {/* Header icon */} +
+
+ +
+
+

{t('profile.changeEmail.title')}

+

+ {step === 'email' && t('profile.changeEmail.description')} + {step === 'code' && t('profile.changeEmail.enterCodeDescription')} + {step === 'success' && t('profile.changeEmail.successDescription')} +

+
+
+ + {/* Current email display */} + {step === 'email' && ( +
+

{t('profile.changeEmail.currentEmail')}

+

{currentEmail}

+
+ )} + + {/* Email input step */} + {step === 'email' && ( +
+
+ + setNewEmail(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSendCode(); + } + }} + placeholder="new@email.com" + className="input w-full" + autoComplete="email" + autoFocus + /> +
+ + +
+ )} + + {/* Code verification step */} + {step === 'code' && ( +
+
+

+ {t('profile.changeEmail.codeSentTo', { email: newEmail })} +

+
+ +
+ + setCode(e.target.value.replace(/\D/g, ''))} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleVerifyCode(); + } + }} + placeholder="000000" + maxLength={6} + className="input w-full text-center text-2xl tracking-[0.5em]" + autoComplete="one-time-code" + autoFocus + /> +
+ + + +
+ + +
+
+ )} + + {/* Success step */} + {step === 'success' && ( +
+
+
+ +
+

+ {t('profile.changeEmail.success')} +

+

{newEmail}

+
+ + +
+ )} + + {/* Error message */} + {error && ( +
+ + + + {error} +
+ )} +
+ ); + + // Render modal based on screen size + const modalContent = isMobileScreen ? ( + <> + {/* Backdrop */} +
+ {/* Bottom sheet */} +
e.stopPropagation()} + > + {/* Handle bar */} +
+
+
+ + {/* Header */} +
+
+ + + {t('profile.changeEmail.title')} + +
+ +
+ + {/* Divider */} +
+ + {/* Content */} +
{contentJSX}
+
+ + ) : ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+ + {t('profile.changeEmail.title')} + +
+ +
+ + {/* Content */} +
{contentJSX}
+
+
+ ); + + if (typeof document !== 'undefined') { + return createPortal(modalContent, document.body); + } + return modalContent; +} From a0461c7a898f068e665df2a4b3fb08288bd1bb4b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 30 Jan 2026 15:55:08 +0300 Subject: [PATCH 4/4] Add files via upload --- src/locales/en.json | 22 ++++++++++++++++++++++ src/locales/fa.json | 22 ++++++++++++++++++++++ src/locales/ru.json | 22 ++++++++++++++++++++++ src/locales/zh.json | 22 ++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/src/locales/en.json b/src/locales/en.json index 0411129..c96e611 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2044,6 +2044,28 @@ "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.", + "changeEmail": { + "button": "Change email", + "title": "Change Email", + "description": "Enter your new email address. A verification code will be sent to it.", + "currentEmail": "Current email", + "newEmail": "New email", + "sendCode": "Send code", + "enterCode": "Enter code", + "enterCodeDescription": "Enter the verification code sent to your new email.", + "verificationCode": "Verification code", + "codeSentTo": "Code sent to {{email}}", + "verify": "Verify", + "resendCode": "Resend code", + "resendIn": "Resend in {{seconds}} sec.", + "success": "Email changed successfully!", + "successDescription": "Your email has been successfully updated.", + "emailAlreadyUsed": "This email is already used by another account.", + "sameEmail": "New email is the same as current.", + "invalidCode": "Invalid verification code.", + "codeExpired": "Code has expired. Please request a new one.", + "tooManyRequests": "Too many requests. Please try again later." + }, "notifications": { "title": "Notification Settings", "subscriptionExpiry": "Subscription Expiry", diff --git a/src/locales/fa.json b/src/locales/fa.json index 0faf228..5aff24c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1670,6 +1670,28 @@ "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", "emailAlreadyRegistered": "این ایمیل قبلاً به حساب دیگری متصل شده است. اگر این ایمیل شماست، با آن وارد شوید یا با پشتیبانی تماس بگیرید.", "alreadyHaveEmail": "شما قبلاً یک ایمیل تایید شده متصل کرده‌اید.", + "changeEmail": { + "button": "تغییر ایمیل", + "title": "تغییر ایمیل", + "description": "آدرس ایمیل جدید را وارد کنید. کد تایید به آن ارسال خواهد شد.", + "currentEmail": "ایمیل فعلی", + "newEmail": "ایمیل جدید", + "sendCode": "ارسال کد", + "enterCode": "کد را وارد کنید", + "enterCodeDescription": "کد تایید ارسال شده به ایمیل جدید را وارد کنید.", + "verificationCode": "کد تایید", + "codeSentTo": "کد به {{email}} ارسال شد", + "verify": "تایید", + "resendCode": "ارسال مجدد کد", + "resendIn": "ارسال مجدد در {{seconds}} ثانیه", + "success": "ایمیل با موفقیت تغییر یافت!", + "successDescription": "ایمیل شما با موفقیت به‌روزرسانی شد.", + "emailAlreadyUsed": "این ایمیل توسط حساب دیگری استفاده می‌شود.", + "sameEmail": "ایمیل جدید با ایمیل فعلی یکسان است.", + "invalidCode": "کد تایید نامعتبر است.", + "codeExpired": "کد منقضی شده است. لطفاً کد جدید درخواست کنید.", + "tooManyRequests": "درخواست‌های زیادی ارسال شده. لطفاً بعداً دوباره امتحان کنید." + }, "notifications": { "title": "تنظیمات اعلان", "subscriptionExpiry": "انقضای اشتراک", diff --git a/src/locales/ru.json b/src/locales/ru.json index 34501a3..835c885 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2729,6 +2729,28 @@ "canLoginWithEmail": "Теперь вы можете входить с помощью email и пароля.", "emailAlreadyRegistered": "Этот email уже привязан к другому аккаунту. Если это ваш email, войдите через него или обратитесь в поддержку.", "alreadyHaveEmail": "У вас уже привязан подтверждённый email.", + "changeEmail": { + "button": "Сменить почту", + "title": "Смена почты", + "description": "Введите новый email адрес. На него будет отправлен код подтверждения.", + "currentEmail": "Текущий email", + "newEmail": "Новый email", + "sendCode": "Отправить код", + "enterCode": "Введите код", + "enterCodeDescription": "Введите код подтверждения, отправленный на новую почту.", + "verificationCode": "Код подтверждения", + "codeSentTo": "Код отправлен на {{email}}", + "verify": "Подтвердить", + "resendCode": "Отправить код повторно", + "resendIn": "Повторить через {{seconds}} сек.", + "success": "Email успешно изменён!", + "successDescription": "Ваш email был успешно обновлён.", + "emailAlreadyUsed": "Этот email уже используется другим аккаунтом.", + "sameEmail": "Новый email совпадает с текущим.", + "invalidCode": "Неверный код подтверждения.", + "codeExpired": "Срок действия кода истёк. Запросите новый.", + "tooManyRequests": "Слишком много запросов. Попробуйте позже." + }, "notifications": { "title": "Настройки уведомлений", "subscriptionExpiry": "Окончание подписки", diff --git a/src/locales/zh.json b/src/locales/zh.json index 1bbcbfc..bd9838f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1669,6 +1669,28 @@ "canLoginWithEmail": "现在您可以使用邮箱和密码登录。", "emailAlreadyRegistered": "此邮箱已绑定到其他账户。如果这是您的邮箱,请使用邮箱登录或联系客服。", "alreadyHaveEmail": "您已经绑定了已验证的邮箱。", + "changeEmail": { + "button": "更换邮箱", + "title": "更换邮箱", + "description": "输入新邮箱地址。验证码将发送到该邮箱。", + "currentEmail": "当前邮箱", + "newEmail": "新邮箱", + "sendCode": "发送验证码", + "enterCode": "输入验证码", + "enterCodeDescription": "输入发送到新邮箱的验证码。", + "verificationCode": "验证码", + "codeSentTo": "验证码已发送至 {{email}}", + "verify": "确认", + "resendCode": "重新发送验证码", + "resendIn": "{{seconds}} 秒后重新发送", + "success": "邮箱更换成功!", + "successDescription": "您的邮箱已成功更新。", + "emailAlreadyUsed": "此邮箱已被其他账户使用。", + "sameEmail": "新邮箱与当前邮箱相同。", + "invalidCode": "验证码无效。", + "codeExpired": "验证码已过期,请重新获取。", + "tooManyRequests": "请求过于频繁,请稍后再试。" + }, "notifications": { "title": "通知设置", "subscriptionExpiry": "订阅到期",