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() {