前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
社区首页 >专栏 >React 应用架构实战 0x6:实现用户认证和全局通知

React 应用架构实战 0x6:实现用户认证和全局通知

作者头像
Cellinlab
发布2023-05-17 21:01:57
发布2023-05-17 21:01:57
1.6K00
代码可运行
举报
文章被收录于专栏:Cellinlab's BlogCellinlab's Blog
运行总次数:0
代码可运行

目前,当涉及到管理控制台中的用户身份验证时,应用程序仍然依赖于测试数据。在本节中,我们将构建应用程序的身份验证系统,允许用户认证并访问受保护的资源在管理控制台中。我们还将创建一个 toast 通知系统,以便在发生我们希望通知用户的操作时向他们提供反馈。

# 身份验证系统

# 流程梳理

大致流程如下:

  1. 用户通过提交登录表单并携带登录凭证向 /auth/login 发起请求
  2. 如果用户存在且凭证有效,则返回包含用户数据的响应。除了响应数据之外,还将附加一个 httpOnly cookie,从此时起用于身份验证请求
  3. 每当用户进行身份验证时,我们将从响应中的用户对象存储在 react-query 缓存中,并使其对应用程序可用
  4. 由于身份验证是基于 cookie 的,带有 httpOnly cookie,因此我们不需要在前端处理身份验证令牌,任何后续请求都将自动包括令牌
  5. 调用 /auth/me 接口将处理页面刷新后的用户数据持久化,该接口将获取用户数据并将其存储在相同的 react-query 缓存中

为了实现此系统,我们需要以下内容:

  • 认证功能(登录、注销和访问已认证用户)
  • 保护需要用户进行身份验证的资源

# 功能实现

# 登录
代码语言:javascript
代码运行次数:0
运行
复制
// src/features/auth/api/login.ts
import { useMutation } from "@tanstack/react-query";

import { apiClient } from "@/lib/api-client";
import { queryClient } from "@/lib/react-query";

import type { AuthUser, LoginData } from "@/features/auth/types";

export const login = (
  data: LoginData
): Promise<{
  user: AuthUser;
}> => {
  return apiClient.post("/auth/login", data);
};

type UseLoginOptions = {
  onSuccess?: (user: AuthUser) => void;
};

export const useLogin = ({ onSuccess }: UseLoginOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: login,
    onSuccess: ({ user }) => {
      queryClient.setQueryData(["auth-user"], user);
      onSuccess?.(user);
    },
  });

  return {
    submit,
    isLoading,
  };
};

在登录表单中,我们将使用 useLogin hook 来处理登录请求:

代码语言:javascript
代码运行次数:0
运行
复制
// src/features/auth/components/login-form/login-form.tsx
import { Stack } from "@chakra-ui/react";
import { useForm } from "react-hook-form";

import { Button } from "@/components/button";
import { InputField } from "@/components/form";

import { useLogin } from "../../api/login";
import type { LoginData } from "../../types";

export type LoginFormProps = {
  onSuccess: () => void;
};

export const LoginForm = ({ onSuccess }: LoginFormProps) => {
  const login = useLogin({ onSuccess });

  const { register, handleSubmit, formState } = useForm<LoginData>();

  const onSubmit = (data: LoginData) => {
    login.submit(data);
  };

  return (
    <Stack as="form" onSubmit={handleSubmit(onSubmit)} spacing="5" w="full">
      <InputField
        label="Email"
        type="email"
        {...register("email", {
          required: "Email is required",
        })}
        error={formState.errors["email"]}
      />
      <InputField
        label="Password"
        type="password"
        {...register("password", {
          required: "Password is required",
        })}
        error={formState.errors["password"]}
      />
      <Button type="submit" isLoading={login.isLoading} isDisabled={login.isLoading}>
        Log in
      </Button>
    </Stack>
  );
};

# 登出
代码语言:javascript
代码运行次数:0
运行
复制
// src/features/auth/api/logout.ts
import { useMutation } from "@tanstack/react-query";

import { apiClient } from "@/lib/api-client";
import { queryClient } from "@/lib/react-query";

export const logout = () => {
  return apiClient.post("/auth/logout");
};

type UseLogoutOptions = {
  onSuccess?: () => void;
};

export const useLogout = ({ onSuccess }: UseLogoutOptions = {}) => {
  const { mutate: submit, isLoading } = useMutation({
    mutationFn: logout,
    onSuccess: () => {
      queryClient.clear();
      onSuccess?.();
    },
  });

  return {
    submit,
    isLoading,
  };
};

在登出按钮中,我们将使用 useLogout hook 来处理注销请求:

代码语言:javascript
代码运行次数:0
运行
复制
// src/layouts/dashboard-layout.tsx
import { useRouter } from "next/router";
import { useLogout } from "@/features/auth";

