diff --git a/api/app/controllers/users_router.py b/api/app/controllers/users_router.py index f6d2048..7825c0a 100644 --- a/api/app/controllers/users_router.py +++ b/api/app/controllers/users_router.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Response from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_db -from app.domain.entities.users import UserRead +from app.domain.entities.users import UserRead, UserUpdate, PasswordChangeRequest from app.domain.models import User from app.infrastructure.dependencies import require_auth_user from app.infrastructure.users_service import UsersService @@ -24,3 +24,33 @@ async def get_authenticated_user_data( ): users_service = UsersService(db) return await users_service.get_by_id(user.id) + +@users_router.put( + '/{user_id}/', + response_model=Optional[UserRead], + summary='Updates user data', + description='Updates user data', +) +async def update_user( + user_id: int, + user_data: UserUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user) +): + users_service = UsersService(db) + return await users_service.update(user_id, user_data, user) + +@users_router.post( + '/change-password/{user_id}/', + response_model=Optional[UserRead], + summary='Updates user data', + description='Updates user data', +) +async def change_password( + user_id: int, + password_data: PasswordChangeRequest, + db: AsyncSession = Depends(get_db), + user: User = Depends(require_auth_user) +): + users_service = UsersService(db) + return await users_service.change_password(user_id, password_data, user) diff --git a/api/app/domain/entities/users.py b/api/app/domain/entities/users.py index 4603040..1fd5c0c 100644 --- a/api/app/domain/entities/users.py +++ b/api/app/domain/entities/users.py @@ -22,6 +22,19 @@ class UserCreate(UserRegister): role_id: int = Field() +class UserUpdate(BaseModel): + first_name: str = Field(max_length=250) + last_name: str = Field(max_length=250) + patronymic: Optional[str] = Field(default=None, max_length=250) + email: Optional[EmailStr] = None + birthdate: date + + +class PasswordChangeRequest(BaseModel): + password: str + repeat_password: str + + class UserRead(BaseModel): id: int first_name: str diff --git a/api/app/infrastructure/users_service.py b/api/app/infrastructure/users_service.py index d476ba5..841b27c 100644 --- a/api/app/infrastructure/users_service.py +++ b/api/app/infrastructure/users_service.py @@ -1,13 +1,19 @@ +from typing import Optional + from fastapi import HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.application.users_repository import UsersRepository -from app.domain.entities.users import UserRead +from app.domain.entities.users import UserRead, UserUpdate, PasswordChangeRequest +from app.domain.models import User +from app.infrastructure.register_service import RegisterService +from app.settings import Settings class UsersService: def __init__(self, db: AsyncSession): self.users_repository = UsersRepository(db) + self.settings = Settings() async def get_by_id(self, user_id: int) -> UserRead: user = await self.users_repository.get_by_id(user_id) @@ -18,3 +24,61 @@ class UsersService: ) return UserRead.model_validate(user) + + async def update(self, user_id: int, user: UserUpdate, current_user: User) -> Optional[UserRead]: + user_model = await self.users_repository.get_by_id(user_id) + if not user_model: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Пользователь не найден', + ) + + if current_user.id != user_model.id and not current_user.role.title != self.settings.root_role_name: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Доступ запрещен', + ) + + user_model.first_name = user.first_name + user_model.last_name = user.last_name + user_model.patronymic = user.patronymic + user_model.email = user.email + user_model.birthdate = user.birthdate + + user_model = await self.users_repository.update(user_model) + + return UserRead.model_validate(user_model) + + async def change_password( + self, user_id: int, password_data: PasswordChangeRequest, current_user: User + ) -> Optional[UserRead]: + user_model = await self.users_repository.get_by_id(user_id) + if not user_model: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Пользователь не найден', + ) + + if current_user.id != user_model.id and not current_user.role.title != self.settings.root_role_name: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Доступ запрещен', + ) + + if password_data.password != password_data.repeat_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Пароли не совпадают', + ) + + if not RegisterService.is_strong_password(password_data.repeat_password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Пароль слишком слабый. Пароль должен содержать не менее 8 символов, включая хотя бы одну букву и одну цифру и один специальный символ.' + ) + + user_model.set_password(password_data.password) + + user_model = await self.users_repository.update(user_model) + + return UserRead.model_validate(user_model) diff --git a/web/src/Api/usersApi.js b/web/src/Api/usersApi.js index 3a28a59..a7882da 100644 --- a/web/src/Api/usersApi.js +++ b/web/src/Api/usersApi.js @@ -10,7 +10,27 @@ export const usersApi = createApi({ getAuthenticatedUserData: builder.query({ query: () => "/users/me/", }), + updateUser: builder.mutation({ + query: ({userId, ...data}) => ({ + url: `/users/${userId}/`, + method: "PUT", + body: data, + }), + invalidatesTags: ["user"], + }), + updateUserPassword: builder.mutation({ + query: ({userId, ...data}) => ({ + url: `/users/change-password/${userId}/`, + method: "POST", + body: data, + }), + invalidatesTags: ["user"], + }), }), }); -export const {useGetAuthenticatedUserDataQuery} = usersApi; \ No newline at end of file +export const { + useGetAuthenticatedUserDataQuery, + useUpdateUserMutation, + useUpdateUserPasswordMutation, +} = usersApi; \ No newline at end of file diff --git a/web/src/App/AdminRoute.jsx b/web/src/App/AdminRoute.jsx index 063670f..c7c858a 100644 --- a/web/src/App/AdminRoute.jsx +++ b/web/src/App/AdminRoute.jsx @@ -24,7 +24,7 @@ const AdminRoute = () => { return ; } - if (!user.role || user.role.title !== "Администратор") { + if (!user.role || user.role.title !== "root") { return ; } diff --git a/web/src/App/AppRouter.jsx b/web/src/App/AppRouter.jsx index bf73a5a..478da4c 100644 --- a/web/src/App/AppRouter.jsx +++ b/web/src/App/AppRouter.jsx @@ -4,6 +4,7 @@ import AdminRoute from "./AdminRoute.jsx"; import LoginPage from "../Components/Pages/LoginPage/LoginPage.jsx"; import CoursesPage from "../Components/Pages/Courses/CoursesPage.jsx"; import MainLayout from "../Components/Layouts/MainLayout.jsx"; +import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx"; const AppRouter = () => ( @@ -12,7 +13,9 @@ const AppRouter = () => ( }> }> - }/> + }/> + }/> + }/> diff --git a/web/src/Components/Layouts/MainLayout.jsx b/web/src/Components/Layouts/MainLayout.jsx index 2c43cc1..3ea8d7d 100644 --- a/web/src/Components/Layouts/MainLayout.jsx +++ b/web/src/Components/Layouts/MainLayout.jsx @@ -21,13 +21,12 @@ const MainLayout = () => { } = useMainLayout(); const menuItems = [ - getItem("Мои курсы", "/", ), + getItem("Мои курсы", "/courses", ), getItem("Профиль", "/profile", ), getItem("Выйти", "logout", ), {type: "divider"} ]; - return ( { - ) }; diff --git a/web/src/Components/Pages/LoginPage/useLoginPage.js b/web/src/Components/Pages/LoginPage/useLoginPage.js index 9259a2c..1549f6e 100644 --- a/web/src/Components/Pages/LoginPage/useLoginPage.js +++ b/web/src/Components/Pages/LoginPage/useLoginPage.js @@ -14,7 +14,7 @@ const useLoginPage = () => { const hasRedirected = useRef(false); const pageContainerStyle = { - paddingTop: screen.xs ? "100px" : "200px" + paddingTop: screen.xs ? "100px" : "200px", }; useEffect(() => { diff --git a/web/src/Components/Pages/ProfilePage/ProfilePage.jsx b/web/src/Components/Pages/ProfilePage/ProfilePage.jsx new file mode 100644 index 0000000..58121f5 --- /dev/null +++ b/web/src/Components/Pages/ProfilePage/ProfilePage.jsx @@ -0,0 +1,117 @@ +import useProfilePage from "./useProfilePage.js"; +import {Button, Col, DatePicker, Divider, Form, Input, Result, Row, Typography} from "antd"; +import {UserOutlined} from "@ant-design/icons"; +import dayjs from "dayjs"; +import LoadingIndicator from "../../Widgets/LoadingIndicator/LoadingIndicator.jsx"; + +const {Title} = Typography; + +const ProfilePage = () => { + const { + userForm, + passwordForm, + userData, + isLoading, + isError, + isUpdateUserLoading, + isUpdateUserError, + onFinishProfileForm, + onFinishPasswordForm + } = useProfilePage(); + + if (isError) { + return ( + + ); + } + + if (isLoading) { + return ; + } + + return ( + + + + <UserOutlined/> Управление профилем + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + Смена пароля +
+ + + + + + + + + +
+ + ); +}; + +export default ProfilePage; \ No newline at end of file diff --git a/web/src/Components/Pages/ProfilePage/useProfilePage.js b/web/src/Components/Pages/ProfilePage/useProfilePage.js new file mode 100644 index 0000000..9fe83fa --- /dev/null +++ b/web/src/Components/Pages/ProfilePage/useProfilePage.js @@ -0,0 +1,117 @@ +import {useEffect} from "react"; +import { + useGetAuthenticatedUserDataQuery, + useUpdateUserMutation, + useUpdateUserPasswordMutation +} from "../../../Api/usersApi.js"; +import {Form, notification} from "antd"; +import dayjs from "dayjs"; + + +const useProfilePage = () => { + const [userForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); + + useEffect(() => { + window.document.title = "Профиль"; + }, []); + + const { + data: userData = {}, + isLoading: isUserDataLoading, + isError: isUserDataError, + } = useGetAuthenticatedUserDataQuery(undefined, { + pollingInterval: 20000, + }); + + const [ + updateUser, + { + isLoading: isUpdateUserLoading, + isError: isUpdateUserError, + } + ] = useUpdateUserMutation(); + + const [ + updateUserPassword, + { + isLoading: isUpdateUserPasswordLoading, + isError: isUpdateUserPasswordError, + } + ] = useUpdateUserPasswordMutation(); + + + useEffect(() => { + if (userData && Object.keys(userData).length > 0) { + const formattedValues = { + ...userData, + birthdate: userData.birthdate ? dayjs(userData.birthdate) : null, + }; + userForm.setFieldsValue(formattedValues); + } + }, [userData, userForm]); + + const onFinishProfileForm = async () => { + const values = userForm.getFieldsValue(); + const payload = { + ...values, + birthdate: values.birthdate + ? values.birthdate.format("YYYY-MM-DD") + : null, + }; + + try { + await updateUser({userId: userData.id, ...payload}).unwrap(); + + notification.success({ + title: "Успешно", + description: "Данные успешно обновлены", + placement: "topRight", + }); + } catch (error) { + notification.error({ + title: "Ошибка", + description: error?.data?.detail || "Не удалось обновить профиль.", + placement: "topRight", + }); + } + }; + + const onFinishPasswordForm = async () => { + const values = passwordForm.getFieldsValue(); + const payload = { + ...values, + }; + + try { + await updateUserPassword({userId: userData.id, ...payload}).unwrap(); + passwordForm.resetFields(); + + notification.success({ + title: "Успешно", + description: "Пароль успешно обновлен", + placement: "topRight", + }); + } catch (error) { + notification.error({ + title: "Ошибка", + description: error?.data?.detail || "Не удалось обновить пароль.", + placement: "topRight", + }); + } + }; + + return { + userForm, + passwordForm, + userData, + isLoading: isUserDataLoading, + isError: isUserDataError, + isUpdateUserLoading, + isUpdateUserError, + onFinishProfileForm, + onFinishPasswordForm, + } +}; + +export default useProfilePage; \ No newline at end of file