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:
jze9
2026-07-27 13:22:05 +05:00
parent 3af79e9d03
commit 5cbc1e127b
6 changed files with 366 additions and 3 deletions

View File

@@ -17,7 +17,14 @@ from app.core.security import (
)
from app.database import get_db
from app.models.user import User
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, UserInToken
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
UpdateProfileRequest,
UserInToken,
)
logger = logging.getLogger(__name__)
@@ -99,6 +106,96 @@ async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken:
return UserInToken.model_validate(current_user)
@router.patch("/me", response_model=UserInToken)
async def update_profile(
data: UpdateProfileRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> UserInToken:
"""
Изменить персональные данные (имя и/или email).
Смена email сбрасывает подтверждение и отправляет новое письмо верификации.
"""
# Загружаем "живого" пользователя из БД: объект из Redis-кэша не привязан
# к сессии и не содержит части полей.
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
email_changed = False
if data.name is not None:
user.name = data.name
if data.email is not None:
new_email = data.email.lower()
if new_email != user.email:
existing = await db.execute(
select(User).where(User.email == new_email, User.id != user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Этот email уже используется другим аккаунтом",
)
user.email = new_email
user.is_verified = False
user.verification_token = secrets.token_urlsafe(32)
email_changed = True
await db.commit()
await db.refresh(user)
await invalidate_user_cache(user.id)
# При смене email — отправить новое письмо подтверждения на новый адрес
if email_changed:
try:
celery_app.send_task(
"notify.send_verification",
args=[user.email, user.name, user.verification_token],
queue="queue.notify",
)
except Exception as e:
logger.warning(f"Не удалось отправить письмо верификации при смене email: {e}")
return UserInToken.model_validate(user)
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
async def change_password(
data: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Сменить пароль. Требует текущий пароль для подтверждения."""
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден"
)
if not verify_password(data.current_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Текущий пароль указан неверно",
)
if verify_password(data.new_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль совпадает с текущим",
)
user.hashed_password = hash_password(data.new_password)
await db.commit()
await invalidate_user_cache(user.id)
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
async def resend_verification(
current_user: User = Depends(get_current_user),

View File

@@ -18,6 +18,20 @@ class LoginRequest(BaseModel):
password: str
class UpdateProfileRequest(BaseModel):
"""Изменение персональных данных (имя и/или email)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
email: EmailStr | None = None
class ChangePasswordRequest(BaseModel):
"""Смена пароля: нужен текущий пароль для подтверждения."""
current_password: str
new_password: str = Field(min_length=8, max_length=128)
class UserInToken(BaseModel):
"""Минимальная информация о пользователе в JWT токене."""

View File

@@ -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}`),

View File

@@ -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 />} />

View File

@@ -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}

View 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>
);
}