目前,当涉及到管理控制台中的用户身份验证时,应用程序仍然依赖于测试数据。在本节中,我们将构建应用程序的身份验证系统,允许用户认证并访问受保护的资源在管理控制台中。我们还将创建一个 toast 通知系统,以便在发生我们希望通知用户的操作时向他们提供反馈。
大致流程如下:
/auth/login
发起请求httpOnly cookie
,从此时起用于身份验证请求react-query
缓存中,并使其对应用程序可用cookie
的,带有 httpOnly cookie
,因此我们不需要在前端处理身份验证令牌,任何后续请求都将自动包括令牌/auth/me
接口将处理页面刷新后的用户数据持久化,该接口将获取用户数据并将其存储在相同的 react-query
缓存中为了实现此系统,我们需要以下内容:
// 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 来处理登录请求:
// 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>
);
};
// 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 来处理注销请求:
// 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>
);
};
// 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 来获取用户数据:
// src/layouts/dashboard-layout.tsx
import { useLogout, useUser } from "@/features/auth";
另外,在 src/pages/dashboard/jobs/index.tsx
中,我们将使用 useUser
hook 来获取用户数据:
// src/pages/dashboard/jobs/index.tsx
import { useUser } from "@/features/auth";
如果未经身份验证的用户尝试查看受保护的资源,应该发生什么?我们希望确保任何这样的尝试都将重定向用户到登录页面。为此,我们要创建一个组件,它将包装受保护的资源,并允许用户查看受保护的内容,只有在他们经过身份验证的情况下才能访问。
// 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}</>;
};
在管理面板中使用:
// 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,这是一个轻量级且非常简单易用的状态管理库。
// 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);
// 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
中集成:
// 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
钩子来显示通知:
// 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 支持拦截器,而且我们已经对其进行了配置,因此我们只需要修改响应错误拦截器即可。
// 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)。