Files
anti-plagiarism/services/frontend/src/pages/Cabinet.tsx
jze9 5cbc1e127b feat(profile): редактирование персональных данных и смена пароля
Бэкенд:
- 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>
2026-07-27 13:22:05 +05:00

161 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}