Mern 身份验证

Flying
2023-06-22 / 0 评论 / 128 阅读 / 正在检测是否收录...

这是一个关于如何使用 JWT 和 HTTP-only cookie 实现 MERN 栈应用程序的身份验证系统的两部分系列的第二部分。在本部分中,我们将创建前端 React 应用程序,以使用我们在第一部分中构建的 API。我们将使用 Redux Toolkit 进行状态管理。

final.png

创建一个新的 React 应用

我们将使用 Vite 来创建 React 单页应用(SPA)。Vite 是一个比 Create React App 更快的新型构建工具,使用起来也非常简单。在项目的根目录下运行以下命令来安装 Vite:

npm create vite@latest frontend

这将创建一个名为 frontend 的新文件夹,并安装 React 应用所需的所有依赖项。

进入 frontend 文件夹并运行 npm install 来安装所有依赖项。

如果你使用 Git,应将.gitignore文件从 frontend 文件夹移动到项目的根目录。替换已存在的文件,只需在新文件中添加 .env 即可。

Vite 配置

打开 vite.config.js 文件,添加一个包含以下值的 server 对象

export default {
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true
      }
    }
  }
};

这将使服务器在 3000 端口启动,并将所有请求代理到 5000 端口的 /api 路径上。这是我们后端服务器运行的端口。

客户端脚本

我希望能够从根目录运行前端 React 开发服务器。因此,在 ROOT 的 package.json 文件中添加以下脚本:

"scripts": {
  "dev": "cd frontend && npm run dev"
}

同时设置

我还希望有一个脚本可以同时运行后端 API 和前端开发服务器。为此,我们将使用 concurrently 包。从根目录运行以下命令安装它:

npm install -D concurrently

然后,我们想要添加一个脚本来同时运行前端和后端。你的 package.json 中的脚本应如下所示:

"scripts": {
  "start": "node backend/server.js",
  "server": "nodemon backend/server.js",
  "client": "npm start --prefix frontend",
  "dev": "concurrently \"npm run server\" \"npm run client\""
},

现在,你可以从根目录运行 npm run dev,它将同时启动后端和前端开发服务器。

你应该在http://localhost:3000/看到登录页面,并且可以通过 5000 端口访问 API。

清理

我通常喜欢清理一些样板代码。虽然 Vite 没有太多样板代码,但我将删除 App.css 文件并清空 index.css 文件。

打开 App.jsx 文件,并将其简化为一个只有一个 h1 标签,内容为“Hello World”的简单组件。

const App = () => {
  return <h1>Hello World</h1>;
};

export default App;

安装 React Bootstrap

我们将使用 Bootstrap 和 React Bootstrap UI 库,它允许我们将 Bootstrap 组件作为 React 组件使用。我们还将使用 react-icons 库来使用 Font Awesome 图标。从 frontend 文件夹运行以下命令:

npm install react-bootstrap bootstrap react-icons

然后,在 main.jsx 文件中添加以下导入语句:

import 'bootstrap/dist/css/bootstrap.min.css';

创建 Header

让我们创建一个基本的页眉/导航栏组件。创建一个名为 components 的新文件夹,在该文件夹内创建一个名为 Header.jsx 的新文件。

import { Navbar, Nav, Container } from 'react-bootstrap';
import { FaSignInAlt, FaSignOutAlt } from 'react-icons/fa';

const Header = () => {
  return (
    <header>
      <Navbar bg="dark" variant="dark" expand="lg" collapseOnSelect>
        <Container>
          <Navbar.Brand href="/">MERN App</Navbar.Brand>
          <Navbar.Toggle aria-controls="basic-navbar-nav" />
          <Navbar.Collapse id="basic-navbar-nav">
            <Nav className="ms-auto">
              <Nav.Link href="/login">
                <FaSignInAlt /> Sign In
              </Nav.Link>
              <Nav.Link href="/login">
                <FaSignOutAlt /> Sign Up
              </Nav.Link>
            </Nav>
          </Navbar.Collapse>
        </Container>
      </Navbar>
    </header>
  );
};

export default Header;

主屏幕

创建一个名为 screens 的文件夹,在该文件夹内创建一个名为 HomeScreen.jsx 的新文件。暂时只添加以下内容:

const HomeScreen = () => {
  return <div>HomeScreen</div>;
};
export default HomeScreen;

主屏幕英雄部分

为了使其外观好看一些,让我们在主屏幕组件中添加一个英雄部分。在 components 文件夹中创建一个名为 Hero.jsx 的新文件。添加以下代码:

import { Container, Card, Button } from 'react-bootstrap';

