diff --git a/api/app/application/users_repository.py b/api/app/application/users_repository.py index 18d6696..ccf3115 100644 --- a/api/app/application/users_repository.py +++ b/api/app/application/users_repository.py @@ -44,3 +44,8 @@ class UsersRepository: await self.db.commit() await self.db.refresh(user) return user + + async def update(self, user: User) -> User: + await self.db.merge(user) + await self.db.commit() + return user diff --git a/api/app/controllers/users_router.py b/api/app/controllers/users_router.py new file mode 100644 index 0000000..9c90f5f --- /dev/null +++ b/api/app/controllers/users_router.py @@ -0,0 +1,41 @@ +from typing import Optional + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.session import get_db +from app.domain.entities.change_password import ChangePasswordEntity +from app.domain.entities.user import UserEntity +from app.infrastructure.dependencies import get_current_user +from app.infrastructure.users_service import UsersService + +router = APIRouter() + + +@router.get( + '/my-data/', + response_model=Optional[UserEntity], + summary='Returns current authenticated user data', + description='Returns current authenticated user data', +) +async def get_authenticated_user_data( + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + users_service = UsersService(db) + return await users_service.get_by_id(user.id) + + +@router.get( + '/change-password/', + response_model=Optional[UserEntity], + summary='Change password for user', + description='Changes password for user', +) +async def get_authenticated_user_data( + data: ChangePasswordEntity, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + users_service = UsersService(db) + return await users_service.get_by_id(data.user_id, data.new_password, user.id) diff --git a/api/app/domain/entities/change_password.py b/api/app/domain/entities/change_password.py new file mode 100644 index 0000000..cc69e43 --- /dev/null +++ b/api/app/domain/entities/change_password.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class ChangePasswordEntity(BaseModel): + user_id: int + new_password: str diff --git a/api/app/infrastructure/users_service.py b/api/app/infrastructure/users_service.py index 3003c3d..40e91c2 100644 --- a/api/app/infrastructure/users_service.py +++ b/api/app/infrastructure/users_service.py @@ -16,6 +16,43 @@ class UsersService: self.users_repository = UsersRepository(db) self.roles_repository = RolesRepository(db) + async def get_by_id(self, user_id: int) -> Optional[UserEntity]: + user = await self.users_repository.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='User was not found', + ) + + return self.model_to_entity(user) + + async def change_password(self, user_id: int, new_password: str, current_user_id: int) -> Optional[UserEntity]: + user = await self.users_repository.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='User was not found', + ) + + current_user = await self.users_repository.get_by_id(current_user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='User was not found', + ) + + if user.id != current_user.id and current_user.role.title != 'Администратор': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Permission denied', + ) + + user.set_password(new_password) + + user = await self.users_repository.update(user) + + return self.model_to_entity(user) + async def register_user(self, register_entity: RegisterEntity) -> Optional[UserEntity]: role = await self.roles_repository.get_by_id(register_entity.role_id) if not role: diff --git a/api/app/main.py b/api/app/main.py index 744f8ff..41f4d50 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -12,6 +12,7 @@ from app.controllers.register_routes import router as register_router from app.controllers.scheduled_appointments_router import router as scheduled_appointments_router from app.controllers.set_content_router import router as set_content_router from app.controllers.sets_router import router as sets_router +from app.controllers.users_router import router as users_router from app.settings import settings @@ -37,6 +38,7 @@ def start_app(): api_app.include_router(scheduled_appointments_router, prefix=f'{settings.APP_PREFIX}/scheduled_appointments', tags=['scheduled_appointments']) api_app.include_router(set_content_router, prefix=f'{settings.APP_PREFIX}/set_content', tags=['set_content']) api_app.include_router(sets_router, prefix=f'{settings.APP_PREFIX}/sets', tags=['sets']) + api_app.include_router(users_router, prefix=f'{settings.APP_PREFIX}/users', tags=['users']) return api_app diff --git a/web-app/src/Api/usersApi.js b/web-app/src/Api/usersApi.js new file mode 100644 index 0000000..56b9f04 --- /dev/null +++ b/web-app/src/Api/usersApi.js @@ -0,0 +1,32 @@ +import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react"; +import CONFIG from "../Core/сonfig.js"; + + +export const usersApi = createApi({ + reducerPath: 'usersApi', + baseQuery: fetchBaseQuery({ + baseUrl: CONFIG.BASE_URL, + prepareHeaders: (headers) => { + const token = localStorage.getItem('access_token'); + if (token) headers.set('Authorization', `Bearer ${token}`); + return headers; + }, + }), + tagTypes: ['User'], + endpoints: (builder) => ({ + getAuthenticatedUserData: builder.query({ + query: () => '/users/my-data/', + providesTags: ['User'], + refetchOnMountOrArgChange: 5, + }), + changePassword: builder.mutation({ + query: (data) => ({ + url: "/users/change-password", + method: "POST", + body: data, + }), + }), + }), +}); + +export const {useGetAuthenticatedUserDataQuery} = usersApi; \ No newline at end of file diff --git a/web-app/src/App/AppRouter.jsx b/web-app/src/App/AppRouter.jsx index 2bb673b..5572421 100644 --- a/web-app/src/App/AppRouter.jsx +++ b/web-app/src/App/AppRouter.jsx @@ -7,6 +7,7 @@ import HomePage from "../Components/Pages/HomePage/HomePage.jsx"; import LensesSetsPage from "../Components/Pages/LensesSetsPage/LensesSetsPage.jsx"; import IssuesPage from "../Components/Pages/IssuesPage/IssuesPage.jsx"; import AppointmentsPage from "../Components/Pages/AppointmentsPage/AppointmentsPage.jsx"; +import ProfilePage from "../Components/Pages/ProfilePage/ProfilePage.jsx"; const AppRouter = () => ( @@ -19,6 +20,7 @@ const AppRouter = () => ( }/> }/> }/> + }/> }/> diff --git a/web-app/src/Components/Pages/ProfilePage/ProfilePage.jsx b/web-app/src/Components/Pages/ProfilePage/ProfilePage.jsx new file mode 100644 index 0000000..ef19099 --- /dev/null +++ b/web-app/src/Components/Pages/ProfilePage/ProfilePage.jsx @@ -0,0 +1,133 @@ +import { Button, Card, Col, Form, Input, Modal, Row, Space, Typography, Result } from "antd"; +import { EditOutlined } from "@ant-design/icons"; +import LoadingIndicator from "../../Widgets/LoadingIndicator.jsx"; +import useProfilePage from "./useProfilePage.js"; +import useProfilePageUI from "./useProfilePageUI.js"; +import { useSelector } from "react-redux"; + +const ProfilePage = () => { + const { userData, isLoading, isError, isUpdating, handleEditProfile, handleCancelEdit, handleSubmitProfile, handleSubmitPassword } = useProfilePage(); + const { containerStyle, cardStyle, buttonStyle, formStyle, profileFormRules, passwordFormRules, isMobile } = useProfilePageUI(); + const editProfileModalVisible = useSelector((state) => state.usersUI.editProfileModalVisible); + const [profileForm] = Form.useForm(); + const [passwordForm] = Form.useForm(); + + if (isError) { + return ( + + ); + } + + return ( +
+ {isLoading ? ( + + ) : ( + <> + Профиль + + + + Фамилия: + {userData.last_name || "-"} + + + Имя: + {userData.first_name || "-"} + + + Отчество: + {userData.patronymic || "-"} + + + Логин: + {userData.login || "-"} + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ +
+ Смена пароля + + + + + + + + + + + + + + + +
+
+ + )} +
+ ); +}; + +export default ProfilePage; \ No newline at end of file diff --git a/web-app/src/Components/Pages/ProfilePage/useProfilePage.js b/web-app/src/Components/Pages/ProfilePage/useProfilePage.js new file mode 100644 index 0000000..24f5f1d --- /dev/null +++ b/web-app/src/Components/Pages/ProfilePage/useProfilePage.js @@ -0,0 +1,92 @@ +import { useGetAuthenticatedUserDataQuery } from "../../../Api/usersApi.js"; +import { useDispatch } from "react-redux"; +import { openEditProfileModal, closeEditProfileModal } from "../../../Redux/Slices/usersSlice.js"; +import { notification } from "antd"; + +const useProfilePage = () => { + const dispatch = useDispatch(); + + const { + data: userData = {}, + isLoading: isLoadingUserData, + isError: isErrorUserData, + } = useGetAuthenticatedUserDataQuery(undefined, { + pollingInterval: 20000, + }); + + // const [updateUserProfile, { isLoading: isUpdatingProfile }] = useUpdateUserProfileMutation(); + const updateUserProfile = () => {}; + const isUpdatingProfile = false; + const isErrorUpdatingProfile = false; + + // const [changePassword, { isLoading: isChangingPassword }] = useChangePasswordMutation(); + const changePassword = () => {}; + const isChangingPassword = false; + + const handleEditProfile = () => { + dispatch(openEditProfileModal()); + }; + + const handleCancelEdit = () => { + dispatch(closeEditProfileModal()); + }; + + const handleSubmitProfile = async (values) => { + try { + const profileData = { + first_name: values.first_name, + last_name: values.last_name, + patronymic: values.patronymic || null, + }; + await updateUserProfile(profileData).unwrap(); + notification.success({ + message: "Успех", + description: "Профиль успешно обновлен.", + placement: "topRight", + }); + dispatch(closeEditProfileModal()); + } catch (error) { + notification.error({ + message: "Ошибка", + description: error?.data?.message || "Не удалось обновить профиль.", + placement: "topRight", + }); + } + }; + + const handleSubmitPassword = async (values) => { + try { + const passwordData = { + current_password: values.current_password, + new_password: values.new_password, + confirm_password: values.confirm_password, + }; + await changePassword(passwordData).unwrap(); + notification.success({ + message: "Успех", + description: "Пароль успешно изменен.", + placement: "topRight", + }); + dispatch(closeEditProfileModal()); + } catch (error) { + notification.error({ + message: "Ошибка", + description: error?.data?.message || "Не удалось изменить пароль.", + placement: "topRight", + }); + } + }; + + return { + userData, + isLoading: isLoadingUserData, + isError: isErrorUserData, + isUpdating: isUpdatingProfile || isChangingPassword, + handleEditProfile, + handleCancelEdit, + handleSubmitProfile, + handleSubmitPassword, + }; +}; + +export default useProfilePage; \ No newline at end of file diff --git a/web-app/src/Components/Pages/ProfilePage/useProfilePageUI.js b/web-app/src/Components/Pages/ProfilePage/useProfilePageUI.js new file mode 100644 index 0000000..61c5eb3 --- /dev/null +++ b/web-app/src/Components/Pages/ProfilePage/useProfilePageUI.js @@ -0,0 +1,63 @@ +import { Grid } from "antd"; + +const { useBreakpoint } = Grid; + +const useProfilePageUI = () => { + const screens = useBreakpoint(); + + const containerStyle = { padding: screens.xs ? 16 : 24 }; + const cardStyle = { marginBottom: 24 }; + const buttonStyle = { width: screens.xs ? "100%" : "auto" }; + const formStyle = { maxWidth: 600 }; + + const profileFormRules = { + first_name: [ + { required: true, message: "Пожалуйста, введите имя" }, + { max: 50, message: "Имя не может превышать 50 символов" }, + ], + last_name: [ + { required: true, message: "Пожалуйста, введите фамилию" }, + { max: 50, message: "Фамилия не может превышать 50 символов" }, + ], + patronymic: [ + { max: 50, message: "Отчество не может превышать 50 символов" }, + ], + login: [ + { required: true, message: "Логин обязателен" }, + ], + }; + + const passwordFormRules = { + current_password: [ + { required: true, message: "Пожалуйста, введите текущий пароль" }, + { min: 8, message: "Пароль должен содержать минимум 8 символов" }, + ], + new_password: [ + { required: true, message: "Пожалуйста, введите новый пароль" }, + { min: 8, message: "Пароль должен содержать минимум 8 символов" }, + ], + confirm_password: [ + { required: true, message: "Пожалуйста, подтвердите новый пароль" }, + ({ getFieldValue }) => ({ + validator(_, value) { + if (!value || getFieldValue("new_password") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error("Пароли не совпадают")); + }, + }), + ], + }; + + return { + containerStyle, + cardStyle, + buttonStyle, + formStyle, + profileFormRules, + passwordFormRules, + isMobile: screens.xs, + }; +}; + +export default useProfilePageUI; \ No newline at end of file diff --git a/web-app/src/Redux/Slices/usersSlice.js b/web-app/src/Redux/Slices/usersSlice.js new file mode 100644 index 0000000..0d9c560 --- /dev/null +++ b/web-app/src/Redux/Slices/usersSlice.js @@ -0,0 +1,22 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const initialState = { + editProfileModalVisible: false, +}; + +const usersSlice = createSlice({ + name: "usersUI", + initialState, + reducers: { + openEditProfileModal(state) { + state.editProfileModalVisible = true; + }, + closeEditProfileModal(state) { + state.editProfileModalVisible = false; + }, + }, +}); + +export const { openEditProfileModal, closeEditProfileModal } = usersSlice.actions; + +export default usersSlice.reducer; \ No newline at end of file diff --git a/web-app/src/Redux/store.js b/web-app/src/Redux/store.js index 1da9bf6..ed47a43 100644 --- a/web-app/src/Redux/store.js +++ b/web-app/src/Redux/store.js @@ -13,6 +13,8 @@ import {appointmentsApi} from "../Api/appointmentsApi.js"; import appointmentsReducer from "./Slices/appointmentsSlice.js"; import {scheduledAppointmentsApi} from "../Api/scheduledAppointmentsApi.js"; import {appointmentTypesApi} from "../Api/appointmentTypesApi.js"; +import {usersApi} from "../Api/usersApi.js"; +import usersReducer from "./Slices/usersSlice.js"; export const store = configureStore({ reducer: { @@ -38,6 +40,9 @@ export const store = configureStore({ [scheduledAppointmentsApi.reducerPath]: scheduledAppointmentsApi.reducer, [appointmentTypesApi.reducerPath]: appointmentTypesApi.reducer, + + [usersApi.reducerPath]: usersApi.reducer, + usersUI: usersReducer }, middleware: (getDefaultMiddleware) => ( getDefaultMiddleware().concat( @@ -50,6 +55,7 @@ export const store = configureStore({ appointmentsApi.middleware, scheduledAppointmentsApi.middleware, appointmentTypesApi.middleware, + usersApi.middleware, ) ), });