Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions apps/admin/src/components/layout/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { useNavigate } from "@tanstack/react-router";
import { LogOut } from "lucide-react";
import { toast } from "sonner";
import { clearSession } from "@/lib/auth/session";
import { type ActiveAdminMenu, AdminSidebar } from "./AdminSidebar";

interface AdminLayoutProps {
children: React.ReactNode;
activeMenu: ActiveAdminMenu;
title: string;
description?: string;
}

export function AdminLayout({ children }: AdminLayoutProps) {
export function AdminLayout({ children, activeMenu, title, description }: AdminLayoutProps) {
const navigate = useNavigate();

const handleLogout = () => {
clearSession();
toast.success("로그아웃되었습니다.");
void navigate({ to: "/auth/login" });
Comment on lines +17 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Call server sign-out before clearing local session

handleLogout only removes the access token from localStorage and immediately navigates to /auth/login, but the refresh cookie is left intact. Because /auth/login now runs redirectIfAuthenticated, users with a still-valid refresh cookie will get a new access token via /auth/reissue and be redirected back to /scores, so logout does not actually terminate the session in normal authenticated flows.

Useful? React with 👍 / 👎.

Comment on lines +17 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

1. 로그아웃 시 refresh cookie도 무효화해 주세요.

- 지금은 `clearSession()`이 access token만 지우기 때문에, `/auth/login` 진입 직후 `redirectIfAuthenticated()`가 `/auth/reissue`로 새 access token을 다시 받아 `/scores`로 되돌릴 수 있습니다.
- 쿠키 기반 refresh 모델에서는 서버 로그아웃 API로 refresh cookie를 만료시키거나, 명시적 로그아웃 상태에서는 reissue를 막는 처리가 필요합니다.
🐛 수정 방향 예시
-	const handleLogout = () => {
-		clearSession();
-		toast.success("로그아웃되었습니다.");
-		void navigate({ to: "/auth/login" });
-	};
+	const handleLogout = async () => {
+		await adminLogoutApi(); // 서버에서 refresh cookie 만료
+		clearSession();
+		toast.success("로그아웃되었습니다.");
+		void navigate({ to: "/auth/login" });
+	};

추가로 adminLogoutApi() 실패 시에는 성공 토스트와 세션 삭제를 하지 않도록 처리하면, 실제 로그아웃 실패와 UI 상태가 엇갈리는 일을 줄일 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/components/layout/AdminLayout.tsx` around lines 17 - 20, When
logging out from handleLogout, call the server logout endpoint (adminLogoutApi)
and await its success before calling clearSession(); on success expire the
refresh cookie server-side so redirectIfAuthenticated cannot reissue an access
token, then call clearSession(), show toast.success("로그아웃되었습니다.") and navigate
to "/auth/login"; on failure do not clear the session or show a success
toast—show an error toast instead. Implement this by replacing the current
synchronous clearSession() call in handleLogout with an async try/catch that
awaits adminLogoutApi(), only clears session and navigates on success, and
handles errors (toast.error) on failure; reference handleLogout, clearSession,
adminLogoutApi and redirectIfAuthenticated when making the change.

};

return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_#eef2ff_0%,_#fafafa_48%,_#f5f5f5_100%)] text-k-800">
<div className="mx-auto flex min-h-screen w-full max-w-[1440px] flex-col px-3 py-4 sm:px-4 sm:py-6 lg:px-8">
Expand All @@ -16,9 +33,29 @@ export function AdminLayout({ children }: AdminLayoutProps) {
<h1 className="typo-sb-7 text-k-900">Admin</h1>
</div>
</div>
<p className="hidden rounded-full bg-bg-50 px-3 py-1 typo-medium-4 text-k-600 sm:block">운영 콘솔</p>
<div className="flex items-center gap-2">
<p className="hidden rounded-full bg-bg-50 px-3 py-1 typo-medium-4 text-k-600 sm:block">운영 콘솔</p>
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1 rounded-md border border-k-200 px-3 py-1.5 text-k-700 typo-medium-4 hover:bg-k-50"
>
<LogOut className="h-4 w-4" />
로그아웃
</button>
</div>
</header>
<main className="flex-1">{children}</main>

<div className="flex min-h-[calc(100vh-96px)] overflow-hidden rounded-[24px] border border-k-100 bg-k-0 shadow-sdw-a">
<AdminSidebar activeMenu={activeMenu} />
<section className="flex-1 bg-bg-50 p-4 sm:p-6 lg:p-7">
<div className="h-full rounded-2xl border border-k-100 bg-k-0 p-4 shadow-[0_8px_24px_-22px_rgba(26,31,39,0.45)] sm:p-6">
<h1 className="typo-bold-1 text-k-900">{title}</h1>
{description ? <p className="mt-1 typo-regular-4 text-k-500">{description}</p> : null}
{children}
</div>
</section>
</div>
</div>
</div>
);
Expand Down
27 changes: 9 additions & 18 deletions apps/admin/src/components/layout/AdminSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { Building2, FileText, FlaskConical, MessageSquare, UserCircle2 } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { FileText, FlaskConical, MessageSquare } from "lucide-react";
import { cn } from "@/lib/utils";

interface AdminSidebarProps {
activeMenu: "scores" | "bruno" | "chatSocket";
}
export type ActiveAdminMenu = "scores" | "bruno" | "chatSocket";

const sideMenus = [
{ key: "university", label: "대학 관리", icon: Building2 },
{ key: "mentor", label: "멘토 관리", icon: UserCircle2 },
{ key: "user", label: "유저 관리", icon: UserCircle2 },
{ key: "scores", label: "성적 관리", icon: FileText, to: "/scores" as const },
{ key: "bruno", label: "Bruno API", icon: FlaskConical, to: "/bruno" as const },
{ key: "chatSocket", label: "채팅 소켓", icon: MessageSquare, to: "/chat-socket" as const },
] as const;

interface AdminSidebarProps {
activeMenu: ActiveAdminMenu;
}

export function AdminSidebar({ activeMenu }: AdminSidebarProps) {
return (
<aside className="flex w-[212px] flex-col border-r border-k-100 bg-bg-100 px-5 py-7">
Expand All @@ -35,20 +35,11 @@ export function AdminSidebar({ activeMenu }: AdminSidebarProps) {
isActive ? "bg-primary-100 text-primary" : "text-k-400 hover:bg-k-0 hover:text-k-700",
);

if ("to" in menu) {
return (
<a key={menu.label} href={menu.to} className={menuClassName}>
<menu.icon className="h-4 w-4" />
{menu.label}
</a>
);
}

return (
<button key={menu.label} type="button" className={menuClassName} disabled>
<Link key={menu.label} to={menu.to} preload="intent" className={menuClassName}>
<menu.icon className="h-4 w-4" />
{menu.label}
</button>
</Link>
);
})}
</nav>
Expand Down
28 changes: 16 additions & 12 deletions apps/admin/src/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { AxiosResponse } from "axios";
import { publicAxiosInstance } from "@/lib/api/client";
import axios, { type AxiosResponse } from "axios";
import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth";

const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();

if (!API_SERVER_URL) {
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
}

const authAxiosInstance = axios.create({
baseURL: API_SERVER_URL,
withCredentials: true,
});

export const adminSignInApi = (email: string, password: string): Promise<AxiosResponse<AdminSignInResponse>> =>
publicAxiosInstance.post("/auth/email/sign-in", { email, password });

export const reissueAccessTokenApi = (refreshToken: string): Promise<AxiosResponse<ReissueAccessTokenResponse>> =>
publicAxiosInstance.post(
"/admin/auth/reissue",
{},
{
headers: { Authorization: `Bearer ${refreshToken}` },
},
);
authAxiosInstance.post("/auth/email/sign-in", { email, password });

export const reissueAccessTokenApi = (): Promise<AxiosResponse<ReissueAccessTokenResponse>> =>
authAxiosInstance.post("/auth/reissue");
90 changes: 30 additions & 60 deletions apps/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import axios, { type AxiosInstance } from "axios";
import { reissueAccessTokenApi } from "@/lib/api/auth";
import { isTokenExpired } from "@/lib/utils/jwtUtils";
import {
loadAccessToken,
loadRefreshToken,
removeAccessToken,
removeRefreshToken,
saveAccessToken,
} from "@/lib/utils/localStorage";
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
import { clearSession, ensureSessionToken, reissueAccessTokenIfPossible } from "@/lib/auth/session";

const convertToBearer = (token: string) => `Bearer ${token}`;

Expand All @@ -22,34 +14,24 @@ export const axiosInstance: AxiosInstance = axios.create({
withCredentials: true,
});

const redirectToLogin = () => {
if (typeof window !== "undefined" && window.location.pathname !== "/auth/login") {
window.location.replace("/auth/login");
}
};

axiosInstance.interceptors.request.use(
async (config) => {
const newConfig = { ...config };
let accessToken: string | null = loadAccessToken();

if (accessToken === null || isTokenExpired(accessToken)) {
const refreshToken = loadRefreshToken();
if (refreshToken === null || isTokenExpired(refreshToken)) {
removeAccessToken();
removeRefreshToken();
return config;
}
const accessToken = await ensureSessionToken();

await reissueAccessTokenApi(refreshToken)
.then((res) => {
accessToken = res.data.accessToken;
saveAccessToken(accessToken);
})
.catch((err) => {
removeAccessToken();
removeRefreshToken();
console.error("인증 토큰 갱신중 오류가 발생했습니다", err);
});
if (!accessToken) {
clearSession();
redirectToLogin();
return Promise.reject(new Error("로그인이 필요합니다."));
}

if (accessToken !== null) {
newConfig.headers.Authorization = convertToBearer(accessToken);
}
newConfig.headers.Authorization = convertToBearer(accessToken);
return newConfig;
},
(error) => Promise.reject(error),
Expand All @@ -58,37 +40,25 @@ axiosInstance.interceptors.request.use(
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const newError = { ...error };
if (error.response?.status === 401 || error.response?.status === 403) {
const refreshToken = loadRefreshToken();

if (refreshToken === null || isTokenExpired(refreshToken)) {
removeAccessToken();
removeRefreshToken();
throw newError;
}

try {
const newAccessToken = await reissueAccessTokenApi(refreshToken).then((res) => res.data.accessToken);
saveAccessToken(newAccessToken);
const status = error.response?.status;
const originalRequest = error.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined;

if (error?.config.headers === undefined) {
newError.config.headers = {};
}
newError.config.headers.Authorization = convertToBearer(newAccessToken);
if ((status === 401 || status === 403) && originalRequest && !originalRequest._retry) {
originalRequest._retry = true;

return await axios.request(newError.config);
} catch (_err) {
removeAccessToken();
removeRefreshToken();
throw Error("로그인이 필요합니다");
const reissuedAccessToken = await reissueAccessTokenIfPossible();
if (reissuedAccessToken) {
originalRequest.headers = originalRequest.headers ?? {};
originalRequest.headers.Authorization = convertToBearer(reissuedAccessToken);
return axiosInstance(originalRequest);
}
} else {
throw newError;
}

if (status === 401 || status === 403) {
clearSession();
redirectToLogin();
}

return Promise.reject(error);
},
);

export const publicAxiosInstance: AxiosInstance = axios.create({
baseURL: API_SERVER_URL,
});
77 changes: 77 additions & 0 deletions apps/admin/src/lib/auth/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { redirect } from "@tanstack/react-router";
import { reissueAccessTokenApi } from "@/lib/api/auth";
import { isTokenExpired } from "@/lib/utils/jwtUtils";
import { loadAccessToken, removeAccessToken, saveAccessToken } from "@/lib/utils/localStorage";

let reissuePromise: Promise<string | null> | null = null;

const getValidAccessToken = (): string | null => {
const accessToken = loadAccessToken();
if (!accessToken) {
return null;
}

if (isTokenExpired(accessToken)) {
removeAccessToken();
return null;
}

return accessToken;
};

export const clearSession = () => {
removeAccessToken();
};
Comment on lines +22 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 설명: 로그아웃 경로가 refresh 쿠키를 서버에서 무효화하는지, clearSession 이후 reissue가 다시 저장될 수 있는지 확인합니다.
# 기대 결과: 로그아웃 성공 시 서버 refresh 쿠키 만료 API가 호출되고, 진행 중이던 reissue 결과는 저장되지 않아야 합니다.
rg -n -C3 --type=ts --type=tsx '\b(clearSession|redirectIfAuthenticated|reissueAccessTokenIfPossible|reissueAccessTokenApi|logout|Logout)\b'

Repository: solid-connection/solid-connect-web

Length of output: 105


🏁 Script executed:

rg -n -C3 'clearSession|redirectIfAuthenticated|reissueAccessTokenIfPossible|reissueAccessTokenApi|logout|Logout' apps/admin/src/lib/auth/

Repository: solid-connection/solid-connect-web

Length of output: 3060


🏁 Script executed:

# Read the session.ts file to understand current implementation
head -100 apps/admin/src/lib/auth/session.ts

Repository: solid-connection/solid-connect-web

Length of output: 1795


🏁 Script executed:

# Find logout-related endpoints and handlers
fd -e ts -e tsx . apps/admin/src | head -30

Repository: solid-connection/solid-connect-web

Length of output: 1310


🏁 Script executed:

cat apps/admin/src/components/layout/AdminLayout.tsx

Repository: solid-connection/solid-connect-web

Length of output: 2519


🏁 Script executed:

cat apps/admin/src/lib/api/auth.ts

Repository: solid-connection/solid-connect-web

Length of output: 820


🏁 Script executed:

# Search for logout endpoint or auth routes
rg -n 'logout|Logout|clearSession' apps/admin/src/ --type-list | head -20

Repository: solid-connection/solid-connect-web

Length of output: 467


🏁 Script executed:

# Check auth routes if they exist
find apps -name '*auth*' -o -name '*logout*' | grep -E '\.(ts|tsx)$'

Repository: solid-connection/solid-connect-web

Length of output: 234


🏁 Script executed:

# Search for logout-related endpoints
rg -n 'sign-out|logout|/auth' apps/ --type ts --max-count 30 | grep -E '(logout|sign-out|endpoint|route)'

Repository: solid-connection/solid-connect-web

Length of output: 2081


🏁 Script executed:

# Check if there's a logout endpoint in the API or routes
find apps -name '*.ts' -o -name '*.tsx' | xargs grep -l 'sign-out\|logout.*endpoint\|auth.*logout' 2>/dev/null | head -10

Repository: solid-connection/solid-connect-web

Length of output: 107


🏁 Script executed:

# Check the actual auth route configuration
cat apps/admin/src/routes/auth/login.tsx

Repository: solid-connection/solid-connect-web

Length of output: 3636


로그아웃 후 refresh 쿠키로 인한 즉시 재인증 방지 필요합니다.

clearSession()은 localStorage의 access token만 삭제하며, 서버에서 refresh 쿠키를 무효화하지 않습니다. 다음과 같은 경로로 로그아웃이 무효화될 수 있습니다.

  1. 로그아웃 시 진행 중인 reissueAccessTokenIfPossible() 호출이 clearSession() 이후에 완료되어 새 토큰을 다시 저장할 수 있습니다.
  2. 로그인 페이지 진입 시 redirectIfAuthenticated()ensureSessionToken()reissueAccessTokenIfPossible()으로 이어지고, 서버의 유효한 refresh 쿠키로 토큰 재발급에 성공합니다.
  3. 새 토큰이 저장되어 사용자가 scores 페이지로 다시 리다이렉트됩니다.

아래 두 가지가 필요합니다.

  1. 서버 측: logout 엔드포인트 추가하여 refresh 쿠키 무효화
  2. 클라이언트 측: 로그아웃 후 stale reissue 결과를 저장하지 않도록 세션 generation guard 추가
구현 예시: session generation guard
 let reissuePromise: Promise<string | null> | null = null;
+let sessionGeneration = 0;
@@
 export const clearSession = () => {
+	sessionGeneration += 1;
 	removeAccessToken();
 };
@@
 export const reissueAccessTokenIfPossible = async (): Promise<string | null> => {
 	if (reissuePromise) {
 		return reissuePromise;
 	}
 
+	const generationAtStart = sessionGeneration;
 	reissuePromise = (async () => {
 		try {
 			const response = await reissueAccessTokenApi();
 			const nextAccessToken = response.data.accessToken;
@@
 			if (!nextAccessToken) {
 				clearSession();
 				return null;
 			}
 
+			if (generationAtStart !== sessionGeneration) {
+				return null;
+			}
+
 			saveAccessToken(nextAccessToken);
 			return nextAccessToken;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/lib/auth/session.ts` around lines 22 - 24, clearSession only
removes local access token but doesn't invalidate the server refresh cookie,
allowing a concurrent or subsequent reissueAccessTokenIfPossible (called via
ensureSessionToken/redirectIfAuthenticated) to silently restore a session; add a
server-side logout endpoint that clears/invalidates the refresh cookie, and on
the client add a session-generation guard (e.g., a monotonically-incremented
sessionGeneration variable stored alongside clearSession) so clearSession
increments the generation and reissueAccessTokenIfPossible checks the generation
before persisting any new token (ignore/stash results if generation changed),
and ensure redirectIfAuthenticated/ensureSessionToken propagate that check so
stale reissues are not saved.


export const reissueAccessTokenIfPossible = async (): Promise<string | null> => {
if (reissuePromise) {
return reissuePromise;
}

reissuePromise = (async () => {
try {
const response = await reissueAccessTokenApi();
const nextAccessToken = response.data.accessToken;

if (!nextAccessToken) {
clearSession();
return null;
}

saveAccessToken(nextAccessToken);
return nextAccessToken;
} catch {
clearSession();
return null;
} finally {
reissuePromise = null;
}
})();

return reissuePromise;
};

export const ensureSessionToken = async (): Promise<string | null> => {
const validAccessToken = getValidAccessToken();
if (validAccessToken) {
return validAccessToken;
}

return reissueAccessTokenIfPossible();
Comment on lines +58 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard reissue flow from running in server-side beforeLoad

ensureSessionToken always falls through to reissueAccessTokenIfPossible, and protected routes now call this from beforeLoad. In this app (@tanstack/react-start), beforeLoad also runs during server rendering, where browser localStorage is unavailable and request cookies are not forwarded into this axios call, so direct loads/refreshes of /scores, /bruno, or /chat-socket can be misclassified as unauthenticated and redirected to /auth/login even for valid sessions.

Useful? React with 👍 / 👎.

};

export const requireAdminSession = async (): Promise<string> => {
const token = await ensureSessionToken();
if (!token) {
throw redirect({ to: "/auth/login" });
}

return token;
};

export const redirectIfAuthenticated = async () => {
const token = await ensureSessionToken();
if (token) {
throw redirect({ to: "/scores" });
}
};
25 changes: 0 additions & 25 deletions apps/admin/src/lib/utils/localStorage.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,3 @@
export const loadRefreshToken = () => {
try {
return localStorage.getItem("refreshToken");
} catch (err) {
console.error("Could not load refresh token", err);
return null;
}
};

export const saveRefreshToken = (token: string) => {
try {
localStorage.setItem("refreshToken", token);
} catch (err) {
console.error("Could not save refresh token", err);
}
};

export const removeRefreshToken = () => {
try {
localStorage.removeItem("refreshToken");
} catch (err) {
console.error("Could not remove refresh token", err);
}
};

export const loadAccessToken = () => {
try {
return localStorage.getItem("accessToken");
Expand Down
9 changes: 6 additions & 3 deletions apps/admin/src/routes/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { adminSignInApi } from "@/lib/api/auth";
import { saveAccessToken, saveRefreshToken } from "@/lib/utils/localStorage";
import { redirectIfAuthenticated } from "@/lib/auth/session";
import { saveAccessToken } from "@/lib/utils/localStorage";

export const Route = createFileRoute("/auth/login")({
beforeLoad: async () => {
await redirectIfAuthenticated();
},
component: LoginPage,
});

Expand All @@ -32,10 +36,9 @@ function LoginPage() {

try {
const response = await signInMutation.mutateAsync({ nextEmail: email, nextPassword: password });
const { accessToken, refreshToken } = response.data;
const { accessToken } = response.data;

saveAccessToken(accessToken);
saveRefreshToken(refreshToken);

toast("로그인 성공", {
description: "관리자 페이지로 이동합니다.",
Expand Down
Loading
Loading