const Hero = () => {
  return (
    <div className=" py-5">
      <Container className="d-flex justify-content-center">
        <Card className="p-5 d-flex flex-column align-items-center hero-card bg-light w-75">
          <h1 className="text-center mb-4">MERN Authentication</h1>
          <p className="text-center mb-4">
            This is a boilerplate for MERN authentication that stores a JWT in
            an HTTP-Only cookie. It also uses Redux Toolkit and the React
            Bootstrap library
          </p>
          <div className="d-flex">
            <Button variant="primary" href="/login" className="me-3">
              Sign In
            </Button>
            <Button variant="secondary" href="/register">
              Register
            </Button>
          </div>
        </Card>
      </Container>
    </div>
  );
};

export default Hero;

然后将其导入到 HomeScreen.jsx 文件中,并添加到组件中:

import Hero from '../components/Hero';

const HomeScreen = () => {
  return (
    <>
      <Hero />
    </>
  );
};

打开 App.js 文件并添加以下代码:

import { Container } from 'react-bootstrap';
import Header from './components/Header';
import HomeScreen from './screens/HomeScreen';

const App = () => {
  return (
    <>
      <Header />
      <Container className="my-2">
        <HomeScreen />
      </Container>
    </>
  );
};

export default App;

现在,你应该可以看到具有英雄部分的主屏幕:

home.png

React 路由器

我们需要在应用程序中拥有多个页面。为此,我们将使用 React Router。从 frontend 文件夹中运行以下命令:

npm install react-router-dom

如果你使用 Vite,则在 main.jsx 文件中;如果你使用 Create React App,则在 index.js 文件中,添加以下代码:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import {
  createBrowserRouter,
  createRoutesFromElements,
  Route,
  RouterProvider
} from 'react-router-dom';
import './index.css';
import 'bootstrap/dist/css/bootstrap.min.css';
import HomeScreen from './screens/HomeScreen';

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route index={true} path="/" element={<HomeScreen />} />
    </Route>
  )
);

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

这里我们正在创建我们的路由,并将我们的应用程序包装在 RouterProvider 组件中。我们还为 HomeScreen 组件创建了路由。

现在,我们只需要在 App.jsx 文件中添加 <Outlet /> 组件。这将呈现父路由的子路由。因此,我们的 App.jsx 文件应如下所示:

import { Container } from 'react-bootstrap';
import { Outlet } from 'react-router-dom';
import Header from './components/Header';

const App = () => {
  return (
    <>
      <Header />
      <Container className="my-2">
        <Outlet />
      </Container>
    </>
  );
};

export default App;

使用 Link 和 LinkContainer

目前,在我们的页眉中,我们使用锚标签链接到登录和注册页面。当我们点击链接时,这将导致整个页面刷新。我们希望使用 React Router 的 Link 组件来阻止这种情况发生。

由于我们使用的是 React Bootstrap,我们可以使用 react-router-bootstrap 中的 LinkContainer 组件来包装我们的 Link 组件。这将允许我们将 Link 组件用作 React Bootstrap 组件。

frontend 文件夹中运行以下命令:

npm install react-router-bootstrap

现在,将 Header.jsx 文件更改为以下内容:

import { Navbar, Nav, Container } from 'react-bootstrap';
import { LinkContainer } from 'react-router-bootstrap';
import { FaSignInAlt, FaSignOutAlt } from 'react-icons/fa';

const Header = () => {
  return (
    <header>
      <Navbar bg="dark" variant="dark" expand="lg" collapseOnSelect>
        <Container>
          <LinkContainer to="/">
            <Navbar.Brand>MERN App</Navbar.Brand>
          </LinkContainer>
          <Navbar.Toggle aria-controls="basic-navbar-nav" />
          <Navbar.Collapse id="basic-navbar-nav">
            <Nav className="ms-auto">
              <LinkContainer to="/login">
                <Nav.Link>
                  <FaSignInAlt /> Sign In
                </Nav.Link>
              </LinkContainer>
              <LinkContainer to="/register">
                <Nav.Link>
                  <FaSignOutAlt /> Sign Up
                </Nav.Link>
              </LinkContainer>
            </Nav>
          </Navbar.Collapse>
        </Container>
      </Navbar>
    </header>
  );
};

export default Header;
请注意,我们现在将 Navbar.BrandNav.Link 组件包装在 LinkContainer 组件中。同时,我们使用 to 属性而不是 href 属性。

由于某种原因,我不得不重新启动服务器。所以如果你遇到问题,请首先尝试重新启动服务器。

FormContainer 组件

在创建登录和注册表单之前,我想创建一个简单的包装组件,因为这些表单将位于更窄的容器中。

components 文件夹中创建一个名为 FormContainer.jsx 的新文件。添加以下代码:

import { Container, Row, Col } from 'react-bootstrap';

const FormContainer = ({ children }) => {
  return (
    <Container>
      <Row className="justify-content-md-center mt-5">
        <Col xs={12} md={6} className="card p-5">
          {children}
        </Col>
      </Row>
    </Container>
  );
};

export default FormContainer;

这将使我们能够将表单包装在一个在页面上居中且在较大屏幕上仅占 6 列的容器中。

登录界面

