diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx index 1f38940..7799435 100644 --- a/src/components/layout/AppShell/DesktopSidebar.tsx +++ b/src/components/layout/AppShell/DesktopSidebar.tsx @@ -74,7 +74,8 @@ export function DesktopSidebar({ const hasCustomLogo = branding?.has_custom_logo || false; const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; - const isActive = (path: string) => location.pathname === path; + const isActive = (path: string) => + path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); const isAdminActive = () => location.pathname.startsWith('/admin'); const navItems = [ diff --git a/src/components/layout/AppShell/MobileBottomNav.tsx b/src/components/layout/AppShell/MobileBottomNav.tsx index 9d72221..f143787 100644 --- a/src/components/layout/AppShell/MobileBottomNav.tsx +++ b/src/components/layout/AppShell/MobileBottomNav.tsx @@ -23,7 +23,8 @@ export function MobileBottomNav({ const location = useLocation(); const { haptic } = usePlatform(); - const isActive = (path: string) => location.pathname === path; + const isActive = (path: string) => + path === '/' ? location.pathname === '/' : location.pathname.startsWith(path); // Core navigation items for bottom bar // When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu) diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 7a839bd..e89e0a6 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { authApi } from '../api/auth'; -import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; +import { brandingApi, type TelegramWidgetConfig, type EmailAuthEnabled } from '../api/branding'; import { useToast } from '../components/Toast'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; @@ -13,6 +13,8 @@ import ProviderIcon from '../components/ProviderIcon'; import { LINK_OAUTH_STATE_KEY, LINK_OAUTH_PROVIDER_KEY, getErrorDetail } from '../utils/oauth'; import { getTelegramInitData } from '../hooks/useTelegramSDK'; import { usePlatform, useIsTelegram } from '@/platform/hooks/usePlatform'; +import { useAuthStore } from '../store/auth'; +import { isValidEmail } from '../utils/validation'; import type { LinkedProvider } from '../types'; const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; @@ -20,7 +22,7 @@ const OAUTH_PROVIDERS = ['google', 'yandex', 'discord', 'vk']; const isOAuthProvider = (provider: string): boolean => OAUTH_PROVIDERS.includes(provider); const isLinkableProvider = (provider: string): boolean => - isOAuthProvider(provider) || provider === 'telegram'; + isOAuthProvider(provider) || provider === 'telegram' || provider === 'email'; // SessionStorage key for Telegram link CSRF state export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state'; @@ -314,6 +316,22 @@ export default function ConnectedAccounts() { const pendingLinkProvider = useRef(null); const blurTimeoutRef = useRef>(undefined); + // Email linking inline form state + const [emailFormOpen, setEmailFormOpen] = useState(false); + const [emailValue, setEmailValue] = useState(''); + const [emailPassword, setEmailPassword] = useState(''); + const [emailConfirmPassword, setEmailConfirmPassword] = useState(''); + const [emailError, setEmailError] = useState(null); + const [emailSuccess, setEmailSuccess] = useState(null); + const setUser = useAuthStore((state) => state.setUser); + + const { data: emailAuthConfig } = useQuery({ + queryKey: ['email-auth-enabled'], + queryFn: brandingApi.getEmailAuthEnabled, + staleTime: 60000, + }); + const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true; + const inTelegram = useIsTelegram(); const platform = usePlatform(); @@ -375,6 +393,57 @@ export default function ConnectedAccounts() { }, }); + const registerEmailMutation = useMutation({ + mutationFn: ({ email, password }: { email: string; password: string }) => + authApi.registerEmail(email, password), + onSuccess: async (response) => { + if (response.merge_required && response.merge_token) { + navigate(`/merge/${response.merge_token}`, { replace: true }); + return; + } + setEmailSuccess(t('profile.emailSent')); + setEmailError(null); + setEmailValue(''); + setEmailPassword(''); + setEmailConfirmPassword(''); + const updatedUser = await authApi.getMe(); + setUser(updatedUser); + queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); + queryClient.invalidateQueries({ queryKey: ['user'] }); + }, + onError: (err: { response?: { data?: { detail?: string } } }) => { + const detail = err.response?.data?.detail; + if (detail?.includes('already registered')) { + setEmailError(t('profile.emailAlreadyRegistered')); + } else if (detail?.includes('already have a verified email')) { + setEmailError(t('profile.alreadyHaveEmail')); + } else { + setEmailError(detail || t('common.error')); + } + setEmailSuccess(null); + }, + }); + + const handleEmailSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setEmailError(null); + setEmailSuccess(null); + + if (!emailValue.trim() || !isValidEmail(emailValue.trim())) { + setEmailError(t('profile.invalidEmail')); + return; + } + if (!emailPassword || emailPassword.length < 8) { + setEmailError(t('profile.passwordMinLength')); + return; + } + if (emailPassword !== emailConfirmPassword) { + setEmailError(t('profile.passwordsMismatch')); + return; + } + registerEmailMutation.mutate({ email: emailValue, password: emailPassword }); + }; + const canUnlink = (provider: LinkedProvider): boolean => { if (!provider.linked) return false; if (!isOAuthProvider(provider.provider)) return false; @@ -470,6 +539,23 @@ export default function ConnectedAccounts() { }; const renderLinkButton = (provider: LinkedProvider) => { + if (provider.provider === 'email') { + if (!isEmailAuthEnabled) return null; + return ( + + ); + } + if (provider.provider === 'telegram') { if (inTelegram && getTelegramInitData()) { // Mini App: one-click button @@ -583,6 +669,88 @@ export default function ConnectedAccounts() { )} + + {/* Inline email linking form */} + {provider.provider === 'email' && !provider.linked && ( + + {emailFormOpen && ( + +
+

+ {t('profile.linkEmailDescription')} +

+
+
+ + setEmailValue(e.target.value)} + placeholder="email@example.com" + className="input" + autoComplete="email" + /> +
+
+ + setEmailPassword(e.target.value)} + placeholder={t('profile.passwordPlaceholder')} + className="input" + autoComplete="new-password" + /> +

{t('profile.passwordHint')}

+
+
+ + setEmailConfirmPassword(e.target.value)} + placeholder={t('profile.confirmPasswordPlaceholder')} + className="input" + autoComplete="new-password" + /> +
+ + {emailError && ( +
+ {emailError} +
+ )} + {emailSuccess && ( +
+ {emailSuccess} +
+ )} + + +
+
+
+ )} +
+ )} ))} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index eb09234..953bc49 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -67,9 +67,6 @@ export default function Profile() { const setUser = useAuthStore((state) => state.setUser); const queryClient = useQueryClient(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); const [copied, setCopied] = useState(false); @@ -145,37 +142,6 @@ export default function Profile() { window.open(telegramUrl, '_blank', 'noopener,noreferrer'); }; - const registerEmailMutation = useMutation({ - mutationFn: ({ email, password }: { email: string; password: string }) => - authApi.registerEmail(email, password), - onSuccess: async (response) => { - // Backend returns merge_required when email belongs to another user - if (response.merge_required && response.merge_token) { - navigate(`/merge/${response.merge_token}`, { replace: true }); - return; - } - setSuccess(t('profile.emailSent')); - setError(null); - setEmail(''); - setPassword(''); - setConfirmPassword(''); - 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('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); - }, - }); - const resendVerificationMutation = useMutation({ mutationFn: authApi.resendVerification, onSuccess: () => { @@ -339,29 +305,6 @@ export default function Profile() { updateNotificationsMutation.mutate(update); }; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setSuccess(null); - - if (!email.trim() || !isValidEmail(email.trim())) { - setError(t('profile.invalidEmail', 'Please enter a valid email address')); - return; - } - - if (!password || password.length < 8) { - setError(t('profile.passwordMinLength')); - return; - } - - if (password !== confirmPassword) { - setError(t('profile.passwordsMismatch')); - return; - } - - registerEmailMutation.mutate({ email, password }); - }; - return ( ) : ( -
-

{t('profile.linkEmailDescription')}

- -
-
- - setEmail(e.target.value)} - placeholder="your@email.com" - className="input" - /> -
- -
- - setPassword(e.target.value)} - placeholder={t('profile.passwordPlaceholder')} - className="input" - /> -

{t('profile.passwordHint')}

-
- -
- - setConfirmPassword(e.target.value)} - placeholder={t('profile.confirmPasswordPlaceholder')} - className="input" - /> -
- - {error && ( -
- {error} -
- )} - - {success && ( -
- {success} -
- )} - - -
+
+

{t('profile.linkEmailDescription')}

+
)}