mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user