让我们继续创建登录界面。在 screens 文件夹中创建一个名为 LoginScreen.jsx 的新文件。添加以下代码:

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Form, Button, Row, Col } from 'react-bootstrap';
import FormContainer from '../components/FormContainer';

const LoginScreen = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const submitHandler = async (e) => {
    e.preventDefault();
    console.log('submit');
  };

  return (
    <FormContainer>
      <h1>Sign In</h1>

      <Form onSubmit={submitHandler}>
        <Form.Group className="my-2" controlId="email">
          <Form.Label>Email Address</Form.Label>
          <Form.Control
            type="email"
            placeholder="Enter email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Form.Group className="my-2" controlId="password">
          <Form.Label>Password</Form.Label>
          <Form.Control
            type="password"
            placeholder="Enter password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Button type="submit" variant="primary" className="mt-3">
          Sign In
        </Button>
      </Form>

      <Row className="py-3">
        <Col>
          New Customer? <Link to={`/register`}>Register</Link>
        </Col>
      </Row>
    </FormContainer>
  );
};

export default LoginScreen;

这仅仅是界面的展示,没有任何功能。在我们添加任何功能之前,我希望能够显示这两个表单。

添加到路由器

打开main.jsx文件并添加以下路由:

import LoginScreen from './screens/LoginScreen.jsx';

<Route path="/" element={<App />}>
  <Route index={true} path="/" element={<HomeScreen />} />
  <Route path="/login" element={<LoginScreen />} /> {/* Add this line */}
</Route>;

现在,你应该可以看到一个基本的登录表单:

login.png

注册界面

让我们对注册界面做同样的操作。创建 UI 并添加路由。

screens 文件夹中创建一个名为 RegisterScreen.jsx 的新文件。添加以下代码:

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Form, Button, Row, Col } from 'react-bootstrap';
import FormContainer from '../components/FormContainer';

const RegisterScreen = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  const submitHandler = async (e) => {
    e.preventDefault();
    console.log('submit');
  };

  return (
    <FormContainer>
      <h1>Register</h1>
      <Form onSubmit={submitHandler}>
        <Form.Group className="my-2" controlId="name">
          <Form.Label>Name</Form.Label>
          <Form.Control
            type="name"
            placeholder="Enter name"
            value={name}
            onChange={(e) => setName(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Form.Group className="my-2" controlId="email">
          <Form.Label>Email Address</Form.Label>
          <Form.Control
            type="email"
            placeholder="Enter email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Form.Group className="my-2" controlId="password">
          <Form.Label>Password</Form.Label>
          <Form.Control
            type="password"
            placeholder="Enter password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          ></Form.Control>
        </Form.Group>
        <Form.Group className="my-2" controlId="confirmPassword">
          <Form.Label>Confirm Password</Form.Label>
          <Form.Control
            type="password"
            placeholder="Confirm password"
            value={confirmPassword}
            onChange={(e) => setConfirmPassword(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Button type="submit" variant="primary" className="mt-3">
          Register
        </Button>
      </Form>

      <Row className="py-3">
        <Col>
          Already have an account? <Link to={`/login`}>Login</Link>
        </Col>
      </Row>
    </FormContainer>
  );
};

export default RegisterScreen;

现在添加路由:

import RegisterScreen from './screens/RegisterScreen.jsx';

<Route path="/" element={<App />}>
  <Route index={true} path="/" element={<HomeScreen />} />
  <Route path="/login" element={<LoginScreen />} />
  <Route path="/register" element={<RegisterScreen />} /> {/* Add this line */}
</Route>;

现在,你应该可以看到一个基本的注册表单:

register.png

Redux Toolkit

现在,我们已经设置好了 UI,接下来我们需要处理应用程序状态。我们可以从登录和注册组件发送请求,但我希望有一个集中处理所有状态和任何改变状态的请求的地方。这就是 Redux 的作用。Redux 是一个状态管理库,允许我们创建一个包含所有状态的存储。然后,我们可以派发 actions 来改变该状态。

Reducer 是一个函数,它接受当前状态和 actions,并返回新的状态。我们可以有多个处理不同部分状态的 reducer。例如,我们可以有一个处理用户状态的 reducer,另一个处理产品状态的 reducer。

Redux Toolkit 是一个帮助我们以比使用原生 Redux 更简单的方式创建存储和 reducer 的包。

让我们安装 Redux Toolkit 以及 Redux 的 React 绑定。确保你在 frontend 文件夹中,然后运行以下命令:

cd frontend
npm install @reduxjs/toolkit react-redux

Store

存储是保存所有状态的地方。然后,我们可以派发 actions 来改变该状态。我们还可以订阅存储以获取当前状态。

frontend/src 文件夹中创建一个名为 store.js 的文件。添加以下代码:

import { configureStore } from '@reduxjs/toolkit';

const store = configureStore({
  reducer: {},
  middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
  devTools: true
});

export default store;

我们使用 Redux Toolkit 的configureStore 函数创建存储。我们传入一个带有 reducer 属性的对象。稍后我们将在这个对象中添加我们的 reducer。我们还传入一个 middleware 属性。中间件是扩展 Redux 的自定义功能的一种方式。我们还启用了 Redux DevTools 扩展,如果你还没有安装,请从以下链接获取:

Provider

为了使我们的应用程序与 Redux 存储正常工作,我们必须将其包装在提供程序中。

打开 main.jsx 文件并添加以下代码:

import store from './store';
import { Provider } from 'react-redux';

然后将应用程序包装在提供程序中:

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>
  </Provider>
);

这里我遇到了另一个错误,实际上我不得不删除我的 frontend 中的 node_modules 文件夹并重新运行 npm install。我不知道为什么会出现这个问题,但这解决了问题。

你可能会在控制台中收到一个警告,说你没有有效的 reducer,但在此阶段这是可以接受的。

Auth Slice

在 Redux Toolkit 中,我们使用一个叫做 slice 的东西来创建 reducer。 Slice 是我们应用程序的单个功能的 reducer 逻辑和 actions 的集合。我们将为我们的身份验证创建一个 slice ,它仅处理用户的本地存储,我们还将有一个单独的 API slice 来向端点发送请求。

slices 文件夹中创建一个名为 authSlice.js 的文件。添加以下代码:

import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  userInfo: localStorage.getItem('userInfo')
    ? JSON.parse(localStorage.getItem('userInfo'))
    : null
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    setCredentials: (state, action) => {
      state.userInfo = action.payload;
      localStorage.setItem('userInfo', JSON.stringify(action.payload));
    },
    logout: (state, action) => {
      state.userInfo = null;
      localStorage.removeItem('userInfo');
    }
  }
});

