diff --git a/apps/admin/src/app/auth/login/page.tsx b/apps/admin/src/app/auth/login/page.tsx index 3488dc0e..8130aacc 100644 --- a/apps/admin/src/app/auth/login/page.tsx +++ b/apps/admin/src/app/auth/login/page.tsx @@ -11,6 +11,12 @@ export default function AuthLoginPage() { let isMounted = true; const redirectIfAuthenticated = async () => { + const shouldSkipSessionCheck = new URLSearchParams(window.location.search).get("loggedOut") === "1"; + if (shouldSkipSessionCheck) { + setCanRenderLogin(true); + return; + } + try { const token = await ensureSessionToken(); if (!isMounted) return; diff --git a/apps/admin/src/components/layout/AdminLayout.tsx b/apps/admin/src/components/layout/AdminLayout.tsx index fa09dadf..1bf3a8c3 100644 --- a/apps/admin/src/components/layout/AdminLayout.tsx +++ b/apps/admin/src/components/layout/AdminLayout.tsx @@ -1,5 +1,9 @@ +"use client"; + import { LogOut } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; +import { adminSignOutApi } from "@/lib/api/auth"; import { clearSession } from "@/lib/auth/session"; import { type ActiveAdminMenu, AdminSidebar } from "./AdminSidebar"; @@ -11,10 +15,26 @@ interface AdminLayoutProps { } export function AdminLayout({ children, activeMenu, title, description }: AdminLayoutProps) { - const handleLogout = () => { - clearSession(); - toast.success("로그아웃되었습니다."); - window.location.assign("/auth/login"); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + const handleLogout = async () => { + if (isLoggingOut) { + return; + } + + setIsLoggingOut(true); + + try { + await adminSignOutApi(); + toast.success("로그아웃되었습니다."); + } catch { + toast.error("서버 로그아웃 요청에 실패했습니다.", { + description: "로컬 세션을 정리하고 로그인 화면으로 이동합니다.", + }); + } finally { + clearSession(); + window.location.assign("/auth/login?loggedOut=1"); + } }; return ( @@ -35,10 +55,11 @@ export function AdminLayout({ children, activeMenu, title, description }: AdminL diff --git a/apps/admin/src/lib/api/auth.ts b/apps/admin/src/lib/api/auth.ts index 6f05e8a5..bd3a6903 100644 --- a/apps/admin/src/lib/api/auth.ts +++ b/apps/admin/src/lib/api/auth.ts @@ -1,4 +1,5 @@ import axios, { type AxiosResponse } from "axios"; +import { loadAccessToken } from "@/lib/utils/localStorage"; import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth"; const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim(); @@ -17,3 +18,11 @@ export const adminSignInApi = (email: string, password: string): Promise> => authAxiosInstance.post("/auth/reissue"); + +export const adminSignOutApi = (): Promise> => { + const accessToken = loadAccessToken(); + + return authAxiosInstance.post("/auth/sign-out", undefined, { + headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined, + }); +}; diff --git a/apps/admin/src/lib/auth/session.ts b/apps/admin/src/lib/auth/session.ts index 528ee996..336d226f 100644 --- a/apps/admin/src/lib/auth/session.ts +++ b/apps/admin/src/lib/auth/session.ts @@ -3,6 +3,7 @@ import { isTokenExpired } from "@/lib/utils/jwtUtils"; import { loadAccessToken, removeAccessToken, saveAccessToken } from "@/lib/utils/localStorage"; let reissuePromise: Promise | null = null; +let sessionVersion = 0; const getValidAccessToken = (): string | null => { const accessToken = loadAccessToken(); @@ -19,6 +20,8 @@ const getValidAccessToken = (): string | null => { }; export const clearSession = () => { + sessionVersion += 1; + reissuePromise = null; removeAccessToken(); }; @@ -27,11 +30,17 @@ export const reissueAccessTokenIfPossible = async (): Promise => return reissuePromise; } + const reissueSessionVersion = sessionVersion; + reissuePromise = (async () => { try { const response = await reissueAccessTokenApi(); const nextAccessToken = response.data.accessToken; + if (reissueSessionVersion !== sessionVersion) { + return null; + } + if (!nextAccessToken) { clearSession(); return null;