feat(auth): подтверждение email + актуализация SMTP/Ollama конфигов
- Резенд письма верификации (/auth/resend-verification), модалка на фронте с поллингом статуса, страница /verify-email/:token - SMTP переведён на собственный Postfix (mail.jze9mail.ru, STARTTLS, SMTP_TLS_VERIFY) вместо Yandex-заглушки в дефолтах и .env.example - OLLAMA_URL и модель в worker-gpu синхронизированы с новым GPU-хостом (llama3:8b -> qwen2.5:7b, которой раньше не было на сервере) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,9 @@ export const authApi = {
|
||||
|
||||
verifyEmail: (token: string) =>
|
||||
api.post(`/auth/verify-email/${token}`),
|
||||
|
||||
resendVerification: () =>
|
||||
api.post('/auth/resend-verification'),
|
||||
};
|
||||
|
||||
export const tasksApi = {
|
||||
|
||||
126
services/frontend/src/components/EmailVerificationModal.tsx
Normal file
126
services/frontend/src/components/EmailVerificationModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Mail, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
export function EmailVerificationModal() {
|
||||
const { user, updateUser } = useAuthStore();
|
||||
const location = useLocation();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
// Не показывать на странице подтверждения — там своя UI
|
||||
if (location.pathname.startsWith('/verify-email')) return null;
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => authApi.resendVerification(),
|
||||
onSuccess: () => toast.success('Письмо отправлено, проверьте почту'),
|
||||
onError: () => toast.error('Не удалось отправить письмо'),
|
||||
});
|
||||
|
||||
useQuery({
|
||||
queryKey: ['email-verification-poll'],
|
||||
queryFn: async () => {
|
||||
const res = await authApi.me();
|
||||
if (res.data.is_verified) {
|
||||
updateUser({ is_verified: true });
|
||||
setVerified(true);
|
||||
}
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!(user && !user.is_verified && !dismissed && !verified),
|
||||
refetchInterval: 5000,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
if (!user || dismissed) return null;
|
||||
if (!user.is_verified && !verified) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="p-4 bg-amber-50 rounded-full">
|
||||
<Mail className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 text-center mb-1">
|
||||
Подтвердите ваш email
|
||||
</h2>
|
||||
<p className="text-center text-sm text-gray-400 mb-6">{user.email}</p>
|
||||
|
||||
{/* Статус */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6 px-4 py-3 bg-amber-50 rounded-xl">
|
||||
<AlertCircle className="w-4 h-4 text-amber-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-amber-700">Email не подтверждён</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 text-center mb-6 leading-relaxed">
|
||||
Мы отправили письмо на{' '}
|
||||
<span className="font-medium text-gray-800">{user.email}</span>.
|
||||
Нажмите кнопку «Подтвердить» в письме для активации аккаунта.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => resend.mutate()}
|
||||
disabled={resend.isPending}
|
||||
className="w-full py-3 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{resend.isPending ? 'Отправляем...' : 'Подтвердить'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="w-full mt-3 py-2.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
Напомнить позже
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (verified) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8 text-center">
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="p-4 bg-green-50 rounded-full">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Email подтверждён!</h2>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 mb-6 px-4 py-3 bg-green-50 rounded-xl">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-green-700">Email подтверждён</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Аккаунт активирован. Теперь вы получаете уведомления о завершении задач.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="w-full py-3 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { EmailVerificationModal } from './EmailVerificationModal';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -144,6 +145,8 @@ export function Layout({ children }: LayoutProps) {
|
||||
<main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<EmailVerificationModal />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Task } from './pages/Task';
|
||||
import { Pricing } from './pages/Pricing';
|
||||
import { Login } from './pages/Login';
|
||||
import { Register } from './pages/Register';
|
||||
import { VerifyEmail } from './pages/VerifyEmail';
|
||||
import { AdminLayout } from './pages/admin/AdminLayout';
|
||||
import { Dashboard } from './pages/admin/Dashboard';
|
||||
import { Users as AdminUsers } from './pages/admin/Users';
|
||||
@@ -48,6 +49,7 @@ function PublicApp() {
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email/:token" element={<VerifyEmail />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
83
services/frontend/src/pages/VerifyEmail.tsx
Normal file
83
services/frontend/src/pages/VerifyEmail.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
|
||||
import { authApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
export function VerifyEmail() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { updateUser } = useAuthStore();
|
||||
|
||||
const verify = useMutation({
|
||||
mutationFn: () => authApi.verifyEmail(token!),
|
||||
onSuccess: () => {
|
||||
updateUser({ is_verified: true });
|
||||
setTimeout(() => navigate('/cabinet'), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (token) verify.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto pt-16 text-center">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 p-10">
|
||||
{verify.isPending && (
|
||||
<>
|
||||
<div className="flex justify-center mb-5">
|
||||
<Loader2 className="w-14 h-14 text-brand-600 animate-spin" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-2">Подтверждаем email…</h1>
|
||||
<p className="text-sm text-gray-500">Пожалуйста, подождите</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{verify.isSuccess && (
|
||||
<>
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="p-4 bg-green-50 rounded-full">
|
||||
<CheckCircle2 className="w-14 h-14 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Email подтверждён!</h1>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-50 rounded-xl mb-4">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm font-medium text-green-700">Email подтверждён</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Аккаунт активирован. Перенаправляем в личный кабинет…
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{verify.isError && (
|
||||
<>
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="p-4 bg-red-50 rounded-full">
|
||||
<XCircle className="w-14 h-14 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ошибка подтверждения</h1>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-red-50 rounded-xl mb-4">
|
||||
<XCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">Email не подтверждён</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Ссылка недействительна или устарела. Запросите новое письмо в личном кабинете.
|
||||
</p>
|
||||
<Link
|
||||
to="/cabinet"
|
||||
className="inline-block px-6 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
В личный кабинет
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user