diff --git a/web-app/src/Api/authApi.js b/web-app/src/Api/authApi.js index a5905ea..c8f5260 100644 --- a/web-app/src/Api/authApi.js +++ b/web-app/src/Api/authApi.js @@ -1,21 +1,41 @@ -import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react"; +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { logout } from "../Redux/Slices/authSlice.js"; import CONFIG from "../Core/сonfig.js"; +const baseQuery = fetchBaseQuery({ + baseUrl: CONFIG.BASE_URL, + prepareHeaders: (headers) => { + const token = localStorage.getItem("access_token"); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + headers.set("Content-Type", "application/json"); + return headers; + }, +}); + +const baseQueryWithAuth = async (args, api, extraOptions) => { + const result = await baseQuery(args, api, extraOptions); + if (result.error && result.error.status === 401) { + localStorage.removeItem("access_token"); + api.dispatch(logout()); + window.location.href = "/login"; + } + return result; +}; export const authApi = createApi({ - reducerPath: 'authApi', - baseQuery: fetchBaseQuery({ - baseUrl: CONFIG.BASE_URL, - }), + reducerPath: "authApi", + baseQuery: baseQueryWithAuth, endpoints: (builder) => ({ login: builder.mutation({ query: (credentials) => ({ - url: '/auth/login/', - method: 'POST', - body: credentials - }) + url: "/login/", + method: "POST", + body: credentials, + }), }), - }) + }), }); -export const {useLoginMutation} = authApi; \ No newline at end of file +export const { useLoginMutation } = authApi; \ No newline at end of file diff --git a/web-app/src/App/App.jsx b/web-app/src/App/App.jsx index 67d3d75..a33bec5 100644 --- a/web-app/src/App/App.jsx +++ b/web-app/src/App/App.jsx @@ -1,6 +1,5 @@ import {BrowserRouter as Router} from "react-router-dom"; import AppRouter from "./AppRouter.jsx"; -import {AuthProvider} from "../Hooks/AuthContext.jsx"; import "/src/Styles/app.css"; import {Provider} from "react-redux"; import store from "../Redux/store.js"; @@ -13,11 +12,9 @@ dayjs.locale('ru'); const App = () => ( - - - - - + + + ); diff --git a/web-app/src/App/ErrorBoundary.jsx b/web-app/src/App/ErrorBoundary.jsx new file mode 100644 index 0000000..42b463c --- /dev/null +++ b/web-app/src/App/ErrorBoundary.jsx @@ -0,0 +1,22 @@ +import { Component } from "react"; + +class ErrorBoundary extends Component { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error, info) { + console.error("ErrorBoundary caught:", error, info); + } + + render() { + if (this.state.hasError) { + return
Произошла ошибка
; + } + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/web-app/src/App/PrivateRoute.jsx b/web-app/src/App/PrivateRoute.jsx index db61459..e45c724 100644 --- a/web-app/src/App/PrivateRoute.jsx +++ b/web-app/src/App/PrivateRoute.jsx @@ -1,15 +1,14 @@ -import {Navigate, Outlet} from "react-router-dom"; -import {useAuth} from "../Hooks/AuthContext.jsx"; +import { Navigate, Outlet } from "react-router-dom"; +import { useSelector } from "react-redux"; const PrivateRoute = () => { - const {user} = useAuth(); + const { user } = useSelector((state) => state.auth); if (!user) { - return ; + return ; } - return ; + return ; }; - export default PrivateRoute; \ No newline at end of file diff --git a/web-app/src/Components/Layouts/MainLayout.jsx b/web-app/src/Components/Layouts/MainLayout.jsx index 06cda8b..64bfaa6 100644 --- a/web-app/src/Components/Layouts/MainLayout.jsx +++ b/web-app/src/Components/Layouts/MainLayout.jsx @@ -11,7 +11,7 @@ import { LogoutOutlined, MessageOutlined } from "@ant-design/icons"; -import {useAuth} from "../../Hooks/AuthContext.jsx"; +import useAuthUtils from "../../Hooks/useAuthUtils.js"; const {Content, Footer, Sider} = Layout; @@ -24,7 +24,7 @@ const MainLayout = () => { const [collapsed, setCollapsed] = useState(true); const navigate = useNavigate(); const location = useLocation(); - const {logout} = useAuth(); + const {logoutAndRedirect} = useAuthUtils(); const menuItems = [ getItem("Главная", "/", ), @@ -42,7 +42,7 @@ const MainLayout = () => { const handleMenuClick = ({key}) => { if (key === "logout") { - logout(); + logoutAndRedirect(); return; } navigate(key); diff --git a/web-app/src/Components/Pages/LoginPage/LoginPage.jsx b/web-app/src/Components/Pages/LoginPage/LoginPage.jsx index 06ce407..9cd81c0 100644 --- a/web-app/src/Components/Pages/LoginPage/LoginPage.jsx +++ b/web-app/src/Components/Pages/LoginPage/LoginPage.jsx @@ -1,73 +1,48 @@ -import {Form, Input, Button, Row, Col, Typography} from 'antd'; -import {useEffect, useState} from 'react'; -import {useAuth} from "../../../Hooks/AuthContext.jsx"; -import {useNavigate} from "react-router-dom"; +import {Form, Input, Button, Row, Col, Typography} from "antd"; +import {useSelector} from "react-redux"; +import useLoginPage from "./useLoginPage.js"; +import useLoginPageUI from "./useLoginPageUI.js"; const {Title} = Typography; const LoginPage = () => { - const {user, login} = useAuth(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const navigate = useNavigate(); - - useEffect(() => { - if (user) { - navigate("/"); - } - document.title = "Авторизация"; - }, [user, navigate]); - - const onFinish = async (values) => { - setLoading(true); - setError(null); - - try { - await login(values); - navigate("/"); - } catch (error) { - setError(`Ошибка при входе: ${error.message}`); - } finally { - setLoading(false); - } - }; + const {error} = useSelector((state) => state.auth); + const {onFinish, isLoading} = useLoginPage(); + const {containerStyle, formContainerStyle, titleStyle, errorStyle, labels} = useLoginPageUI(); return ( - + -
- Авторизация +
+ + {labels.title} + - {error &&
{error}
} + {error && ( +
+ {labels.errorPrefix} + {error} +
+ )} -
+ - + - + -
@@ -77,4 +52,4 @@ const LoginPage = () => { ); }; -export default LoginPage; +export default LoginPage; \ No newline at end of file diff --git a/web-app/src/Components/Pages/LoginPage/useLoginPage.js b/web-app/src/Components/Pages/LoginPage/useLoginPage.js new file mode 100644 index 0000000..b6d3917 --- /dev/null +++ b/web-app/src/Components/Pages/LoginPage/useLoginPage.js @@ -0,0 +1,36 @@ +import { useDispatch } from "react-redux"; +import { notification } from "antd"; +import {setError, setUser} from "../../../Redux/Slices/authSlice.js"; +import {useLoginMutation} from "../../../Api/authApi.js"; + +const useLoginPage = () => { + const dispatch = useDispatch(); + const [loginUser, { isLoading }] = useLoginMutation(); + + const onFinish = async (loginData) => { + try { + const response = await loginUser(loginData).unwrap(); + const token = response.access_token || response.token; + if (!token) { + throw new Error("Токен не получен от сервера"); + } + localStorage.setItem("access_token", token); + dispatch(setUser({ token })); + } catch (error) { + const errorMessage = error?.data?.message || "Не удалось войти"; + dispatch(setError(errorMessage)); + notification.error({ + message: "Ошибка при входе", + description: errorMessage, + placement: "topRight", + }); + } + }; + + return { + onFinish, + isLoading, + }; +}; + +export default useLoginPage; \ No newline at end of file diff --git a/web-app/src/Components/Pages/LoginPage/useLoginPageUI.js b/web-app/src/Components/Pages/LoginPage/useLoginPageUI.js new file mode 100644 index 0000000..cf40404 --- /dev/null +++ b/web-app/src/Components/Pages/LoginPage/useLoginPageUI.js @@ -0,0 +1,58 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { Grid } from "antd"; + +const { useBreakpoint } = Grid; + +const useLoginPageUI = () => { + const navigate = useNavigate(); + const { user } = useSelector((state) => state.auth); + const screens = useBreakpoint(); + + const containerStyle = { + minHeight: "100vh", + }; + + const formContainerStyle = { + padding: screens.xs ? 10 : 20, + border: "1px solid #ddd", + borderRadius: 8, + }; + + const titleStyle = { + textAlign: "center", + }; + + const errorStyle = { + color: "red", + marginBottom: 15, + }; + + const labels = { + title: "Авторизация", + loginPlaceholder: "Логин", + passwordPlaceholder: "Пароль", + submitButton: "Войти", + loginRequired: "Пожалуйста, введите логин", + passwordRequired: "Пожалуйста, введите пароль", + errorPrefix: "Ошибка при входе: ", + }; + + useEffect(() => { + if (user) { + navigate("/"); + } + document.title = labels.title; + }, [user, navigate]); + + return { + containerStyle, + formContainerStyle, + titleStyle, + errorStyle, + labels, + }; +}; + +export default useLoginPageUI; \ No newline at end of file diff --git a/web-app/src/Hooks/AuthContext.jsx b/web-app/src/Hooks/AuthContext.jsx deleted file mode 100644 index 4b8352f..0000000 --- a/web-app/src/Hooks/AuthContext.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import {createContext, useState, useContext, useEffect} from "react"; -import PropTypes from "prop-types"; -import loginUser from "../old_api/auth/loginRequest.js"; -import {Spin} from "antd"; -import {useNavigate} from "react-router-dom"; -import createApi from "../Core/axiosConfig.js"; - -const AuthContext = createContext(undefined); - -const AuthProvider = ({children}) => { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const navigate = useNavigate(); - - useEffect(() => { - const token = localStorage.getItem("access_token"); - if (token) { - setUser({token}); - } - setIsLoading(false); - }, []); - - - const login = async (loginData) => { - try { - const token = await loginUser(loginData, api); - localStorage.setItem("access_token", token); - setUser({token}); - } catch (error) { - console.error("Login failed", error); - throw error; - } - }; - - const logoutAndRedirect = () => { - localStorage.removeItem("access_token"); - setUser(null); - navigate("/login"); - }; - - const logout = () => { - localStorage.removeItem("access_token"); - setUser(null); - }; - - if (isLoading) { - return ; - } - - const api = createApi(logoutAndRedirect); - - return ( - - {children} - - ); -}; - -AuthProvider.propTypes = { - children: PropTypes.node.isRequired, -}; - -const useAuth = () => { - return useContext(AuthContext); -}; - -export {useAuth, AuthProvider}; \ No newline at end of file diff --git a/web-app/src/Hooks/useAuthUtils.js b/web-app/src/Hooks/useAuthUtils.js new file mode 100644 index 0000000..20c9d85 --- /dev/null +++ b/web-app/src/Hooks/useAuthUtils.js @@ -0,0 +1,18 @@ +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { logout } from "../Redux/Slices/authSlice.js"; + +const useAuthUtils = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const logoutAndRedirect = () => { + localStorage.removeItem("access_token"); + dispatch(logout()); + navigate("/login"); + }; + + return { logoutAndRedirect }; +}; + +export default useAuthUtils; \ No newline at end of file diff --git a/web-app/src/Redux/Slices/authSlice.js b/web-app/src/Redux/Slices/authSlice.js new file mode 100644 index 0000000..f327c5d --- /dev/null +++ b/web-app/src/Redux/Slices/authSlice.js @@ -0,0 +1,49 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; + +export const checkAuth = createAsyncThunk("auth/checkAuth", async () => { + const token = localStorage.getItem("access_token"); + if (token) { + return { token }; + } + return null; +}); + +const initialState = { + user: null, + isLoading: true, + error: null, +}; + +const authSlice = createSlice({ + name: "auth", + initialState, + reducers: { + setUser(state, action) { + state.user = action.payload; + state.isLoading = false; + state.error = null; + }, + setError(state, action) { + state.error = action.payload; + state.isLoading = false; + }, + logout(state) { + state.user = null; + state.error = null; + state.isLoading = false; + }, + }, + extraReducers: (builder) => { + builder + .addCase(checkAuth.fulfilled, (state, action) => { + state.user = action.payload; + state.isLoading = false; + }) + .addCase(checkAuth.rejected, (state) => { + state.isLoading = false; + }); + }, +}); + +export const { setUser, setError, logout } = authSlice.actions; +export default authSlice.reducer; \ No newline at end of file diff --git a/web-app/src/Redux/store.js b/web-app/src/Redux/store.js index ed47a43..9c0d86d 100644 --- a/web-app/src/Redux/store.js +++ b/web-app/src/Redux/store.js @@ -15,6 +15,8 @@ import {scheduledAppointmentsApi} from "../Api/scheduledAppointmentsApi.js"; import {appointmentTypesApi} from "../Api/appointmentTypesApi.js"; import {usersApi} from "../Api/usersApi.js"; import usersReducer from "./Slices/usersSlice.js"; +import {authApi} from "../Api/authApi.js"; +import authReducer from "./Slices/authSlice.js"; export const store = configureStore({ reducer: { @@ -42,7 +44,10 @@ export const store = configureStore({ [appointmentTypesApi.reducerPath]: appointmentTypesApi.reducer, [usersApi.reducerPath]: usersApi.reducer, - usersUI: usersReducer + usersUI: usersReducer, + + auth: authReducer, + [authApi.reducerPath]: authApi.reducer, }, middleware: (getDefaultMiddleware) => ( getDefaultMiddleware().concat( @@ -56,6 +61,7 @@ export const store = configureStore({ scheduledAppointmentsApi.middleware, appointmentTypesApi.middleware, usersApi.middleware, + authApi.middleware, ) ), }); diff --git a/web-app/src/old_api/auth/loginRequest.js b/web-app/src/old_api/auth/loginRequest.js deleted file mode 100644 index d0b6249..0000000 --- a/web-app/src/old_api/auth/loginRequest.js +++ /dev/null @@ -1,14 +0,0 @@ -import CONFIG from "../../Core/сonfig.js"; - -const loginUser = async (loginData, api) => { - const response = await api.post(`${CONFIG.BASE_URL}/login/`, loginData, { - withCredentials: true, - headers: { - 'Content-Type': 'application/json' - } - }); - - return response.data.access_token; -}; - -export default loginUser; \ No newline at end of file