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>
This commit is contained in:
@@ -38,6 +38,12 @@ export const authApi = {
|
||||
|
||||
me: () => api.get('/auth/me'),
|
||||
|
||||
updateProfile: (data: { name?: string; email?: string }) =>
|
||||
api.patch('/auth/me', data),
|
||||
|
||||
changePassword: (data: { current_password: string; new_password: string }) =>
|
||||
api.post('/auth/change-password', data),
|
||||
|
||||
verifyEmail: (token: string) =>
|
||||
api.post(`/auth/verify-email/${token}`),
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Search } from './pages/Search';
|
||||
import { Check } from './pages/Check';
|
||||
import { Bibliography } from './pages/Bibliography';
|
||||
import { Cabinet } from './pages/Cabinet';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Task } from './pages/Task';
|
||||
import { Pricing } from './pages/Pricing';
|
||||
import { Login } from './pages/Login';
|
||||
@@ -45,6 +46,7 @@ function PublicApp() {
|
||||
<Route path="/check" element={<Check />} />
|
||||
<Route path="/bibliography" element={<Bibliography />} />
|
||||
<Route path="/cabinet" element={<Cabinet />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/tasks/:taskId" element={<Task />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Crown, Search, Shield, BookOpen, ShieldAlert } from 'lucide-react';
|
||||
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';
|
||||
@@ -60,6 +60,13 @@ export function Cabinet() {
|
||||
<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}
|
||||
|
||||
237
services/frontend/src/pages/Settings.tsx
Normal file
237
services/frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Navigate, Link } from 'react-router-dom';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { ArrowLeft, User as UserIcon, Lock, Mail, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { authApi } from '../api/client';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import type { User } from '../types';
|
||||
|
||||
export function Settings() {
|
||||
const { isAuthenticated, user, updateUser } = useAuthStore();
|
||||
|
||||
// Персональные данные
|
||||
const [name, setName] = useState(user?.name ?? '');
|
||||
const [email, setEmail] = useState(user?.email ?? '');
|
||||
|
||||
// Смена пароля
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
|
||||
const emailChanged = email.trim().toLowerCase() !== (user?.email ?? '').toLowerCase();
|
||||
const profileDirty = name.trim() !== (user?.name ?? '') || emailChanged;
|
||||
|
||||
const saveProfile = useMutation({
|
||||
mutationFn: () => {
|
||||
const payload: { name?: string; email?: string } = {};
|
||||
if (name.trim() !== user?.name) payload.name = name.trim();
|
||||
if (emailChanged) payload.email = email.trim();
|
||||
return authApi.updateProfile(payload);
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
const updated: User = response.data;
|
||||
updateUser(updated);
|
||||
setEmail(updated.email);
|
||||
setName(updated.name);
|
||||
toast.success(
|
||||
emailChanged
|
||||
? 'Данные сохранены. Мы отправили письмо для подтверждения нового email.'
|
||||
: 'Данные сохранены'
|
||||
);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Не удалось сохранить данные');
|
||||
},
|
||||
});
|
||||
|
||||
const changePassword = useMutation({
|
||||
mutationFn: () =>
|
||||
authApi.changePassword({ current_password: currentPassword, new_password: newPassword }),
|
||||
onSuccess: () => {
|
||||
toast.success('Пароль изменён');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Не удалось сменить пароль');
|
||||
},
|
||||
});
|
||||
|
||||
const passwordMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||
const canChangePassword =
|
||||
currentPassword.length > 0 &&
|
||||
newPassword.length >= 8 &&
|
||||
newPassword === confirmPassword &&
|
||||
!changePassword.isPending;
|
||||
|
||||
const inputClass =
|
||||
'w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-shadow';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/cabinet"
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Настройки профиля</h1>
|
||||
<p className="text-sm text-gray-400">Персональные данные и безопасность</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Персональные данные */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (profileDirty) saveProfile.mutate();
|
||||
}}
|
||||
className="bg-white rounded-2xl border border-gray-100 p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
|
||||
<UserIcon className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-gray-900">Персональные данные</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Имя</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
placeholder="Иван Иванов"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Email</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-4 h-4 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="ivan@example.com"
|
||||
className={`${inputClass} pl-10`}
|
||||
/>
|
||||
</div>
|
||||
{/* Статус подтверждения / предупреждение о смене */}
|
||||
{emailChanged ? (
|
||||
<div className="flex items-start gap-2 mt-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
После сохранения потребуется подтвердить новый адрес — мы пришлём письмо со ссылкой.
|
||||
</span>
|
||||
</div>
|
||||
) : user?.is_verified ? (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-xs text-green-600">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
Email подтверждён
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-xs text-amber-600">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
Email не подтверждён
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profileDirty || saveProfile.isPending}
|
||||
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{saveProfile.isPending ? 'Сохраняем...' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Смена пароля */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (canChangePassword) changePassword.mutate();
|
||||
}}
|
||||
className="bg-white rounded-2xl border border-gray-100 p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<div className="w-9 h-9 bg-brand-50 rounded-lg flex items-center justify-center">
|
||||
<Lock className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-gray-900">Смена пароля</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Текущий пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">Новый пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
placeholder="Минимум 8 символов"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 block mb-1">
|
||||
Повторите новый пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
className={`${inputClass} ${passwordMismatch ? 'border-red-300 focus:ring-red-400' : ''}`}
|
||||
/>
|
||||
{passwordMismatch && (
|
||||
<p className="text-xs text-red-500 mt-1.5">Пароли не совпадают</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canChangePassword}
|
||||
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{changePassword.isPending ? 'Меняем...' : 'Изменить пароль'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user