mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: move email linking form into Connected Accounts page
Users couldn't find the email linking form buried in the Profile page. Now the Email row in Connected Accounts shows a "Привязать" button (consistent with Google/Yandex/Discord) that expands an inline form with email, password, and confirm password fields. - Added email to isLinkableProvider check - Inline AnimatePresence form expands under Email card (no popups) - Removed duplicate registration form from Profile page - Profile page now shows a redirect button to Connected Accounts for users without email (email change/verify stays in Profile)
This commit is contained in:
@@ -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', 'Please enter a valid email address'));
|
||||
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,78 @@ export default function ConnectedAccounts() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline email linking form */}
|
||||
{provider.provider === 'email' && !provider.linked && (
|
||||
<AnimatePresence>
|
||||
{emailFormOpen && (
|
||||
<motion.div
|
||||
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 className="label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={emailValue}
|
||||
onChange={(e) => setEmailValue(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="input"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">{t('auth.password')}</label>
|
||||
<input
|
||||
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 className="label">{t('auth.confirmPassword')}</label>
|
||||
<input
|
||||
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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user