export const { setCredentials, logout } = authSlice.actions;

export default authSlice.reducer;

正如我所说,这只涉及用户的本地存储。我们有一个设置用户信息到本地存储的 reducer,以及一个删除用户信息的 reducer。非常简单。

要使用一个 slice ,我们需要将其导入到存储中并使用它。打开 store.js 文件并添加以下代码:

import { configureStore } from '@reduxjs/toolkit';
import authReducer from './slices/authSlice';

const store = configureStore({
  reducer: {
    auth: authReducer
  },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware(),
  devTools: true
});

export default store;

因此,将 authSlice 引入并将其添加到 reducer 对象中。

现在,打开你的开发者工具并转到 Redux 选项卡。你应该看到带有 auth slice 的存储:

store.png)

userinfo 目前为空,但在我们与后端进行身份验证之后,它将存储已登录用户的数据。它也将保存在本地存储中。

API Slice

为了从我们的 slice 发出异步请求,我们需要使用一个叫做createAsyncThunk的东西。这可能有点混乱,但只要我们遵循正确的约定,Redux 会在幕后处理这个问题。

frontend/src/slices文件夹中创建一个名为 apiSlice.js 的文件。添加以下代码:

import { fetchBaseQuery, createApi } from '@reduxjs/toolkit/query/react';

const baseQuery = fetchBaseQuery({ baseUrl: '' });

export const apiSlice = createApi({
  baseQuery,
  tagTypes: ['User'],
  endpoints: (builder) => ({})
});

我们使用 Redux Toolkit 的 createApi 函数来创建 API slice,而不是使用 createSlice ,因为它包含了我们需要向服务器发起请求的中间件。我们传入一个 baseQuery 对象,该对象将用于发起请求。我们还传入一个 endpoints 对象,该对象将保存所有的端点。

我们需要将其连接到我们的存储中,因此打开 store.js文件并添加以下代码:

import { configureStore } from '@reduxjs/toolkit';
import { apiSlice } from './slices/apiSlice';
import authReducer from './slices/authSlice'; // add this line

