diff --git a/services/api/app/api/auth.py b/services/api/app/api/auth.py
index e79fcd9..b54cfc1 100644
--- a/services/api/app/api/auth.py
+++ b/services/api/app/api/auth.py
@@ -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),
diff --git a/services/api/app/schemas/auth.py b/services/api/app/schemas/auth.py
index a2a762f..61b4cae 100644
--- a/services/api/app/schemas/auth.py
+++ b/services/api/app/schemas/auth.py
@@ -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 токене."""
diff --git a/services/frontend/src/api/client.ts b/services/frontend/src/api/client.ts
index 261e3aa..b0b41ac 100644
--- a/services/frontend/src/api/client.ts
+++ b/services/frontend/src/api/client.ts
@@ -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}`),
diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx
index d13083f..16b7134 100644
--- a/services/frontend/src/main.tsx
+++ b/services/frontend/src/main.tsx
@@ -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() {
{user?.email}
Персональные данные и безопасность
+