From 5f4d78640be276174b7ef69177b0de19efad267f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 24 Oct 2025 09:37:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EB=8F=99=EC=A0=81=20URL=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80=20=ED=95=A8=EC=88=98=EB=A5=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/api/dashboard.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index 88306356..6cd98427 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -3,7 +3,27 @@ */ import { DashboardElement } from "@/components/admin/dashboard/types"; -import { API_BASE_URL } from "./client"; + +// API URL 동적 설정 +function getApiBaseUrl(): string { + // 클라이언트 사이드에서만 실행 + if (typeof window !== "undefined") { + const hostname = window.location.hostname; + + // 프로덕션: v1.vexplor.com → https://api.vexplor.com/api + if (hostname === "v1.vexplor.com") { + return "https://api.vexplor.com/api"; + } + + // 로컬 개발: localhost → http://localhost:8080/api + if (hostname === "localhost" || hostname === "127.0.0.1") { + return "http://localhost:8080/api"; + } + } + + // 서버 사이드 렌더링 시 기본값 + return "/api"; +} // 토큰 가져오기 (실제 인증 시스템에 맞게 수정) function getAuthToken(): string | null { @@ -17,6 +37,7 @@ async function apiRequest( options: RequestInit = {}, ): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> { const token = getAuthToken(); + const API_BASE_URL = getApiBaseUrl(); const config: RequestInit = { headers: { From 759665978b184cb9c9afaf6894e0f0e7d9ddafc6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 24 Oct 2025 09:52:51 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=A0=88=EB=8C=80=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/CustomMetricWidget.tsx | 5 ++-- frontend/lib/utils/apiUrl.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 frontend/lib/utils/apiUrl.ts diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index 4dfc289e..d6fe8086 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import { DashboardElement } from "@/components/admin/dashboard/types"; +import { getApiUrl } from "@/lib/utils/apiUrl"; interface CustomMetricWidgetProps { element?: DashboardElement; @@ -79,7 +80,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } const token = localStorage.getItem("authToken"); - const response = await fetch("/api/dashboards/execute-query", { + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { method: "POST", headers: { "Content-Type": "application/json", @@ -121,7 +122,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } const token = localStorage.getItem("authToken"); - const response = await fetch("/api/dashboards/fetch-external-api", { + const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/lib/utils/apiUrl.ts b/frontend/lib/utils/apiUrl.ts new file mode 100644 index 00000000..ea334b86 --- /dev/null +++ b/frontend/lib/utils/apiUrl.ts @@ -0,0 +1,24 @@ +/** + * API URL 유틸리티 + * 프로덕션/개발 환경에 따라 올바른 API URL을 반환 + */ + +export function getApiUrl(endpoint: string): string { + // 클라이언트 사이드에서만 실행 + if (typeof window !== "undefined") { + const hostname = window.location.hostname; + + // 프로덕션: v1.vexplor.com → https://api.vexplor.com + if (hostname === "v1.vexplor.com") { + return `https://api.vexplor.com${endpoint}`; + } + + // 로컬 개발: localhost → http://localhost:8080 + if (hostname === "localhost" || hostname === "127.0.0.1") { + return `http://localhost:8080${endpoint}`; + } + } + + // 기본값: 상대 경로 + return endpoint; +} From bc8587f6888a6d98e822712c8fa38dd17f091771 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 24 Oct 2025 10:02:34 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=ED=8C=A8=EB=94=A9=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/layout/AppLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 3aaf7e6b..903a91bb 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -457,7 +457,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { {/* 가운데 컨텐츠 영역 - 스크롤 가능 */} -
{children}
+
{children}
{/* 프로필 수정 모달 */} From 03039ab7430fe9802b6d6d6930ccbf2ad6dd5094 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 24 Oct 2025 10:09:19 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=90=98?= =?UTF-8?q?=EC=96=B4=EC=9E=88=EC=9D=84=20=EC=8B=9C=20/main=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/hooks/useAuth.ts | 6 +++++ frontend/hooks/useLogin.ts | 23 ++++++++++------ frontend/middleware.ts | 55 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 frontend/middleware.ts diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 3e1a4a17..7c4cbf51 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -63,13 +63,19 @@ const TokenManager = { setToken: (token: string): void => { if (typeof window !== "undefined") { + // localStorage에 저장 localStorage.setItem("authToken", token); + // 쿠키에도 저장 (미들웨어에서 사용) + document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`; } }, removeToken: (): void => { if (typeof window !== "undefined") { + // localStorage에서 제거 localStorage.removeItem("authToken"); + // 쿠키에서도 제거 + document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } }, diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 63318de1..1a7513e9 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -61,11 +61,15 @@ export const useLogin = () => { * API 호출 공통 함수 */ const apiCall = useCallback(async (endpoint: string, options: RequestInit = {}): Promise => { + // 로컬 스토리지에서 토큰 가져오기 + const token = localStorage.getItem("authToken"); + const response = await fetch(`${API_BASE_URL}${endpoint}`, { credentials: "include", headers: { "Content-Type": "application/json", Accept: "application/json", + ...(token && { Authorization: `Bearer ${token}` }), ...options.headers, }, ...options, @@ -90,16 +94,19 @@ export const useLogin = () => { // 토큰이 있으면 API 호출로 유효성 확인 const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS); - if (result.success && result.data?.isLoggedIn) { + // 백엔드가 isAuthenticated 필드를 반환함 + if (result.success && result.data?.isAuthenticated) { // 이미 로그인된 경우 메인으로 리다이렉트 router.push(AUTH_CONFIG.ROUTES.MAIN); } else { // 토큰이 유효하지 않으면 제거 localStorage.removeItem("authToken"); + document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } } catch (error) { // 에러가 발생하면 토큰 제거 localStorage.removeItem("authToken"); + document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; console.debug("기존 인증 체크 중 오류 (정상):", error); } }, [apiCall, router]); @@ -128,9 +135,12 @@ export const useLogin = () => { }); if (result.success && result.data?.token) { - // JWT 토큰 저장 + // JWT 토큰 저장 (localStorage와 쿠키 모두에 저장) localStorage.setItem("authToken", result.data.token); + // 쿠키에도 저장 (미들웨어에서 사용) + document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`; + // 로그인 성공 router.push(AUTH_CONFIG.ROUTES.MAIN); } else { @@ -148,13 +158,10 @@ export const useLogin = () => { [formData, validateForm, apiCall, router], ); - // 컴포넌트 마운트 시 기존 인증 상태 확인 (한 번만 실행) + // 컴포넌트 마운트 시 기존 인증 상태 확인 useEffect(() => { - // 로그인 페이지에서만 실행 - if (window.location.pathname === "/login") { - checkExistingAuth(); - } - }, []); // 의존성 배열을 비워서 한 번만 실행 + checkExistingAuth(); + }, [checkExistingAuth]); return { // 상태 diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 00000000..eb42b4c2 --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +/** + * Next.js 미들웨어 + * 페이지 렌더링 전에 실행되어 인증 상태를 확인하고 리다이렉트 처리 + */ +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // 인증 토큰 확인 + const token = request.cookies.get("authToken")?.value || request.headers.get("authorization")?.replace("Bearer ", ""); + + // /login 페이지 접근 시 + if (pathname === "/login") { + // 토큰이 있으면 메인 페이지로 리다이렉트 + if (token) { + const url = request.nextUrl.clone(); + url.pathname = "/main"; + return NextResponse.redirect(url); + } + // 토큰이 없으면 로그인 페이지 표시 + return NextResponse.next(); + } + + // 인증이 필요한 페이지들 + const protectedPaths = ["/main", "/admin", "/dashboard", "/settings"]; + const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path)); + + if (isProtectedPath && !token) { + // 인증되지 않은 사용자는 로그인 페이지로 리다이렉트 + const url = request.nextUrl.clone(); + url.pathname = "/login"; + return NextResponse.redirect(url); + } + + return NextResponse.next(); +} + +/** + * 미들웨어가 실행될 경로 설정 + */ +export const config = { + matcher: [ + /* + * 다음 경로를 제외한 모든 요청에 대해 실행: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - public 폴더의 파일들 + */ + "/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.svg$).*)", + ], +};