const store = configureStore({
  reducer: {
    [apiSlice.reducerPath]: apiSlice.reducer,
    auth: authReducer, // add this line
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(apiSlice.middleware),
  devTools: true,
});

export default store;

我们引入了新的 API slice,并将其添加到 reducer 对象中。我们还通过使用 concat 方法将中间件添加到 getDefaultMiddleware 函数中。

这几行代码在幕后为我们做了很多事情。它们允许我们轻松地创建查询和突变以及加载和错误状态。如果使用原生 Redux,我们将不得不手动处理这些事情。

User API Slice

我们想要处理我们的用户数据,并且我们需要创建一些端点来与后端交互。我们将把这些内容保存在一个单独的文件中。

frontend/src/slices 文件夹中创建一个名为 usersApiSlice.js 的文件。添加以下代码:

import { apiSlice } from './apiSlice';
const USERS_URL = '/api/users';

export const userApiSlice = apiSlice.injectEndpoints({
  endpoints: (builder) => ({
    login: builder.mutation({
      query: (data) => ({
        url: `${USERS_URL}/auth`,
        method: 'POST',
        body: data
      })
    })
  })
});

export const { useLoginMutation } = userApiSlice;

我们引入了 apiSlice,然后使用 injectEndpoints 方法来创建我们的端点。目前我们只创建了一个登录端点,但稍后我们将添加更多。我们使用 mutation 方法创建一个登录端点。我们传入一个包含请求的 URL、方法和正文的 query 对象。

正如你所看到的,当我们需要与后端交互时,只需创建一个端点并传入数据即可。我们不必担心加载和错误状态,因为 Redux Toolkit 为我们处理了这些。我们也不需要将

此 slice 添加到存储中,因为它已经包含在我们添加到存储中的 apiSlice 中。你可以将其视为 API slice 的子 slice。

使用登录端点

现在我们想将这个突变与我们的登录表单连接起来,所以打开 frontend/src/screens/LoginScreen.js 文件并添加以下代码:

import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Form, Button, Row, Col } from 'react-bootstrap';
import FormContainer from '../components/FormContainer';
import { useDispatch, useSelector } from 'react-redux';
import { useLoginMutation } from '../slices/usersApiSlice';
import { setCredentials } from '../slices/authSlice';

const LoginScreen = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const dispatch = useDispatch();
  const navigate = useNavigate();

  const [login, { isLoading }] = useLoginMutation();

  const { userInfo } = useSelector((state) => state.auth);

  useEffect(() => {
    if (userInfo) {
      navigate('/');
    }
  }, [navigate, userInfo]);

  const submitHandler = async (e) => {
    e.preventDefault();
    try {
      const res = await login({ email, password }).unwrap();
      dispatch(setCredentials({ ...res }));
      navigate('/');
    } catch (err) {
      console.log(err?.data?.message || err.error);
    }
  };

  return (
    <FormContainer>
      <h1>Sign In</h1>

      <Form onSubmit={submitHandler}>
        <Form.Group className="my-2" controlId="email">
          <Form.Label>Email Address</Form.Label>
          <Form.Control
            type="email"
            placeholder="Enter email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Form.Group className="my-2" controlId="password">
          <Form.Label>Password</Form.Label>
          <Form.Control
            type="password"
            placeholder="Enter password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Button
          disabled={isLoading}
          type="submit"
          variant="primary"
          className="mt-3"
        >
          Sign In
        </Button>
      </Form>

      {isLoading && <p>Loading...</p>}

      <Row className="py-3">
        <Col>
          New Customer? <Link to="/register">Register</Link>
        </Col>
      </Row>
    </FormContainer>
  );
};

export default LoginScreen;

我们在这里做了很多事情。首先,我们从 react-redux 中引入了 useSelectoruseDispatch。这允许我们在存储库中派发 actions 和选择数据。我们还从 usersApiSlice.js 文件中引入了 useLoginMutation Hook。我们还从 authSlice.js 文件中引入了 setCredentials action。在成功登录后,我们将设置凭证。

然后,我们从 useLoginMutation Hook 中获取 login 函数和 isLoading 状态。我们还从 authSlice.js 文件中获取 userInfo 状态。

我们使用 useEffect Hook 在用户已经登录时将其重定向到主页。我们还使用 react-router-dom 中的 useNavigate Hook 在成功登录后重定向用户。

尝试使用空字段或错误的登录凭证进行提交。你应该看到带有错误的控制台日志。我想使用 React Toastify 包显示错误。我还想在点击登录按钮时显示加载中的旋转图标。在测试正确的登录凭证之前,让我们处理这个问题。

React Toastify

我们将使用 React Toastify 包来显示错误和成功消息。这是一个非常好用且外观漂亮的包。现在让我们安装它。确保你在 frontend 文件夹中,并运行以下命令:

npm i react-toastify

现在打开 frontend/src/App.js 文件,并用以下代码替换:

import { Container } from 'react-bootstrap';
import { Outlet } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import Header from './components/Header';

const App = () => {
  return (
    <>
      <Header />
      <ToastContainer />
      <Container className="my-2">
        <Outlet />
      </Container>
    </>
  );
};

export default App;

现在,在 frontend/src/screens/LoginScreen.js 文件中添加以下代码:

import { toast } from 'react-toastify';

用以下代码替换 catch 块中的 console.log:

toast.error(err?.data?.message || err.error);

现在,当你尝试使用错误的登录凭证进行提交时,你应该会看到一个漂亮的错误消息。你还可以使用 toast.success 方法显示成功消息。

加载旋转图标

让我们在 frontend/src/components 文件夹中创建一个名为 Loader.jsx 的文件,并添加以下代码:

import { Spinner } from 'react-bootstrap';

const Loader = () => {
  return (
    <Spinner
      animation="border"
      role="status"
      style={{
        width: '100px',
        height: '100px',
        margin: 'auto',
        display: 'block'
      }}
    ></Spinner>
  );
};

export default Loader;