const Navbar = () => {
  const router = useRouter();
  const logout = useLogout({
    onSuccess: () => {
      router.push("/auth/login");
    },
  });
  // ...

  return (
    <Box>
      <__x>
        <Button
          variant="outline"
          isDisabled={logout.isLoading}
          isLoading={logout.isLoading}
          onClick={() => logout.submit()}
        >
          Log Out
        </Button>
      </__x>
    </Box>
  );
};

# 访问已认证用户
代码语言:javascript
代码运行次数:0
运行
复制
// src/features/auth/api/get-auth-user.ts
import { useQuery } from "@tanstack/react-query";

import { apiClient } from "@/lib/api-client";
import type { AuthUser } from "../types";

export const getAuthUser = (): Promise<AuthUser> => {
  return apiClient.get("/auth/me");
};

export const useUser = () => {
  const { data, isLoading } = useQuery({
    queryKey: ["auth-user"],
    queryFn: () => getAuthUser(),
  });

  return {
    data,
    isLoading,
  };
};

在布局组件中,我们将使用 useUser hook 来获取用户数据:

代码语言:javascript
代码运行次数:0
运行
复制
// src/layouts/dashboard-layout.tsx
import { useLogout, useUser } from "@/features/auth";

另外,在 src/pages/dashboard/jobs/index.tsx 中,我们将使用 useUser hook 来获取用户数据:

代码语言:javascript
代码运行次数:0
运行
复制
// src/pages/dashboard/jobs/index.tsx
import { useUser } from "@/features/auth";

# 保护需要用户进行身份验证的资源

如果未经身份验证的用户尝试查看受保护的资源,应该发生什么?我们希望确保任何这样的尝试都将重定向用户到登录页面。为此,我们要创建一个组件,它将包装受保护的资源,并允许用户查看受保护的内容,只有在他们经过身份验证的情况下才能访问。

代码语言:javascript
代码运行次数:0
运行
复制
// src/features/auth/components/protected/protected.tsx
import { useEffect } from "react";
import type { ReactNode } from "react";
import { useRouter } from "next/router";
import { Flex } from "@chakra-ui/react";

import { Loading } from "@/components/loading";

import { useUser } from "../../api/get-auth-user";

export type ProtectedProps = {
  children: ReactNode;
};

export const Protected = ({ children }: ProtectedProps) => {
  const { replace, asPath } = useRouter();
  const user = useUser();

  useEffect(() => {
    if (!user.data && !user.isLoading) {
      replace(`/auth/login?redirect=${asPath}`, undefined, { shallow: true });
    }
  }, [user, replace, asPath]);

  if (user.isLoading) {
    return (
      <Flex direction="column" justify="center" h="full">
        <Loading />
      </Flex>
    );
  }

  if (!user.data && !user.isLoading) return null;

  return <>{children}</>;
};

在管理面板中使用:

代码语言:javascript
代码运行次数:0
运行
复制
// src/layouts/dashboard-layout.tsx
import { useLogout, useUser, Protected } from "@/features/auth";

export const DashboardLayout = ({ children }: DashboardLayoutProps) => {
  const user = useUser();

  return (
    <Protected>
      <Box as="section" h="100vh" overflow="auto">
        <Navbar />
        <Container as="main" maxW="container.lg" py="12">
          {children}
        </Container>
        <Box py="8" textAlign="center">
          <Link href={`/organizations/${user.data?.organizationId}`}>
            View Public Organization Page
          </Link>
        </Box>
      </Box>
    </Protected>
  );
};

更多细节,可以参考cellinlab/next-jobs-app (opens new window)

# 通知提示

每当应用程序有事情发生,例如表单成功提交或 API 请求失败,我们都希望通知用户。

我们需要创建一个全局存储,用于跟踪所有通知。我们希望它是全局的,因为我们想从应用程序的任何地方显示这些通知。

为了处理全局状态,我们将使用 Zustand,这是一个轻量级且非常简单易用的状态管理库。

# 创建 store

代码语言:javascript
代码运行次数:0
运行
复制
// src/stores/notifications/notifications.ts
import { createStore, useStore } from "zustand";

import { uid } from "@/utils/uid";

export type NotificationType = "info" | "warning" | "success" | "error";

export type Notification = {
  id: string;
  type: NotificationType;
  title: string;
  duration?: number;
  message?: string;
};

export type NotificationsStore = {
  notifications: Notification[];
  showNotification: (notification: Omit<Notification, "id">) => void;
  dismissNotification: (id: string) => void;
};

export const notificationsStore = createStore<NotificationsStore>((set, get) => ({
  notifications: [],
  showNotification: (notification) => {
    const id = uid();
    set((state) => ({
      notifications: [
        ...state.notifications,
        {
          id,
          ...notification,
        },
      ],
    }));

    if (notification.duration) {
      setTimeout(() => {
        get().dismissNotification(id);
      }, notification.duration);
    }
  },
  dismissNotification: (id) => {
    set((state) => ({
      notifications: state.notifications.filter((n) => n.id !== id),
    }));
  },
}));

export const useNotifications = () => useStore(notificationsStore);

