diff --git a/src/app/Router.tsx b/src/app/Router.tsx index cb879151..f3b4908c 100644 --- a/src/app/Router.tsx +++ b/src/app/Router.tsx @@ -3,7 +3,8 @@ import { createBrowserRouter, Outlet, RouterProvider } from 'react-router'; import { ROUTE } from '@/shared/config/route'; import RouteErrorBoundary from '@/app/RouteErrorBoundary'; import RouteErrorElement from '@/app/RouteErrorElement'; -import checkAuth from '@/entities/auth/lib/checkAuth'; +import checkAuth from '@/features/auth/lib/checkAuth'; +import checkAlreadyAuthLoader from '@/features/auth/lib/checkAlreadyAuthLoader'; import groupTokenUrlLoader from '@/entities/auth/lib/groupTokenUrlLoader'; import createExpensePageGuardLoader from '@/pages/CreateExpensePage/lib/createExpensePageGuardLoader'; import joinLoader from '@/pages/join/loader'; @@ -87,6 +88,7 @@ function AppRouter() { { path: ROUTE.login, element: , + loader: checkAlreadyAuthLoader, }, { id: 'protected', diff --git a/src/entities/auth/api/auth.ts b/src/entities/auth/api/auth.ts index b1b3c42b..c4c7527a 100644 --- a/src/entities/auth/api/auth.ts +++ b/src/entities/auth/api/auth.ts @@ -1,5 +1,5 @@ import axiosInstance from '@/shared/api/axios'; -import { User } from '../model/user.type'; +import { AuthCheckResponse, User } from '../model/user.type'; // CHECK - 게스트 토큰 정책 제거 가능성 있음 export interface GuestTokenData { @@ -16,17 +16,12 @@ export const getGuestToken = async (): Promise => { // ========== -export const getAuth = async () => { - const response = await axiosInstance.get('/user/auth/check', { - useMock: true, - }); +export const getAuth = async (): Promise => { + const response = await axiosInstance.get('/auth/check'); return response.data; }; export const getUserInfo = async () => { - const response = await axiosInstance.get('/user', { - useMock: true, - }); - + const response = await axiosInstance.get('/user'); return response.data; }; diff --git a/src/entities/auth/api/useGetGuestToken.ts b/src/entities/auth/api/useGetGuestToken.ts deleted file mode 100644 index 81c4a4d8..00000000 --- a/src/entities/auth/api/useGetGuestToken.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useNavigate } from 'react-router'; -import { useQuery } from '@tanstack/react-query'; -import { ROUTE } from '@/shared/config/route'; -import { getGuestToken } from './auth'; - -export const useGetGuestToken = () => { - const navigate = useNavigate(); - return useQuery({ - queryKey: ['guestToken'], - queryFn: async () => { - const response = await getGuestToken(); - if (response?.accessToken) { - localStorage.setItem('accessToken', `Bearer ${response?.accessToken}`); - navigate(ROUTE.selectGroup); - return response; - } - throw new Error('Access Token not found'); - }, - enabled: false, // refetch가 호출될 때만 실행되도록 설정 - }); -}; diff --git a/src/entities/auth/lib/checkAuth.ts b/src/entities/auth/lib/checkAuth.ts deleted file mode 100644 index 3f873918..00000000 --- a/src/entities/auth/lib/checkAuth.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { redirect } from 'react-router'; -import { ROUTE } from '@/shared/config/route'; -import { queryClient } from '@/shared/api/queryClient'; -import { getAuth } from '../api/auth'; - -/** - * 페이지에 접근하기 전에 실행되는 함수 - * */ -const checkAuth = async () => { - try { - const user = await queryClient.ensureQueryData({ - queryKey: ['auth', 'user'], - queryFn: getAuth, - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - }); - - if (!user || !user.authenticated) { - throw new Error('Unauthorized'); - } - - return user; - } catch { - // NOTE - 로그인 성공 후 이전 페이지로 돌아가기 위한 로직 - // const redirectTo = new URL(request.url).pathname; - // return redirect(`${ROUTE.login}?redirectTo=${redirectTo}`); - return redirect(ROUTE.login); - } -}; - -export default checkAuth; diff --git a/src/entities/auth/lib/kakaoLogin.ts b/src/entities/auth/lib/kakaoLogin.ts index f9e3ab4e..e835e672 100644 --- a/src/entities/auth/lib/kakaoLogin.ts +++ b/src/entities/auth/lib/kakaoLogin.ts @@ -1,16 +1,23 @@ +import getSafeRedirectPath from '@/shared/lib/getSafeRedirectPath'; + const KAKAO_CLIENT_ID = import.meta.env.VITE_KAKAO_CLIENT_ID; const KAKAO_REDIRECT_URI = import.meta.env.VITE_KAKAO_REDIRECT_URI; -function kakaoLogin(url?: string) { +/** + * 카카오 소셜 로그인 페이지로 이동한다. + * @param redirectPathAfterLogin - 로그인 완료 후 돌아올 경로 (pathname). 미전달 시 origin(루트)으로 이동. + * state 파라미터에 완전한 URL을 담아 전달하며, 백엔드가 로그인 완료 후 해당 URL로 redirect한다. + */ +function kakaoLogin(redirectPathAfterLogin?: string) { if (!KAKAO_CLIENT_ID || !KAKAO_REDIRECT_URI) { throw new Error('카카오 OAuth에 필요한 환경 변수가 설정되지 않았습니다.'); } - const defaultRedirectUrl = window.location.origin; - const redirectUrl = url || defaultRedirectUrl; + const safePath = getSafeRedirectPath(redirectPathAfterLogin); + const stateUrl = `${window.location.origin}${safePath}`; window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${KAKAO_CLIENT_ID}&redirect_uri=${KAKAO_REDIRECT_URI}&response_type=code&state=${encodeURIComponent( - redirectUrl + stateUrl )}`; } diff --git a/src/entities/auth/model/user.type.ts b/src/entities/auth/model/user.type.ts index 940fb3f3..5e394aa6 100644 --- a/src/entities/auth/model/user.type.ts +++ b/src/entities/auth/model/user.type.ts @@ -1,6 +1,14 @@ export interface User { - id: number; email: string; name: string; - profileImageUrl?: string; + profile?: string; +} + +export interface AuthCheckResponse { + authenticated: boolean; + user?: { + id: number; + role: string; + }; + reason?: string; } diff --git a/src/features/auth/api/authApi.ts b/src/features/auth/api/authApi.ts index c7bf8b26..62ea9abd 100644 --- a/src/features/auth/api/authApi.ts +++ b/src/features/auth/api/authApi.ts @@ -1,9 +1,5 @@ import axiosInstance from '@/shared/api/axios'; -export const logout = () => - axiosInstance.post('/user/logout', null, { useMock: true }); +export const logout = () => axiosInstance.post('/logout'); -export const unregister = () => - axiosInstance.delete('/users/me', { - useMock: true, - }); +export const unregister = () => axiosInstance.delete('/unlink'); diff --git a/src/features/auth/lib/checkAlreadyAuthLoader.ts b/src/features/auth/lib/checkAlreadyAuthLoader.ts new file mode 100644 index 00000000..0d1b39d6 --- /dev/null +++ b/src/features/auth/lib/checkAlreadyAuthLoader.ts @@ -0,0 +1,31 @@ +import { redirect } from 'react-router'; +import type { LoaderFunctionArgs } from 'react-router'; +import { queryClient } from '@/shared/api/queryClient'; +import { getAuth } from '@/entities/auth/api/auth'; +import getSafeRedirectPath from '@/shared/lib/getSafeRedirectPath'; + +/** + * 로그인 페이지 진입 전에 실행되는 loader + * 이미 인증된 상태라면 redirectTo 또는 홈으로 redirect한다. + */ +const checkAlreadyAuthLoader = async ({ request }: LoaderFunctionArgs) => { + try { + const user = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + if (user?.authenticated) { + const redirectTo = new URL(request.url).searchParams.get('redirectTo'); + return redirect(getSafeRedirectPath(redirectTo)); + } + } catch { + // 인증 실패 시 로그인 페이지 렌더링 + } + + return null; +}; + +export default checkAlreadyAuthLoader; diff --git a/src/features/auth/lib/checkAuth.ts b/src/features/auth/lib/checkAuth.ts new file mode 100644 index 00000000..733aa83d --- /dev/null +++ b/src/features/auth/lib/checkAuth.ts @@ -0,0 +1,30 @@ +import { redirect } from 'react-router'; +import type { LoaderFunctionArgs } from 'react-router'; +import { ROUTE } from '@/shared/config/route'; +import { queryClient } from '@/shared/api/queryClient'; +import { getAuth } from '@/entities/auth/api/auth'; + +/** + * 페이지에 접근하기 전에 실행되는 함수 + * */ +const checkAuth = async ({ request }: LoaderFunctionArgs) => { + const user = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }); + + if (!user || !user.authenticated) { + const { pathname, search, hash } = new URL(request.url); + const redirectTo = `${pathname}${search}${hash}`; + return redirect( + `${ROUTE.login}?redirectTo=${encodeURIComponent(redirectTo)}` + ); + } + + // 5xx, 네트워크 오류 등 인증과 무관한 에러는 상위로 throw → RouteErrorElement에서 처리 (error를 여기서 캐치하지 않습니다.) + return user; +}; + +export default checkAuth; diff --git a/src/features/user-profile/ui/MyProfile/index.tsx b/src/features/user-profile/ui/MyProfile/index.tsx index ed2e8155..7dee601c 100644 --- a/src/features/user-profile/ui/MyProfile/index.tsx +++ b/src/features/user-profile/ui/MyProfile/index.tsx @@ -9,22 +9,28 @@ import Button from '@/shared/ui/Button'; import * as S from './index.styles'; function MyProfile() { - const { data: profile } = useGetUserInfo(); + const { data: user } = useGetUserInfo(); const navigate = useNavigate(); const theme = useTheme(); + // suspense로 감싸져 있긴 초기에 없는 경우의 에러를 방지하기 위해 null guard를 추가했습니다. + // ref: https://github.com/moddo-kr/moddo-frontend/pull/30#discussion_r3068041167 + if (!user) { + return null; + } + return ( - + - {profile.name} + {user.name} {/* TODO: 디자인 시스템 정비 후 다시 디자인 확인이 필요합니다 (Opacity를 계속 쓰는지?) */} - {profile.email} + {user.email} {/* TODO: 현 피그마 디자인은 Chip이 Button으로 쓰이고 있는 상황이라 우선 button 컴포넌트 기준으로 구현했습니다. 디자인시스템 정리 후 다시 확인이 필요합니다! */} diff --git a/src/mocks/handlers/auth.ts b/src/mocks/handlers/auth.ts index c7c06db3..1052235c 100644 --- a/src/mocks/handlers/auth.ts +++ b/src/mocks/handlers/auth.ts @@ -29,7 +29,6 @@ const authHandlers = [ if (!isMocked || isMocked !== 'true') return passthrough(); const mockUserInfo: User = { - id: 1, name: '김모또', email: 'moddo@kakao.com', }; diff --git a/src/pages/expenseDetail/loader.ts b/src/pages/expenseDetail/loader.ts index cd85e034..3cae5868 100644 --- a/src/pages/expenseDetail/loader.ts +++ b/src/pages/expenseDetail/loader.ts @@ -1,7 +1,7 @@ // 정산 상세 페이지 전 거치는 로더 // TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경했음. 동작 확인 필요함. -import { getUserInfo } from '@/entities/auth/api/auth'; +import { getAuth } from '@/entities/auth/api/auth'; import { getGroupHeader } from '@/entities/group/api/group'; import { getProfiles } from '@/entities/member/api/getProfiles'; import { queryClient } from '@/shared/api/queryClient'; @@ -18,13 +18,13 @@ async function expenseDetailLoader({ params }: LoaderFunctionArgs) { try { // 1. 로그인 여부 확인 - // TODO: getUserInfo 401 발생 시 axiosInstance 인터셉터가 window.location.href로 처리해 returnUrl이 무시됨. 인터셉터를 React Router redirect 방식으로 교체 필요. (https://moddo2.atlassian.net/browse/MD-25) - const user = await queryClient.ensureQueryData({ - queryKey: ['userInfo'], - queryFn: getUserInfo, + // TODO: getAuth 401 발생 시 axiosInstance 인터셉터가 window.location.href로 처리해 returnUrl이 무시됨. 인터셉터를 React Router redirect 방식으로 교체 필요. (https://moddo2.atlassian.net/browse/MD-27) + const auth = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, }); // TODO: 로그인 페이지에서 성공 후 returnUrl 처리 필요함 - if (!user) { + if (!auth?.authenticated) { const returnUrl = encodeURIComponent(`/expense-detail/${groupToken}`); return redirect(`/login?returnUrl=${returnUrl}`); } @@ -35,7 +35,7 @@ async function expenseDetailLoader({ params }: LoaderFunctionArgs) { queryFn: () => getProfiles(groupToken), }); const myProfile = - profiles.find((profile) => profile.userId === user.id) ?? null; + profiles.find((profile) => profile.userId === auth.user?.id) ?? null; if (!myProfile) return redirect(`/join/${groupToken}`); const groupData = await queryClient.ensureQueryData({ diff --git a/src/pages/join/loader.ts b/src/pages/join/loader.ts index d71eb15c..476aab46 100644 --- a/src/pages/join/loader.ts +++ b/src/pages/join/loader.ts @@ -1,7 +1,7 @@ // 정산 참여 페이지 전 거치는 로더 // TODO : 기존 groupToken들을 사용하는 방식을 settlementCode를 사용하는 방식으로 변경해야 함. -import { getUserInfo } from '@/entities/auth/api/auth'; +import { getAuth } from '@/entities/auth/api/auth'; import { getProfiles } from '@/entities/member/api/getProfiles'; import { queryClient } from '@/shared/api/queryClient'; import { ROUTE } from '@/shared/config/route'; @@ -14,13 +14,13 @@ async function joinLoader({ params }: LoaderFunctionArgs) { if (!groupToken) return redirect(ROUTE.home); // 1. 로그인 여부 확인 - // TODO: getUserInfo 401 발생 시 axiosInstance 인터셉터가 window.location.href로 처리해 returnUrl이 무시됨. 인터셉터를 React Router redirect 방식으로 교체 필요. (https://moddo2.atlassian.net/browse/MD-25) - const user = await queryClient.ensureQueryData({ - queryKey: ['userInfo'], - queryFn: getUserInfo, + // TODO: getAuth 401 발생 시 axiosInstance 인터셉터가 window.location.href로 처리해 returnUrl이 무시됨. 인터셉터를 React Router redirect 방식으로 교체 필요. (https://moddo2.atlassian.net/browse/MD-27) + const auth = await queryClient.ensureQueryData({ + queryKey: ['auth', 'user'], + queryFn: getAuth, }); - if (!user) { + if (!auth?.authenticated) { const returnUrl = encodeURIComponent(`/join/${groupToken}`); return redirect(`/login?returnUrl=${returnUrl}`); } @@ -33,7 +33,7 @@ async function joinLoader({ params }: LoaderFunctionArgs) { // 3. 본인 프로필을 선택했는지 확인 const myProfile = - profiles.find((profile) => profile.userId === user.id) ?? null; + profiles.find((profile) => profile.userId === auth.user?.id) ?? null; if (myProfile) return redirect(`/expense-detail/${groupToken}`); return { profiles }; diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index 00908c90..8d7c5b30 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -1,30 +1,45 @@ import LogoImg from '@/shared/assets/pngs/LogoImg.png'; import Text from '@/shared/ui/Text'; -import { useNavigate } from 'react-router'; -import { ROUTE } from '@/shared/config/route'; +import { useNavigate, useSearchParams } from 'react-router'; import { useEffect, useState } from 'react'; import theme from '@/shared/styles/theme'; import Button from '@/shared/ui/Button'; import { Kakao } from '@/shared/assets/svgs/icon'; import Flex from '@/shared/ui/Flex'; -import { useGetGuestToken } from '@/entities/auth/api/useGetGuestToken'; +import { getGuestToken } from '@/entities/auth/api/auth'; +import { ROUTE } from '@/shared/config/route'; import kakaoLogin from '@/entities/auth/lib/kakaoLogin'; +import { queryClient } from '@/shared/api/queryClient'; +import { showToast } from '@/shared/ui/Toast'; import LoginEntranceView from './LoginEntranceView'; import * as S from './LoginPage.styles'; function LoginPage() { - const { refetch: getGuestToken } = useGetGuestToken(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [isEntrance, setIsEntrance] = useState(true); + const [isGuestLoginPending, setIsGuestLoginPending] = useState(false); - const handleLoginButtonClick = (loginType: 'KAKAO' | 'GUEST') => { - const token = localStorage.getItem('accessToken'); + const handleLoginButtonClick = async (loginType: 'KAKAO' | 'GUEST') => { if (loginType === 'KAKAO') { - kakaoLogin(); - } else if (!token) { - getGuestToken(); + const redirectPathAfterLogin = + searchParams.get('redirectTo') ?? undefined; + kakaoLogin(redirectPathAfterLogin); } else { - navigate(ROUTE.home); + if (isGuestLoginPending) return; + setIsGuestLoginPending(true); + try { + await getGuestToken(); + queryClient.removeQueries({ queryKey: ['auth', 'user'] }); + navigate(ROUTE.selectGroup); + } catch { + showToast({ + type: 'error', + content: '비회원 로그인에 실패했습니다. 다시 시도해주세요.', + }); + } finally { + setIsGuestLoginPending(false); + } } }; @@ -69,6 +84,7 @@ function LoginPage() {