我们使用 react-bootstrap 中的 Spinner 组件创建了一个加载旋转图标。我们还使用一些内联样式来居中显示旋转图标。

现在将其引入 LoginScreen 组件,并替换段落标签:

import Loader from '../components/Loader';

{
  isLoading && <Loader />;
}

现在,当你点击登录按钮时,你应该会看到加载旋转图标闪烁几毫秒。

设置凭证

现在,让我们尝试使用正确的电子邮件和密码登录。

如果成功登录,你应该被重定向到主屏幕,如果打开 Redux 开发工具,你应该可以看到带有 userInfo 对象的 auth 状态。

auth.png

这是因为一旦成功登录,我们就调用了 setCredentials action 并传入 userInfo 对象。这将在 auth 状态中设置 userInfo 对象。它也保存在本地存储中。

非常酷的是,现在 cookie 已经设置了 JWT。如果打开开发工具并转到 Application 选项卡,你应该能够看到 cookie。

cookie.png

这是一个 HTTP-Only cookie,是 JWT 的更安全的存储位置,并将随每个请求发送。

如果你尝试转到登录页面,你将被重定向到主页。

动态头部链接

我希望如果我们已登录,我们能够看到一个下拉菜单,其中包含用户的名称、链接到其个人资料的链接以及注销链接。

打开 frontend/src/components/Header.js 文件,并用以下代码替换:

import { Navbar, Nav, Container, NavDropdown, Badge } from 'react-bootstrap';
import { FaSignInAlt, FaSignOutAlt } from 'react-icons/fa';
import { LinkContainer } from 'react-router-bootstrap';
import { useSelector, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';

const Header = () => {
  const { userInfo } = useSelector((state) => state.auth);

  return (
    <header>
      <Navbar bg="dark" variant="dark" expand="lg" collapseOnSelect>
        <Container>
          <LinkContainer to="/">
            <Navbar.Brand>MERN App</Navbar.Brand>
          </LinkContainer>
          <Navbar.Toggle aria-controls="basic-navbar-nav" />
          <Navbar.Collapse id="basic-navbar-nav">
            <Nav className="ms-auto">
              {userInfo ? (
                <>
                  <NavDropdown title={userInfo.name} id="username">
                    <LinkContainer to="/profile">
                      <NavDropdown.Item>Profile</NavDropdown.Item>
                    </LinkContainer>
                    <NavDropdown.Item>Logout</NavDropdown.Item>
                  </NavDropdown>
                </>
              ) : (
                <>
                  <LinkContainer to="/login">
                    <Nav.Link>
                      <FaSignInAlt /> Sign In
                    </Nav.Link>
                  </LinkContainer>
                  <LinkContainer to="/register">
                    <Nav.Link>
                      <FaSignOutAlt /> Sign Up
                    </Nav.Link>
                  </LinkContainer>
                </>
              )}
            </Nav>
          </Navbar.Collapse>
        </Container>
      </Navbar>
    </header>
  );
};

export default Header;

我们从 auth 状态中引入了 userInfo。这基本上是我们在本地存储中的数据。我们还从 react-redux 中引入了 useDispatch Hook 和从 react-router-dom 中引入的 useNavigate Hook。我们很快就会需要它们。

注销

接下来让我们创建注销函数。我们已经有了后端功能。

打开 usersApiSlice.js 文件,并在登录函数下添加以下函数:

logout: builder.mutation({
  query: () => ({
    url: `${USERS_URL}/logout`,
    method: 'POST',
  }),
}),

不要忘记导出:

export const { useLoginMutation, useLogoutMutation } = userApiSlice;

这非常简单,我们只是向 /logout 端点发出 POST 请求。请记住,它会在服务器上销毁 cookie。我们还需要删除本地存储和 Redux 状态。该函数已经添加到 authSlice.js 文件中。

现在打开 frontend/src/components/Header.js 文件,并添加以下代码:

import { useLogoutMutation } from '../slices/usersApiSlice';
import { logout } from '../slices/authSlice';

在获取 userInfo 的位置下方添加以下代码:

const dispatch = useDispatch();
const navigate = useNavigate();

const [logoutApiCall] = useLogoutMutation();

const logoutHandler = async () => {
  try {
    await logoutApiCall().unwrap();
    dispatch(logout());
    navigate('/login');
  } catch (err) {
    console.error(err);
  }
};

在上述代码中,我们从 usersApiSlice 引入了注销突变和 authSlice 中的 logout action。我们还初始化了 useNavigateuseDispatch Hook。

我们获取了 logoutApiCall 函数(可以随意命名),然后添加了注销处理程序,用于调用注销突变和注销 action。我们还导航到登录页面。

现在,只需在注销链接上添加 onClick 处理程序即可:

<NavDropdown.Item onClick={logoutHandler}>Logout</NavDropdown.Item>

现在,当你单击注销链接时,你应该被重定向到登录页面,并且应该删除 cookie。用户也应该从本地存储和 Redux 状态中删除。

注册

在进入个人资料页面之前,让我们制作注册页面函数。

让我们首先将突变添加到状态中。打开 usersApiSlice.js 文件,并在注销函数下方添加以下代码:

register: builder.mutation({
  query: (data) => ({
    url: `${USERS_URL}`,
    method: 'POST',
    body: data,
  }),
}),

导出它:

export const { useLoginMutation, useLogoutMutation, useRegisterMutation } =
  userApiSlice;

现在打开 frontend/src/screens/RegisterScreen.js 文件,并添加以下代码:

import { useState, useEffect } from 'react';
import { Form, Button, Row, Col } from 'react-bootstrap';
import FormContainer from '../components/FormContainer';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useRegisterMutation } from '../slices/usersApiSlice';
import { setCredentials } from '../slices/authSlice';
import { toast } from 'react-toastify';

