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:
@@ -17,7 +17,14 @@ from app.core.security import (
|
|||||||
)
|
)
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
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__)
|
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)
|
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)
|
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def resend_verification(
|
async def resend_verification(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|||||||
@@ -18,6 +18,20 @@ class LoginRequest(BaseModel):
|
|||||||
password: str
|
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):
|
class UserInToken(BaseModel):
|
||||||
"""Минимальная информация о пользователе в JWT токене."""
|
"""Минимальная информация о пользователе в JWT токене."""
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export const authApi = {
|
|||||||
|
|
||||||
me: () => api.get('/auth/me'),
|
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) =>
|
verifyEmail: (token: string) =>
|
||||||
api.post(`/auth/verify-email/${token}`),
|
api.post(`/auth/verify-email/${token}`),
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Search } from './pages/Search';
|
|||||||
import { Check } from './pages/Check';
|
import { Check } from './pages/Check';
|
||||||
import { Bibliography } from './pages/Bibliography';
|
import { Bibliography } from './pages/Bibliography';
|
||||||
import { Cabinet } from './pages/Cabinet';
|
import { Cabinet } from './pages/Cabinet';
|
||||||
|
import { Settings } from './pages/Settings';
|
||||||
import { Task } from './pages/Task';
|
import { Task } from './pages/Task';
|
||||||
import { Pricing } from './pages/Pricing';
|
import { Pricing } from './pages/Pricing';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
@@ -45,6 +46,7 @@ function PublicApp() {
|
|||||||
<Route path="/check" element={<Check />} />
|
<Route path="/check" element={<Check />} />
|
||||||
<Route path="/bibliography" element={<Bibliography />} />
|
<Route path="/bibliography" element={<Bibliography />} />
|
||||||
<Route path="/cabinet" element={<Cabinet />} />
|
<Route path="/cabinet" element={<Cabinet />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/tasks/:taskId" element={<Task />} />
|
<Route path="/tasks/:taskId" element={<Task />} />
|
||||||
<Route path="/pricing" element={<Pricing />} />
|
<Route path="/pricing" element={<Pricing />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<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 { 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 toast from 'react-hot-toast';
|
||||||
import { TaskCard } from '../components/TaskCard';
|
import { TaskCard } from '../components/TaskCard';
|
||||||
import { tasksApi, adminApi } from '../api/client';
|
import { tasksApi, adminApi } from '../api/client';
|
||||||
@@ -60,6 +60,13 @@ export function Cabinet() {
|
|||||||
<p className="text-gray-500 text-sm">{user?.email}</p>
|
<p className="text-gray-500 text-sm">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{user?.is_admin && (
|
||||||
<button
|
<button
|
||||||
onClick={openAdmin}
|
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