Merge pull request #374 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-04-02 07:23:59 +03:00
committed by GitHub
4 changed files with 180 additions and 116 deletions

View File

@@ -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 = [

View File

@@ -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)

View File

@@ -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<string | null>(null);
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(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<string | null>(null);
const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
const setUser = useAuthStore((state) => state.setUser);
const { data: emailAuthConfig } = useQuery<EmailAuthEnabled>({
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 (
<Button
variant="primary"
size="sm"
onClick={() => {
setEmailFormOpen((prev) => !prev);
setEmailError(null);
setEmailSuccess(null);
}}
>
{emailFormOpen ? t('common.cancel') : t('profile.accounts.link')}
</Button>
);
}
if (provider.provider === 'telegram') {
if (inTelegram && getTelegramInitData()) {
// Mini App: one-click button
@@ -583,6 +669,88 @@ export default function ConnectedAccounts() {
)}
</div>
</div>
{/* Inline email linking form */}
{provider.provider === 'email' && !provider.linked && (
<AnimatePresence>
{emailFormOpen && (
<motion.div
key="email-link-form"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 border-t border-dark-700/30 pt-4">
<p className="mb-4 text-sm text-dark-400">
{t('profile.linkEmailDescription')}
</p>
<form onSubmit={handleEmailSubmit} className="space-y-3">
<div>
<label htmlFor="email-link-input" className="label">
Email
</label>
<input
id="email-link-input"
type="email"
value={emailValue}
onChange={(e) => setEmailValue(e.target.value)}
placeholder="email@example.com"
className="input"
autoComplete="email"
/>
</div>
<div>
<label htmlFor="email-link-password" className="label">
{t('auth.password')}
</label>
<input
id="email-link-password"
type="password"
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
placeholder={t('profile.passwordPlaceholder')}
className="input"
autoComplete="new-password"
/>
<p className="mt-1 text-xs text-dark-500">{t('profile.passwordHint')}</p>
</div>
<div>
<label htmlFor="email-link-confirm" className="label">
{t('auth.confirmPassword')}
</label>
<input
id="email-link-confirm"
type="password"
value={emailConfirmPassword}
onChange={(e) => setEmailConfirmPassword(e.target.value)}
placeholder={t('profile.confirmPasswordPlaceholder')}
className="input"
autoComplete="new-password"
/>
</div>
{emailError && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
{emailError}
</div>
)}
{emailSuccess && (
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400">
{emailSuccess}
</div>
)}
<Button type="submit" fullWidth loading={registerEmailMutation.isPending}>
{t('profile.linkEmail')}
</Button>
</form>
</div>
</motion.div>
)}
</AnimatePresence>
)}
</Card>
</motion.div>
))}

View File

@@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<motion.div
className="space-y-6"
@@ -669,60 +612,11 @@ export default function Profile() {
</AnimatePresence>
</div>
) : (
<div>
<p className="mb-6 text-sm text-dark-400">{t('profile.linkEmailDescription')}</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="label">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className="input"
/>
</div>
<div>
<label className="label">{t('auth.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('profile.passwordPlaceholder')}
className="input"
/>
<p className="mt-2 text-xs text-dark-500">{t('profile.passwordHint')}</p>
</div>
<div>
<label className="label">{t('auth.confirmPassword')}</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t('profile.confirmPasswordPlaceholder')}
className="input"
/>
</div>
{error && (
<div className="rounded-linear border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
{error}
</div>
)}
{success && (
<div className="rounded-linear border border-success-500/30 bg-success-500/10 p-4 text-sm text-success-400">
{success}
</div>
)}
<Button type="submit" fullWidth loading={registerEmailMutation.isPending}>
{t('profile.linkEmail')}
</Button>
</form>
<div className="space-y-3">
<p className="text-sm text-dark-400">{t('profile.linkEmailDescription')}</p>
<Button variant="primary" onClick={() => navigate('/profile/accounts')}>
{t('profile.linkEmail')}
</Button>
</div>
)}