将其添加到所有 useState Hook 下方:

const dispatch = useDispatch();
const navigate = useNavigate();

const [register, { isLoading }] = useRegisterMutation();

const { userInfo } = useSelector((state) => state.auth);

useEffect(() => {
  if (userInfo) {
    navigate('/');
  }
}, [navigate, userInfo]);

将以下代码添加到 submitHandler 函数中:

const submitHandler = async (e) => {
  e.preventDefault();

  if (password !== confirmPassword) {
    toast.error('Passwords do not match');
  } else {
    try {
      const res = await register({ name, email, password }).unwrap();
      dispatch(setCredentials({ ...res }));
      navigate('/');
    } catch (err) {
      toast.error(err?.data?.message || err.error);
    }
  }
};

我们与登录一样做了相同的事情,只是使用了注册突变而不是登录突变。我们还使用了 setCredentials action,而不是登录 action。

在按钮下方添加加载检查:

{
  isLoading && <Loader />;
}

请尝试注册一个新用户。注册成功后,该用户将自动登录。你也可以进行注销操作。

个人资料

现在我们已经完全实现了认证功能。让我们制作个人资料页面。

frontend/src/screens 文件夹中创建一个名为 ProfileScreen.js 的新屏幕。添加以下代码:

import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Form, Button } from 'react-bootstrap';
import { useDispatch, useSelector } from 'react-redux';
import FormContainer from '../components/FormContainer';
import { toast } from 'react-toastify';
import Loader from '../components/Loader';

const ProfileScreen = () => {
  const [email, setEmail] = useState('');
  const [name, setName] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  const submitHandler = async (e) => {
    e.preventDefault();
    console.log('submit');
  };

  return (
    <FormContainer>
      <h1>Update Profile</h1>

      <Form onSubmit={submitHandler}>
        <Form.Group className="my-2" controlId="name">
          <Form.Label>Name</Form.Label>
          <Form.Control
            type="name"
            placeholder="Enter name"
            value={name}
            onChange={(e) => setName(e.target.value)}
          ></Form.Control>
        </Form.Group>
        <Form.Group className="my-2" controlId="email">
          <Form.Label>Email Address</Form.Label>
          <Form.Control
            type="email"
            placeholder="Enter email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          ></Form.Control>
        </Form.Group>
        <Form.Group className="my-2" controlId="password">
          <Form.Label>Password</Form.Label>
          <Form.Control
            type="password"
            placeholder="Enter password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Form.Group className="my-2" controlId="confirmPassword">
          <Form.Label>Confirm Password</Form.Label>
          <Form.Control
            type="password"
            placeholder="Confirm password"
            value={confirmPassword}
            onChange={(e) => setConfirmPassword(e.target.value)}
          ></Form.Control>
        </Form.Group>

        <Button type="submit" variant="primary" className="mt-3">
          Update
        </Button>
      </Form>
    </FormContainer>
  );
};

export default ProfileScreen;

这只是一个简单的表单,包含了用户数据的输入字段。我们很快将添加用户数据,但首先让我们添加路由。

打开 main.jsx 文件并添加以下路由:

<Route path="/" element={<App />}>
  <Route index={true} path="/" element={<HomeScreen />} />
  <Route path="/login" element={<LoginScreen />} />
  <Route path="/register" element={<RegisterScreen />} />
  <Route path="/profile" element={<ProfileScreen />} /> {/* Add this line */}
</Route>

现在,如果你登录,你应该能够转到个人资料菜单项并看到该表单。

私有路由

我们目前的问题是,即使你注销并手动转到个人资料页面,你仍然可以看

到表单。我们需要将其设置为私有路由,以便只有登录的用户才能看到。

我们可以通过创建一个私有路由组件来实现这一点。在 frontend/src/components 文件夹中创建一个名为 PrivateRoute.jsx 的文件。添加以下代码:

import { Navigate, Outlet } from 'react-router-dom';
import { useSelector } from 'react-redux';