# 创建 UI

代码语言:javascript
代码运行次数:0
运行
复制
// src/components/notifications/notifications.tsx
import { Flex, Box, CloseButton, Stack, Text } from "@chakra-ui/react";

import type { Notification, NotificationType } from "@/stores/notifications";
import { useNotifications } from "@/stores/notifications";

export const Notifications = () => {
  const { notifications, dismissNotification } = useNotifications();

  if (notifications.length < 1) return null;

  return (
    <Box as="section" p="4" position="fixed" top="12" right="0" zIndex="1">
      <Flex gap="4" direction="column-reverse">
        {notifications.map((notification: Notification) => (
          <NotificationToast
            key={notification.id}
            notification={notification}
            onDismiss={dismissNotification}
          />
        ))}
      </Flex>
    </Box>
  );
};

const notificationVariants: Record<NotificationType, { color: string }> = {
  info: {
    color: "primary",
  },
  success: {
    color: "green",
  },
  warning: {
    color: "orange",
  },
  error: {
    color: "red",
  },
};

type NotificationToastProps = {
  notification: Omit<Notification, "duration">;
  onDismiss: (id: string) => void;
};

const NotificationToast = ({ notification, onDismiss }: NotificationToastProps) => {
  const { id, type, title, message } = notification;

  return (
    <Box
      w={{
        base: "full",
        sm: "md",
      }}
      boxShadow="md"
      bg="white"
      borderRadius="lg"
      {...notificationVariants[type]}
    >
      <Stack direction="row" p="4" spacing="3" justifyContent="space-between">
        <Stack spacing="2.5">
          <Stack spacing="1">
            <Text fontWeight="medium" fontSize="sm">
              {title}
            </Text>
            {notification.message && (
              <Text fontSize="sm" color="muted">
                {message}
              </Text>
            )}
          </Stack>
        </Stack>
        <CloseButton onClick={() => onDismiss(id)} transform="translateY(-6px)" />
      </Stack>
    </Box>
  );
};

# 集成和使用

src/providers/app.tsx 中集成:

代码语言:javascript
代码运行次数:0
运行
复制
// src/providers/app.tsx
import { ReactNode } from "react";
import { ChakraProvider, GlobalStyle } from "@chakra-ui/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ErrorBoundary } from "react-error-boundary";

import { theme } from "@/config/theme";
import { queryClient } from "@/lib/react-query";
import { Notifications } from "@/components/notifications";

type AppProviderProps = {
  children: ReactNode;
};

export const AppProvider = ({ children }: AppProviderProps) => {
  return (
    <ChakraProvider theme={theme}>
      <ErrorBoundary fallback={<div>Something went wrong</div>} onError={console.error}>
        <GlobalStyle />
        <Notifications />
        <QueryClientProvider client={queryClient}>
          <ReactQueryDevtools initialIsOpen={false} />
          {children}
        </QueryClientProvider>
      </ErrorBoundary>
    </ChakraProvider>
  );
};

现在,可以在任何地方使用 useNotifications 钩子来显示通知:

代码语言:javascript
代码运行次数:0
运行
复制
// src/pages/dashboard/jobs/create.tsx

import { useNotifications } from "@/stores/notifications";

const DashboardCreateJobPage = () => {
  const { showNotification } = useNotifications();

  const onSuccess = () => {
    showNotification({
      type: "success",
      title: "Job created",
      message: "Your job has been created successfully",
      duration: 5000,
    });
    router.push("/dashboard/jobs");
  };

  return (
    <>
      <!-- ... -->
    </>
  );
};

export default DashboardCreateJobPage;

提交成功后可以看到通知:

另一个可以利用通知的地方是 API 错误处理。每当发生 API 错误时,我们希望让用户知道发生了错误。

我们可以在 API Client 别处理它。由于 Axios 支持拦截器,而且我们已经对其进行了配置,因此我们只需要修改响应错误拦截器即可。

代码语言:javascript
代码运行次数:0
运行
复制
// src/lib/api-client.ts
import Axios from "axios";

import { API_URL } from "@/config/constants";
import { notificationsStore } from "@/stores/notifications";

export const apiClient = Axios.create({
  baseURL: API_URL,
  headers: {
    "Content-Type": "application/json",
  },
});

apiClient.interceptors.response.use(
  (resp) => {
    return resp.data;
  },
  (err) => {
    const message = err?.response?.data?.message || err.message;
    notificationsStore.getState().showNotification({
      type: "error",
      title: "Error",
      message,
      duration: 5000,
    });
    return Promise.reject(message);
  }
);

更多细节可以参考 源码 (opens new window)

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023/1/17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • # 身份验证系统
    • # 流程梳理
    • # 功能实现
      • # 登录
      • # 登出
      • # 访问已认证用户
      • # 保护需要用户进行身份验证的资源
  • # 通知提示
    • # 创建 store
    • # 创建 UI
    • # 集成和使用
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档