refactor: Авторизация через Redux Toolkit

Удален AuthContext, логика авторизации перенесена в Redux.
Добавлены authSlice и authApi для управления состоянием авторизации.
This commit is contained in:
Андрей Дувакин 2025-06-02 19:28:18 +05:00
parent 0c326d815a
commit 4d903ee8c5
13 changed files with 258 additions and 159 deletions

View File

@ -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;
export const { useLoginMutation } = authApi;

View File

@ -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 = () => (
<Provider store={store}>
<Router>
<AuthProvider>
<ConfigProvider locale={locale}>
<AppRouter/>
</ConfigProvider>
</AuthProvider>
<ConfigProvider locale={locale}>
<AppRouter/>
</ConfigProvider>
</Router>
</Provider>
);

View File

@ -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 <div>Произошла ошибка</div>;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -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 <Navigate to="/login"/>;
return <Navigate to="/login" />;
}
return <Outlet/>;
return <Outlet />;
};
export default PrivateRoute;

View File

@ -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("Главная", "/", <HomeOutlined/>),
@ -42,7 +42,7 @@ const MainLayout = () => {
const handleMenuClick = ({key}) => {
if (key === "logout") {
logout();
logoutAndRedirect();
return;
}
navigate(key);

View File

@ -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 (
<Row justify="center" align="middle" style={{minHeight: '100vh'}}>
<Row justify="center" align="middle" style={containerStyle}>
<Col xs={24} sm={18} md={12} lg={8} xl={6}>
<div style={{padding: 20, border: '1px solid #ddd', borderRadius: 8}}>
<Title level={2} style={{textAlign: 'center'}}>Авторизация</Title>
<div style={formContainerStyle}>
<Title level={2} style={titleStyle}>
{labels.title}
</Title>
{error && <div style={{color: 'red', marginBottom: 15}}>{error}</div>}
{error && (
<div style={errorStyle}>
{labels.errorPrefix}
{error}
</div>
)}
<Form
name="login"
initialValues={{remember: true}}
onFinish={onFinish}
>
<Form name="login" initialValues={{remember: true}} onFinish={onFinish}>
<Form.Item
name="login"
rules={[{required: true, message: 'Пожалуйста, введите логин'}]}
rules={[{required: true, message: labels.loginRequired}]}
>
<Input placeholder="Логин"/>
<Input placeholder={labels.loginPlaceholder}/>
</Form.Item>
<Form.Item
name="password"
rules={[{required: true, message: 'Пожалуйста, введите пароль'}]}
rules={[{required: true, message: labels.passwordRequired}]}
>
<Input.Password placeholder="Пароль"/>
<Input.Password placeholder={labels.passwordPlaceholder}/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
loading={loading}
>
Войти
<Button type="primary" htmlType="submit" block loading={isLoading}>
{labels.submitButton}
</Button>
</Form.Item>
</Form>
@ -77,4 +52,4 @@ const LoginPage = () => {
);
};
export default LoginPage;
export default LoginPage;

View File

@ -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;

View File

@ -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;

View File

@ -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 <Spin/>;
}
const api = createApi(logoutAndRedirect);
return (
<AuthContext.Provider value={{user, login, logout, logoutAndRedirect, api}}>
{children}
</AuthContext.Provider>
);
};
AuthProvider.propTypes = {
children: PropTypes.node.isRequired,
};
const useAuth = () => {
return useContext(AuthContext);
};
export {useAuth, AuthProvider};

View File

@ -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;

View File

@ -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;

View File

@ -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,
)
),
});

View File

@ -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;