const PrivateRoute = () => {
  const { userInfo } = useSelector((state) => state.auth);
  return userInfo ? <Outlet /> : <Navigate to="/login" replace />;
};
export default PrivateRoute;

在这里,我们只是检查用户是否已登录。如果已登录,我们渲染 Outlet 组件,该组件将渲染子组件。如果未登录,我们将重定向到登录页面。

现在,我们需要在 main.jsx 文件中使用它。添加以下导入:

import PrivateRoute from './components/PrivateRoute.jsx';

然后将 createRoutesFromElements 函数中的当前 <Route> 替换为以下代码:

const router = createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<App />}>
      <Route index={true} path="/" element={<HomeScreen />} />
      <Route path="/login" element={<LoginScreen />} />
      <Route path="/register" element={<RegisterScreen />} />
      <Route path="" element={<PrivateRoute />}>
        <Route path="/profile" element={<ProfileScreen />} />
      </Route>
    </Route>
  )
);

我们将 PrivateRoute 组件包装在 ProfileScreen 组件周围。现在,如果你注销并尝试转到个人资料页面,你将被重定向到登录页面。

填写表单

我们在本地存储中有用户数据,所以我们可以使用它来填写表单。打开 frontend/src/screens/ProfileScreen.js 文件并添加以下代码:

const { userInfo } = useSelector((state) => state.auth);

useEffect(() => {
  setName(userInfo.name);
  setEmail(userInfo.email);
}, [userInfo.email, userInfo.name]);

我们从存储中获取用户,然后使用 useEffect hook 在组件挂载时设置 name 和 email 状态。

更新用户

我们最后需要做的一件重要的事情是能够更新用户数据。

我们已经有了端点,所以让我们在 usersApiSlice.js 文件中添加 mutation:

 updateUser: builder.mutation({
    query: (data) => ({
      url: `${USERS_URL}/profile`,
      method: 'PUT',
      body: data,
    }),
  }),

导出它:

export const {
  useLoginMutation,
  useLogoutMutation,
  useRegisterMutation,
  useUpdateUserMutation
} = userApiSlice;

现在打开 frontend/src/screens/ProfileScreen.js 文件并添加以下代码:

import { useUpdateUserMutation } from '../slices/usersApiSlice';
import { setCredentials } from '../slices/authSlice';

我们需要引入刚刚创建的 mutation 以及 setCredentials 操作,因为如果更改了电子邮件或名称,必须更新状态中的凭据。

初始化 dispatch 和更新函数以及 loading 状态:

const dispatch = useDispatch();

const [updateProfile, { isLoading }] = useUpdateUserMutation();

最后,在 submitHandler 函数中添加以下代码:

const submitHandler = async (e) => {
  e.preventDefault();
  if (password !== confirmPassword) {
    toast.error('Passwords do not match');
  } else {
    try {
      const res = await updateProfile({
        _id: userInfo._id,
        name,
        email,
        password
      }).unwrap();
      dispatch(setCredentials({ ...res }));
      toast.success('Profile updated successfully');
    } catch (err) {
      toast.error(err?.data?.message || err.error);
    }
  }
};

我们检查确认密码,然后命中后端端点以更新数据,然后设置状态中的凭据并显示成功消息。

去尝试更新名称吧。

如果你更新了电子邮件或密码,请确保使用新的电子邮件和密码登录。

准备生产

我们即将完成。我们只需要准备我们的应用程序进行生产。我们需要构建前端,然后从后端提供静态文件。

打开前端文件夹并运行以下命令:

npm run build

这将在前端文件夹中创建一个文件夹。如果你使用的是 Vite,则文件夹将被称为 dist;如果你使用的是 CRA,则文件夹将被称为 build。这是我们将从后端提供的内容。

打开 backend/server.js 文件并添加以下代码:

import path from 'path';

将下面几行代码:

app.get('/', (req, res) => {
  res.send('API is running...');
});

替换为:

if (process.env.NODE_ENV === 'production') {
  const __dirname = path.resolve();
  app.use(express.static(path.join(__dirname, '/frontend/dist')));

  app.get('*', (req, res) =>
    res.sendFile(path.resolve(__dirname, 'frontend', 'dist', 'index.html'))
  );
} else {
  app.get('/', (req, res) => {
    res.send('API is running....');
  });
}

这将从 frontend/dist 文件夹中提供静态文件,并为其他路由提供 index.html 文件。API 路由仍将正常工作。请确保如果你使用 CRA,则将 frontend/dist 替换为 frontend/build

你可以通过将 .env 文件中的 NODE_ENV 变量设置为 production,然后使用 npm start 运行服务器并在浏览器中打开 localhost:5000 来测试此功能。你应该看到 React 应用程序。这是你的生产版本

就是这样!现在你已经成功实现了一个基本的身份验证系统,其中包括用户注册、登录、个人资料和注销功能。

原文:MERN 速成课程(第 2 部分)——React 前端 UI,状态管理 与 Redux工具包

1

评论 (0)

取消