From 5cbc1e127beb6486dd8818e0fd447aa4c6ad9b8b Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 27 Jul 2026 13:22:05 +0500 Subject: [PATCH] =?UTF-8?q?feat(profile):=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд: - PATCH /auth/me — изменение имени и/или email. Смена email проверяет уникальность, сбрасывает is_verified и отправляет новое письмо подтверждения. Пользователь перезагружается из БД (объект из Redis-кэша не привязан к сессии), кэш инвалидируется после изменения. - POST /auth/change-password — смена пароля с подтверждением текущего; отклоняет неверный текущий и совпадение нового со старым. Фронтенд: - Страница /settings: карточка персональных данных (имя, email со статусом подтверждения и предупреждением о повторной верификации) и карточка смены пароля с проверкой совпадения. - Ссылка «Настройки» в карточке профиля кабинета, методы API-клиента. Co-Authored-By: Claude Opus 4.8 --- services/api/app/api/auth.py | 99 +++++++++- services/api/app/schemas/auth.py | 14 ++ services/frontend/src/api/client.ts | 6 + services/frontend/src/main.tsx | 2 + services/frontend/src/pages/Cabinet.tsx | 11 +- services/frontend/src/pages/Settings.tsx | 237 +++++++++++++++++++++++ 6 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 services/frontend/src/pages/Settings.tsx 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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/services/frontend/src/pages/Cabinet.tsx b/services/frontend/src/pages/Cabinet.tsx index 6ad3fc6..2eb5d6a 100644 --- a/services/frontend/src/pages/Cabinet.tsx +++ b/services/frontend/src/pages/Cabinet.tsx @@ -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() {

{user?.email}

+ + + Настройки + {user?.is_admin && ( +
+ + + {/* Смена пароля */} +
{ + e.preventDefault(); + if (canChangePassword) changePassword.mutate(); + }} + className="bg-white rounded-2xl border border-gray-100 p-6" + > +
+
+ +
+

Смена пароля

+
+ +
+
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + placeholder="••••••••" + className={inputClass} + /> +
+ +
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + minLength={8} + placeholder="Минимум 8 символов" + className={inputClass} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + placeholder="••••••••" + className={`${inputClass} ${passwordMismatch ? 'border-red-300 focus:ring-red-400' : ''}`} + /> + {passwordMismatch && ( +

Пароли не совпадают

+ )} +
+
+ +
+ +
+
+ + ); +}