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 (
+
+
+
+ Управление профилем
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Смена пароля
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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