From 5587e6b6a370d3e408b0512ddb2fbd17d6e6bac2 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 7 Jul 2026 17:34:27 +0900 Subject: [PATCH] =?UTF-8?q?[common]=20feat:=20=EA=B3=B5=ED=86=B5=20auth=20?= =?UTF-8?q?=EB=B0=8F=20components=20=EC=88=98=EC=A0=95(authService=20DI=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/common/auth/index.ts | 7 +- packages/common/auth/types.ts | 11 ++ .../common/components/src/ui/header/index.tsx | 175 +++++++++--------- .../common/components/src/ui/layout/base.tsx | 5 +- .../common/components/src/ui/layout/nolnb.tsx | 11 +- 5 files changed, 117 insertions(+), 92 deletions(-) diff --git a/packages/common/auth/index.ts b/packages/common/auth/index.ts index 04fb01bb4..e069f7c6c 100644 --- a/packages/common/auth/index.ts +++ b/packages/common/auth/index.ts @@ -4,4 +4,9 @@ export type { TokenManager } from "./token-manager"; export { getCookie, setCookie, removeCookie } from "./cookie"; export { getJwtExpiry, getRemainingMs } from "./jwt-utils"; -export type { AuthConfig, CookieOptions } from "./types"; +export type { + AuthConfig, + AuthPayload, + AuthService, + CookieOptions, +} from "./types"; diff --git a/packages/common/auth/types.ts b/packages/common/auth/types.ts index 077eb821f..292c8edd7 100644 --- a/packages/common/auth/types.ts +++ b/packages/common/auth/types.ts @@ -1,3 +1,14 @@ +export interface AuthPayload { + userId: string; + password: string; +} + +export interface AuthService { + login(payload: AuthPayload): Promise; + silentRefresh(): Promise; + logout(): Promise; +} + export interface AuthConfig { authUserId: string; claimName: string; diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index 4f6c61c56..b303b5e9f 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -3,7 +3,7 @@ import "./header.css"; import { useEffect, useCallback, useState, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { images } from "@common/assets"; -import type { TokenManager } from "@common/auth"; +import type { AuthService, TokenManager } from "@common/auth"; import { getConfig } from "@common/config"; import { ENDPOINT } from "../../config"; import { setCookie, getCookie } from "../../lib"; @@ -29,7 +29,84 @@ const userId = getCookie(appConfig.AUTH_USER_ID ?? ""); const siteId = getCookie("site-id-key") ?? ""; const isLoggedIn = !!userId; -export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager; viewOnly?: boolean }) { +function getRootDomain(hostname: string): string { + if (hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { + return hostname; + } + + if (hostname.includes("hasuon.or.kr")) { + return ".hasuon.or.kr"; + } + + const parts = hostname.split("."); + if (parts.length >= 2) { + return `.${parts.slice(-2).join(".")}`; + } + + return hostname; +} + +function clearClientCookies(tokenManager?: TokenManager) { + const actualHost = window.location.hostname; + const domain = getRootDomain(actualHost); + + const cookiesToDelete = [ + appConfig.AUTH_USER_ID, + appConfig.REFRESH_TOKEN, + appConfig.ACCESS_TOKEN, + appConfig.CLAIM_NAME, + "site-id-key", + "site-theme-key", + ].filter(Boolean); + + cookiesToDelete.forEach((cookieName) => { + if (!cookieName) return; + + tokenManager?.removeToken(cookieName); + + if (domain !== "localhost") { + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}`; + } + + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + + if (window.location.protocol === "https:") { + if (domain !== "localhost") { + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}; secure`; + } + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; secure`; + } + }); +} + +async function legacyLogout(tokenManager?: TokenManager) { + try { + const response = await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + + if (!response.ok) { + console.error("Logout API failed"); + } + } catch (error) { + console.error("Logout error:", error); + } finally { + clearClientCookies(tokenManager); + window.location.href = "/auth/login"; + } +} + +export function Header({ + authService, + tokenManager, + viewOnly, +}: { + authService?: AuthService; + /** @deprecated authService를 사용하세요. */ + tokenManager?: TokenManager; + viewOnly?: boolean; +}) { const [mounted, setMounted] = useState(false); const [logoWidth, setLogoWidth] = useState(0); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); @@ -66,93 +143,13 @@ export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager queryFn: () => getSiteList(), enabled: isLoggedIn && !viewOnly, }); - const logout = async () => { - try { - // 1. 백엔드 API 로그아웃 호출 - const response = await fetch("/api/auth/logout", { - method: "POST", - credentials: "include", // ⭐ 쿠키 포함 중요! - }); - - if (!response.ok) { - console.error("Logout API failed"); - // API 실패해도 계속 진행 - } - } catch (error) { - console.error("Logout error:", error); - // 에러가 나도 계속 진행 - } finally { - // 2. 클라이언트 쿠키 삭제 (백업) - clearClientCookies(); - - // 3. 로컬/세션 스토리지 정리 - // if (typeof window !== "undefined") { - // localStorage.clear(); - // sessionStorage.clear(); - // } - - // 4. 로그인 페이지로 리다이렉트 - window.location.href = "/auth/login"; + const handleLogout = () => { + if (authService) { + void authService.logout(); + return; } - }; - // 쿠키 삭제 헬퍼 함수 - const clearClientCookies = () => { - const actualHost = window.location.hostname; - - // 루트 도메인 추출 - const getRootDomain = (hostname: string): string => { - if (hostname === "localhost" || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { - return hostname; - } - - if (hostname.includes("hasuon.or.kr")) { - return ".hasuon.or.kr"; - } - - const parts = hostname.split("."); - if (parts.length >= 2) { - return `.${parts.slice(-2).join(".")}`; - } - - return hostname; - }; - - const domain = getRootDomain(actualHost); - - // 삭제할 쿠키 목록 - const cookiesToDelete = [ - appConfig.AUTH_USER_ID, - appConfig.REFRESH_TOKEN, - appConfig.ACCESS_TOKEN, - appConfig.CLAIM_NAME, - "site-id-key", - "site-theme-key", - ].filter(Boolean); // undefined 제거 - - // 여러 방법으로 쿠키 삭제 시도 - cookiesToDelete.forEach((cookieName) => { - if (!cookieName) return; - - // 방법 1: removeToken 함수 사용 - tokenManager?.removeToken(cookieName); - - // 방법 2: 직접 document.cookie 설정 (도메인 포함) - if (domain !== "localhost") { - document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}`; - } - - // 방법 3: 도메인 없이 삭제 (현재 도메인만) - document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; - - // 방법 4: Secure 속성 포함 삭제 - if (window.location.protocol === "https:") { - if (domain !== "localhost") { - document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}; secure`; - } - document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; secure`; - } - }); + void legacyLogout(tokenManager); }; const openPopup = useCallback(async (popup: Popup) => { @@ -270,7 +267,7 @@ export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager
  • 로그아웃 @@ -346,7 +343,7 @@ export function Header({ tokenManager, viewOnly }: { tokenManager?: TokenManager
  • 로그아웃 diff --git a/packages/common/components/src/ui/layout/base.tsx b/packages/common/components/src/ui/layout/base.tsx index e1f0c1329..2ac151666 100644 --- a/packages/common/components/src/ui/layout/base.tsx +++ b/packages/common/components/src/ui/layout/base.tsx @@ -1,4 +1,4 @@ -import type { TokenManager } from "@common/auth"; +import type { AuthService, TokenManager } from "@common/auth"; import type { QueryClient } from "@tanstack/react-query"; import { QueryProvider } from "../../provider/query-provider"; import { PopupProvider } from "../../provider/popup-provider"; @@ -14,12 +14,15 @@ import "./common.css"; export function BaseLayout({ children, queryClient, + authService, tokenManager, currentPath, handleMenuClick, }: { children: React.ReactNode; queryClient?: QueryClient; + authService?: AuthService; + /** @deprecated authService를 사용하세요. authService가 없을 때만 Header legacy logout에 사용됩니다. */ tokenManager?: TokenManager; currentPath?: string; handleMenuClick?: (item: MenuItem) => void; diff --git a/packages/common/components/src/ui/layout/nolnb.tsx b/packages/common/components/src/ui/layout/nolnb.tsx index c43941793..47717c99b 100644 --- a/packages/common/components/src/ui/layout/nolnb.tsx +++ b/packages/common/components/src/ui/layout/nolnb.tsx @@ -1,3 +1,4 @@ +import type { AuthService, TokenManager } from "@common/auth"; import type { QueryClient } from "@tanstack/react-query"; import { QueryProvider } from "../../provider/query-provider"; import { PopupProvider } from "../../provider/popup-provider"; @@ -13,11 +14,16 @@ import "./common.css"; export function NoLNBLayout({ children, queryClient, + authService, + tokenManager, currentPath, handleMenuClick, }: { children: React.ReactNode; queryClient?: QueryClient; + authService?: AuthService; + /** @deprecated authService를 사용하세요. authService가 없을 때만 Header legacy logout에 사용됩니다. */ + tokenManager?: TokenManager; currentPath?: string; handleMenuClick?: (item: MenuItem) => void; }) { @@ -28,7 +34,10 @@ export function NoLNBLayout({
    -
    +
    {children}