Бэкенд: - PATCH /auth/me — изменение имени и/или email. Смена email проверяет уникальность, сбрасывает is_verified и отправляет новое письмо подтверждения. Пользователь перезагружается из БД (объект из Redis-кэша не привязан к сессии), кэш инвалидируется после изменения. - POST /auth/change-password — смена пароля с подтверждением текущего; отклоняет неверный текущий и совпадение нового со старым. Фронтенд: - Страница /settings: карточка персональных данных (имя, email со статусом подтверждения и предупреждением о повторной верификации) и карточка смены пароля с проверкой совпадения. - Ссылка «Настройки» в карточке профиля кабинета, методы API-клиента. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
import { Navigate, useNavigate, Link } from 'react-router-dom';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Crown, Search, Shield, BookOpen, ShieldAlert, Settings as SettingsIcon } from 'lucide-react';
|
||
import toast from 'react-hot-toast';
|
||
import { TaskCard } from '../components/TaskCard';
|
||
import { tasksApi, adminApi } from '../api/client';
|
||
import { useAuthStore } from '../store/auth';
|
||
import { PLAN_LIMITS, type Task } from '../types';
|
||
|
||
const PLAN_BADGE_STYLES = {
|
||
free: 'bg-gray-100 text-gray-600',
|
||
student: 'bg-blue-100 text-blue-700',
|
||
premium: 'bg-purple-100 text-purple-700',
|
||
science: 'bg-amber-100 text-amber-700',
|
||
};
|
||
|
||
export function Cabinet() {
|
||
const { isAuthenticated, user } = useAuthStore();
|
||
const navigate = useNavigate();
|
||
|
||
const openAdmin = async () => {
|
||
try {
|
||
const { data } = await adminApi.openSession();
|
||
navigate(`${data.url}/dashboard`);
|
||
} catch {
|
||
toast.error('Не удалось открыть админ-панель');
|
||
}
|
||
};
|
||
|
||
if (!isAuthenticated) {
|
||
return <Navigate to="/login" replace />;
|
||
}
|
||
|
||
const { data: tasks, isLoading } = useQuery({
|
||
queryKey: ['tasks'],
|
||
queryFn: () => tasksApi.list().then((r) => r.data as Task[]),
|
||
refetchInterval: 10000,
|
||
});
|
||
|
||
const plan = user?.plan || 'free';
|
||
const planInfo = PLAN_LIMITS[plan as keyof typeof PLAN_LIMITS];
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
{/* Профиль */}
|
||
<div className="bg-white rounded-2xl border border-gray-100 p-6">
|
||
<div className="flex items-start gap-4">
|
||
<div className="w-16 h-16 bg-brand-100 rounded-2xl flex items-center justify-center flex-shrink-0">
|
||
<span className="text-2xl font-bold text-brand-700">
|
||
{user?.name?.[0]?.toUpperCase() || 'U'}
|
||
</span>
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<h2 className="text-xl font-semibold text-gray-900">{user?.name}</h2>
|
||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${PLAN_BADGE_STYLES[plan as keyof typeof PLAN_BADGE_STYLES]}`}>
|
||
{planInfo.name}
|
||
</span>
|
||
</div>
|
||
<p className="text-gray-500 text-sm">{user?.email}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Link
|
||
to="/settings"
|
||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||
>
|
||
<SettingsIcon className="w-4 h-4" />
|
||
Настройки
|
||
</Link>
|
||
{user?.is_admin && (
|
||
<button
|
||
onClick={openAdmin}
|
||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
|
||
>
|
||
<ShieldAlert className="w-4 h-4" />
|
||
Админ-панель
|
||
</button>
|
||
)}
|
||
<button className="px-4 py-2 text-sm font-medium bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||
Обновить план
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Лимиты */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||
{[
|
||
{
|
||
icon: Search,
|
||
label: 'Поисков/день',
|
||
value: planInfo.search_per_day,
|
||
color: 'text-blue-600',
|
||
bg: 'bg-blue-50',
|
||
},
|
||
{
|
||
icon: Shield,
|
||
label: 'Проверок/мес',
|
||
value: planInfo.plagiarism_per_month,
|
||
color: 'text-purple-600',
|
||
bg: 'bg-purple-50',
|
||
},
|
||
{
|
||
icon: BookOpen,
|
||
label: 'Изложений/мес',
|
||
value: planInfo.summarize_per_month,
|
||
color: 'text-emerald-600',
|
||
bg: 'bg-emerald-50',
|
||
},
|
||
{
|
||
icon: Crown,
|
||
label: 'Одновременно',
|
||
value: planInfo.concurrent,
|
||
color: 'text-amber-600',
|
||
bg: 'bg-amber-50',
|
||
},
|
||
].map(({ icon: Icon, label, value, color, bg }) => (
|
||
<div key={label} className="bg-white rounded-xl border border-gray-100 p-4">
|
||
<div className={`w-8 h-8 ${bg} rounded-lg flex items-center justify-center mb-2`}>
|
||
<Icon className={`w-4 h-4 ${color}`} />
|
||
</div>
|
||
<div className="text-xl font-bold text-gray-900">
|
||
{value === null ? '∞' : value}
|
||
</div>
|
||
<div className="text-xs text-gray-500">{label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Задачи */}
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||
Мои задачи {tasks && `(${tasks.length})`}
|
||
</h3>
|
||
|
||
{isLoading && (
|
||
<div className="space-y-3">
|
||
{[1, 2, 3].map((i) => (
|
||
<div key={i} className="h-20 bg-gray-100 rounded-xl animate-pulse" />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{tasks && tasks.length > 0 && (
|
||
<div className="space-y-2">
|
||
{tasks.map((task) => (
|
||
<TaskCard key={task.public_id} task={task} />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{tasks && tasks.length === 0 && (
|
||
<div className="text-center py-12 text-gray-400">
|
||
<p>Задач пока нет. Начните с поиска источников!</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|