diff --git a/src/api/fetcher.ts b/src/api/fetcher.ts index 930117a..a7468b0 100644 --- a/src/api/fetcher.ts +++ b/src/api/fetcher.ts @@ -4,6 +4,7 @@ import { HTTPSTATUS } from "./types"; type FetcherOptions = RequestInit & { headers?: HeadersInit; + cookies?: Record; }; type FetcherResponse = { @@ -40,6 +41,23 @@ const handleError = async (response: Response) => { throw new Error(`HTTP error! status: ${response.status}`); }; +// 쿠키를 헤더에 추가 +const addCookiesToHeaders = ( + headers: HeadersInit = {}, + cookies?: Record +): HeadersInit => { + if (!cookies) return headers; + + const cookieString = Object.entries(cookies) + .map(([key, value]) => `${key}=${value}`) + .join("; "); + + return { + ...headers, + Cookie: cookieString, + }; +}; + // 데이터를 받지 않는 요청 (GET, DELETE) const requestWithoutData = async ( url: string, @@ -49,10 +67,13 @@ const requestWithoutData = async ( const fetchOptions: FetcherOptions = { ...options, method, - headers: { - "Content-Type": "application/json", - ...(options.headers || {}), - }, + headers: addCookiesToHeaders( + { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + options.cookies + ), }; const response = await fetch(url, fetchOptions); @@ -75,10 +96,13 @@ const requestWithData = async ( const fetchOptions: FetcherOptions = { ...options, method, - headers: { - "Content-Type": "application/json", - ...(options?.headers || {}), - }, + headers: addCookiesToHeaders( + { + "Content-Type": "application/json", + ...(options?.headers || {}), + }, + options?.cookies + ), ...(data ? { body: JSON.stringify(data) } : {}), }; @@ -157,6 +181,10 @@ export const proxyFetcher = createFetcher(`${BASE_API_URL}/api`, { credentials: "include", }); +export const clientFetcher = createFetcher(`${CLIENT_URL}/api`, { + credentials: "include", +}); + export const fetcherWithoutCredentials = createFetcher(`${BASE_API_URL}/api`, { credentials: "omit", }); diff --git a/src/components/Header/desktop/HeaderTopD.tsx b/src/components/Header/desktop/HeaderTopD.tsx index 6f4943a..b19a6ac 100644 --- a/src/components/Header/desktop/HeaderTopD.tsx +++ b/src/components/Header/desktop/HeaderTopD.tsx @@ -6,20 +6,19 @@ import { PortfoliosDropdown } from "@/components/PortfoliosDropdown/PortfoliosDr import SearchBarD from "@/components/SearchBar/desktop/SearchBarD"; import Routes from "@/constants/Routes"; import { MAIN_HEADER_HEIGHT_D } from "@/constants/styleConstants"; +import useAuthStatusQuery from "@/features/auth/api/queries/useAuthStatusQuery"; import UserControls from "@/features/user/components/desktop/UserControls"; -import { UserContext } from "@/features/user/context/UserContext"; import Image from "next/image"; import Link from "next/link"; -import { useContext } from "react"; import styled from "styled-components"; export default function HeaderTopD() { - const { user } = useContext(UserContext); + const { data: isLoggedIn } = useAuthStatusQuery(); const navItems = [ { name: "Watchlists", - to: user ? Routes.WATCHLISTS : Routes.SIGNIN, + to: isLoggedIn ? Routes.WATCHLISTS : Routes.SIGNIN, }, { name: "Indices", to: Routes.INDICES("KRX:KOSPI") }, ]; @@ -28,7 +27,7 @@ export default function HeaderTopD() { + href={isLoggedIn ? Routes.DASHBOARD : Routes.LANDING}> FineAnts @@ -43,7 +42,7 @@ export default function HeaderTopD() { - {user ? ( + {isLoggedIn ? ( ) : ( diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2c6e620..b930c37 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,18 +1,15 @@ import Routes from "@/constants/Routes"; -import dynamic from "next/dynamic"; import { usePathname } from "next/navigation"; import { ReactNode } from "react"; +import Header from "./Header/Header"; -const Header = dynamic(import("./Header/Header"), { - ssr: false, -}); +type LayoutProps = { children: ReactNode }; -export default function Layout({ children }: { children: ReactNode }) { +export default function Layout({ children }: LayoutProps) { const pathname = usePathname(); - // TODO : Layout을 제외해야 하는 페이지가 더 늘어난다면 개선하기 if (pathname === Routes.SIGNIN || pathname === Routes.SIGNUP) { - return children; + return <>{children}; } return ( diff --git a/src/features/auth/api/apiRoutes.ts b/src/features/auth/api/apiRoutes.ts new file mode 100644 index 0000000..5f74c8c --- /dev/null +++ b/src/features/auth/api/apiRoutes.ts @@ -0,0 +1,9 @@ +import { clientFetcher } from "@/api/fetcher"; +import { Response } from "@/api/types"; + +export const getAuthStatus = async (cookies?: Record) => { + const res = await clientFetcher.get>("/authStatus", { + cookies, + }); + return res.data; +}; diff --git a/src/features/auth/api/queries/queryKeys.ts b/src/features/auth/api/queries/queryKeys.ts new file mode 100644 index 0000000..4324018 --- /dev/null +++ b/src/features/auth/api/queries/queryKeys.ts @@ -0,0 +1,5 @@ +import { createQueryKeys } from "@lukemorales/query-key-factory"; + +export const authKeys = createQueryKeys("auth", { + authStatus: null, +}); diff --git a/src/features/auth/api/queries/useAuthStatusQuery.ts b/src/features/auth/api/queries/useAuthStatusQuery.ts new file mode 100644 index 0000000..b55f863 --- /dev/null +++ b/src/features/auth/api/queries/useAuthStatusQuery.ts @@ -0,0 +1,14 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { getAuthStatus } from "../apiRoutes"; +import { authKeys } from "./queryKeys"; + +export default function useAuthStatusQuery(cookies?: Record) { + return useSuspenseQuery({ + queryKey: authKeys.authStatus.queryKey, + queryFn: () => getAuthStatus(cookies), + select: (res) => res.data, + retry: 0, + gcTime: Infinity, + staleTime: Infinity, + }); +} diff --git a/src/features/auth/api/queries/useSignInMutation.ts b/src/features/auth/api/queries/useSignInMutation.ts index 1068494..ff490b7 100644 --- a/src/features/auth/api/queries/useSignInMutation.ts +++ b/src/features/auth/api/queries/useSignInMutation.ts @@ -1,30 +1,28 @@ import Routes from "@/constants/Routes"; -import { getUser } from "@/features/user/api"; -import { UserContext } from "@/features/user/context/UserContext"; -import { useMutation } from "@tanstack/react-query"; +import { userKeys } from "@/features/user/api/queries/queryKeys"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/router"; -import { useContext } from "react"; import { postSignIn } from "../index"; +import { authKeys } from "./queryKeys"; export default function useSignInMutation() { const router = useRouter(); - const { onSignOut, onGetUser } = useContext(UserContext); + const queryClient = useQueryClient(); return useMutation({ mutationFn: postSignIn, onSuccess: async () => { try { - const { - data: { user }, - } = await getUser(); - - onGetUser(user); - router.push(Routes.DASHBOARD); + queryClient.invalidateQueries({ + queryKey: authKeys.authStatus.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: userKeys.userInfo.queryKey, + }); } catch (error) { // eslint-disable-next-line no-console console.error("Failed to fetch user data"); - onSignOut(); router.push(Routes.SIGNIN); } }, diff --git a/src/features/auth/api/queries/useSignOutMutation.ts b/src/features/auth/api/queries/useSignOutMutation.ts index 394b4d9..6e6e776 100644 --- a/src/features/auth/api/queries/useSignOutMutation.ts +++ b/src/features/auth/api/queries/useSignOutMutation.ts @@ -1,19 +1,24 @@ import Routes from "@/constants/Routes"; -import { UserContext } from "@/features/user/context/UserContext"; -import { useMutation } from "@tanstack/react-query"; +import { userKeys } from "@/features/user/api/queries/queryKeys"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/router"; -import { useContext } from "react"; import { postSignOut } from ".."; +import { authKeys } from "./queryKeys"; export default function useSignOutMutation() { const router = useRouter(); - const { onSignOut } = useContext(UserContext); + const queryClient = useQueryClient(); return useMutation({ mutationFn: postSignOut, onSuccess: () => { - onSignOut(); router.push(Routes.LANDING); + queryClient.invalidateQueries({ + queryKey: authKeys.authStatus.queryKey, + }); + queryClient.invalidateQueries({ + queryKey: userKeys.userInfo.queryKey, + }); }, meta: { toastErrorMessage: "로그아웃을 다시 시도해주세요", diff --git a/src/features/user/api/index.ts b/src/features/user/api/index.ts index 07081b1..698ab9d 100644 --- a/src/features/user/api/index.ts +++ b/src/features/user/api/index.ts @@ -2,8 +2,10 @@ import { fetcher } from "@/api/fetcher"; import { Response } from "@/api/types"; import { User } from "./types"; -export const getUser = async () => { - const res = await fetcher.get>("/profile"); +export const getUser = async (cookies?: Record) => { + const res = await fetcher.get>("/profile", { + cookies, + }); return res.data; }; diff --git a/src/features/user/api/queries/queryKeys.ts b/src/features/user/api/queries/queryKeys.ts new file mode 100644 index 0000000..2c0cd57 --- /dev/null +++ b/src/features/user/api/queries/queryKeys.ts @@ -0,0 +1,5 @@ +import { createQueryKeys } from "@lukemorales/query-key-factory"; + +export const userKeys = createQueryKeys("user", { + userInfo: null, +}); diff --git a/src/features/user/api/queries/useUserQuery.ts b/src/features/user/api/queries/useUserQuery.ts new file mode 100644 index 0000000..cc41c50 --- /dev/null +++ b/src/features/user/api/queries/useUserQuery.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { getUser } from ".."; +import { userKeys } from "./queryKeys"; + +export default function useUserQuery(cookies?: Record) { + return useQuery({ + queryKey: userKeys.userInfo.queryKey, + queryFn: () => getUser(cookies), + select: (res) => res.data.user, + retry: 0, + gcTime: Infinity, + staleTime: Infinity, + }); +} diff --git a/src/features/user/components/desktop/UserControls.tsx b/src/features/user/components/desktop/UserControls.tsx index 2d54776..b276645 100644 --- a/src/features/user/components/desktop/UserControls.tsx +++ b/src/features/user/components/desktop/UserControls.tsx @@ -1,7 +1,6 @@ import styled from "styled-components"; import UserDropdown from "./UserDropdown"; -// export default function UserControls({ user }: { user: User }) { export default function UserControls() { return ( diff --git a/src/features/user/components/desktop/UserDropdown.tsx b/src/features/user/components/desktop/UserDropdown.tsx index 38ad6bf..680c7a0 100644 --- a/src/features/user/components/desktop/UserDropdown.tsx +++ b/src/features/user/components/desktop/UserDropdown.tsx @@ -6,13 +6,13 @@ import designSystem, { parseFontString } from "@/styles/designSystem"; import { Divider } from "@mui/material"; import Image from "next/image"; import Link from "next/link"; -import { MouseEvent, useContext } from "react"; +import { MouseEvent } from "react"; import styled from "styled-components"; -import { UserContext } from "../../context/UserContext"; +import useUserQuery from "../../api/queries/useUserQuery"; import UserProfileButton from "../UserProfileButton"; export default function UserDropdown() { - const { user } = useContext(UserContext); + const { data: user } = useUserQuery(); const { mutate: signOutMutate } = useSignOutMutation(); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 0855a10..ffc3b36 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,9 +1,17 @@ import Layout from "@/components/Layout"; -import { UserProvider } from "@/features/user/context/UserContext"; +import { getAuthStatus } from "@/features/auth/api/apiRoutes"; +import { authKeys } from "@/features/auth/api/queries/queryKeys"; +import { getUser } from "@/features/user/api"; +import { userKeys } from "@/features/user/api/queries/queryKeys"; import GlobalStyles from "@/styles/GlobalStyles"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + HydrationBoundary, + QueryClient, + QueryClientProvider, + dehydrate, +} from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import type { AppProps } from "next/app"; +import type { AppContext, AppInitialProps, AppProps } from "next/app"; import localFont from "next/font/local"; const ibmPlexSansKR = localFont({ @@ -37,44 +45,67 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, }, }, - - // TODO : api 환경 설정 끝나고 toast 컴포넌트와 함께 적용하기 - // queryCache: new QueryCache({ - // onError: (_, query) => { - // if (query.meta?.toastErrorMessage) { - // toast.error(query.meta.toastErrorMessage as string); - // return; - // } - // }, - // }), - // mutationCache: new MutationCache({ - // onSuccess: (_, __, ___, mutation) => { - // if (mutation.meta?.toastSuccessMessage) { - // toast.success(mutation.meta.toastSuccessMessage as string); - // return; - // } - // }, - // onError: (_, __, ___, mutation) => { - // if (mutation.meta?.toastErrorMessage) { - // toast.error(mutation.meta.toastErrorMessage as string); - // return; - // } - // }, - // }), + // TODO : api 환경 설정 끝나고 toast 컴포넌트와 함께 CUD 피드백 적용하기 }); export default function App({ Component, pageProps }: AppProps) { return ( - +
-
- + +
); } + +App.getInitialProps = async ( + appContext: AppContext +): Promise => { + const { ctx, Component } = appContext; + + if (ctx.req) { + const cookies = ctx.req.headers.cookie || ""; + const cookiesObject = cookies.split(";").reduce( + (acc, cookie) => { + const [key, value] = cookie.split("=").map((v) => v.trim()); + acc[key] = value; + return acc; + }, + {} as Record + ); + + const hasAccessToken = !!cookiesObject["accessToken"]; + const hasRefreshToken = !!cookiesObject["refreshToken"]; + + await queryClient.prefetchQuery({ + queryKey: authKeys.authStatus.queryKey, + queryFn: () => getAuthStatus(cookiesObject), + gcTime: Infinity, + staleTime: Infinity, + }); + + if (hasAccessToken && hasRefreshToken) { + await queryClient.prefetchQuery({ + queryKey: userKeys.userInfo.queryKey, + queryFn: () => getUser(cookiesObject), + gcTime: Infinity, + staleTime: Infinity, + }); + } + } + + let pageProps = {}; + if (Component.getInitialProps) { + pageProps = await Component.getInitialProps(ctx); + } + + return { + pageProps, + }; +}; diff --git a/src/pages/api/authStatus.ts b/src/pages/api/authStatus.ts new file mode 100644 index 0000000..be35085 --- /dev/null +++ b/src/pages/api/authStatus.ts @@ -0,0 +1,8 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + const { accessToken, refreshToken } = req.cookies; + const hasTokens = !!accessToken && !!refreshToken; + + res.status(200).json({ data: hasTokens }); +}