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.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),
|
||||
|
||||
@@ -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 токене."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user