Skip to content

Commit c8f9626

Browse files
committed
fix(admin): 인증 라이프사이클 및 사이드바 동작 안정화
1 parent dae3441 commit c8f9626

12 files changed

Lines changed: 591 additions & 673 deletions

File tree

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
1+
import { useNavigate } from "@tanstack/react-router";
2+
import { LogOut } from "lucide-react";
3+
import { toast } from "sonner";
4+
import { clearSession } from "@/lib/auth/session";
5+
import { type ActiveAdminMenu, AdminSidebar } from "./AdminSidebar";
6+
17
interface AdminLayoutProps {
28
children: React.ReactNode;
9+
activeMenu: ActiveAdminMenu;
10+
title: string;
11+
description?: string;
312
}
413

5-
export function AdminLayout({ children }: AdminLayoutProps) {
14+
export function AdminLayout({ children, activeMenu, title, description }: AdminLayoutProps) {
15+
const navigate = useNavigate();
16+
17+
const handleLogout = () => {
18+
clearSession();
19+
toast.success("로그아웃되었습니다.");
20+
void navigate({ to: "/auth/login" });
21+
};
22+
623
return (
724
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_#eef2ff_0%,_#fafafa_48%,_#f5f5f5_100%)] text-k-800">
825
<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">
@@ -16,9 +33,29 @@ export function AdminLayout({ children }: AdminLayoutProps) {
1633
<h1 className="typo-sb-7 text-k-900">Admin</h1>
1734
</div>
1835
</div>
19-
<p className="hidden rounded-full bg-bg-50 px-3 py-1 typo-medium-4 text-k-600 sm:block">운영 콘솔</p>
36+
<div className="flex items-center gap-2">
37+
<p className="hidden rounded-full bg-bg-50 px-3 py-1 typo-medium-4 text-k-600 sm:block">운영 콘솔</p>
38+
<button
39+
type="button"
40+
onClick={handleLogout}
41+
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"
42+
>
43+
<LogOut className="h-4 w-4" />
44+
로그아웃
45+
</button>
46+
</div>
2047
</header>
21-
<main className="flex-1">{children}</main>
48+
49+
<div className="flex min-h-[calc(100vh-96px)] overflow-hidden rounded-[24px] border border-k-100 bg-k-0 shadow-sdw-a">
50+
<AdminSidebar activeMenu={activeMenu} />
51+
<section className="flex-1 bg-bg-50 p-4 sm:p-6 lg:p-7">
52+
<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">
53+
<h1 className="typo-bold-1 text-k-900">{title}</h1>
54+
{description ? <p className="mt-1 typo-regular-4 text-k-500">{description}</p> : null}
55+
{children}
56+
</div>
57+
</section>
58+
</div>
2259
</div>
2360
</div>
2461
);

apps/admin/src/components/layout/AdminSidebar.tsx

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1-
import { Building2, FileText, FlaskConical, MessageSquare, UserCircle2 } from "lucide-react";
1+
import { Link } from "@tanstack/react-router";
2+
import { FileText, FlaskConical, MessageSquare } from "lucide-react";
23
import { cn } from "@/lib/utils";
34

4-
interface AdminSidebarProps {
5-
activeMenu: "scores" | "bruno" | "chatSocket";
6-
}
5+
export type ActiveAdminMenu = "scores" | "bruno" | "chatSocket";
76

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

13+
interface AdminSidebarProps {
14+
activeMenu: ActiveAdminMenu;
15+
}
16+
1717
export function AdminSidebar({ activeMenu }: AdminSidebarProps) {
1818
return (
1919
<aside className="flex w-[212px] flex-col border-r border-k-100 bg-bg-100 px-5 py-7">
@@ -35,20 +35,11 @@ export function AdminSidebar({ activeMenu }: AdminSidebarProps) {
3535
isActive ? "bg-primary-100 text-primary" : "text-k-400 hover:bg-k-0 hover:text-k-700",
3636
);
3737

38-
if ("to" in menu) {
39-
return (
40-
<a key={menu.label} href={menu.to} className={menuClassName}>
41-
<menu.icon className="h-4 w-4" />
42-
{menu.label}
43-
</a>
44-
);
45-
}
46-
4738
return (
48-
<button key={menu.label} type="button" className={menuClassName} disabled>
39+
<Link key={menu.label} to={menu.to} preload="intent" className={menuClassName}>
4940
<menu.icon className="h-4 w-4" />
5041
{menu.label}
51-
</button>
42+
</Link>
5243
);
5344
})}
5445
</nav>

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

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import type { AxiosResponse } from "axios";
2-
import { publicAxiosInstance } from "@/lib/api/client";
1+
import axios, { type AxiosResponse } from "axios";
32
import type { AdminSignInResponse, ReissueAccessTokenResponse } from "@/types/auth";
43

4+
const API_SERVER_URL = import.meta.env.VITE_API_SERVER_URL?.trim();
5+
6+
if (!API_SERVER_URL) {
7+
throw new Error("[admin] VITE_API_SERVER_URL is required. Configure it in your environment.");
8+
}
9+
10+
const authAxiosInstance = axios.create({
11+
baseURL: API_SERVER_URL,
12+
withCredentials: true,
13+
});
14+
515
export const adminSignInApi = (email: string, password: string): Promise<AxiosResponse<AdminSignInResponse>> =>
6-
publicAxiosInstance.post("/auth/email/sign-in", { email, password });
7-
8-
export const reissueAccessTokenApi = (refreshToken: string): Promise<AxiosResponse<ReissueAccessTokenResponse>> =>
9-
publicAxiosInstance.post(
10-
"/admin/auth/reissue",
11-
{},
12-
{
13-
headers: { Authorization: `Bearer ${refreshToken}` },
14-
},
15-
);
16+
authAxiosInstance.post("/auth/email/sign-in", { email, password });
17+
18+
export const reissueAccessTokenApi = (): Promise<AxiosResponse<ReissueAccessTokenResponse>> =>
19+
authAxiosInstance.post("/auth/reissue");

apps/admin/src/lib/api/client.ts

Lines changed: 30 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
1-
import axios, { type AxiosInstance } from "axios";
2-
import { reissueAccessTokenApi } from "@/lib/api/auth";
3-
import { isTokenExpired } from "@/lib/utils/jwtUtils";
4-
import {
5-
loadAccessToken,
6-
loadRefreshToken,
7-
removeAccessToken,
8-
removeRefreshToken,
9-
saveAccessToken,
10-
} from "@/lib/utils/localStorage";
1+
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from "axios";
2+
import { clearSession, ensureSessionToken, reissueAccessTokenIfPossible } from "@/lib/auth/session";
113

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

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

17+
const redirectToLogin = () => {
18+
if (typeof window !== "undefined" && window.location.pathname !== "/auth/login") {
19+
window.location.replace("/auth/login");
20+
}
21+
};
22+
2523
axiosInstance.interceptors.request.use(
2624
async (config) => {
2725
const newConfig = { ...config };
28-
let accessToken: string | null = loadAccessToken();
29-
30-
if (accessToken === null || isTokenExpired(accessToken)) {
31-
const refreshToken = loadRefreshToken();
32-
if (refreshToken === null || isTokenExpired(refreshToken)) {
33-
removeAccessToken();
34-
removeRefreshToken();
35-
return config;
36-
}
26+
const accessToken = await ensureSessionToken();
3727

38-
await reissueAccessTokenApi(refreshToken)
39-
.then((res) => {
40-
accessToken = res.data.accessToken;
41-
saveAccessToken(accessToken);
42-
})
43-
.catch((err) => {
44-
removeAccessToken();
45-
removeRefreshToken();
46-
console.error("인증 토큰 갱신중 오류가 발생했습니다", err);
47-
});
28+
if (!accessToken) {
29+
clearSession();
30+
redirectToLogin();
31+
return Promise.reject(new Error("로그인이 필요합니다."));
4832
}
4933

50-
if (accessToken !== null) {
51-
newConfig.headers.Authorization = convertToBearer(accessToken);
52-
}
34+
newConfig.headers.Authorization = convertToBearer(accessToken);
5335
return newConfig;
5436
},
5537
(error) => Promise.reject(error),
@@ -58,37 +40,25 @@ axiosInstance.interceptors.request.use(
5840
axiosInstance.interceptors.response.use(
5941
(response) => response,
6042
async (error) => {
61-
const newError = { ...error };
62-
if (error.response?.status === 401 || error.response?.status === 403) {
63-
const refreshToken = loadRefreshToken();
64-
65-
if (refreshToken === null || isTokenExpired(refreshToken)) {
66-
removeAccessToken();
67-
removeRefreshToken();
68-
throw newError;
69-
}
70-
71-
try {
72-
const newAccessToken = await reissueAccessTokenApi(refreshToken).then((res) => res.data.accessToken);
73-
saveAccessToken(newAccessToken);
43+
const status = error.response?.status;
44+
const originalRequest = error.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined;
7445

75-
if (error?.config.headers === undefined) {
76-
newError.config.headers = {};
77-
}
78-
newError.config.headers.Authorization = convertToBearer(newAccessToken);
46+
if ((status === 401 || status === 403) && originalRequest && !originalRequest._retry) {
47+
originalRequest._retry = true;
7948

80-
return await axios.request(newError.config);
81-
} catch (_err) {
82-
removeAccessToken();
83-
removeRefreshToken();
84-
throw Error("로그인이 필요합니다");
49+
const reissuedAccessToken = await reissueAccessTokenIfPossible();
50+
if (reissuedAccessToken) {
51+
originalRequest.headers = originalRequest.headers ?? {};
52+
originalRequest.headers.Authorization = convertToBearer(reissuedAccessToken);
53+
return axiosInstance(originalRequest);
8554
}
86-
} else {
87-
throw newError;
8855
}
56+
57+
if (status === 401 || status === 403) {
58+
clearSession();
59+
redirectToLogin();
60+
}
61+
62+
return Promise.reject(error);
8963
},
9064
);
91-
92-
export const publicAxiosInstance: AxiosInstance = axios.create({
93-
baseURL: API_SERVER_URL,
94-
});

apps/admin/src/lib/auth/session.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { redirect } from "@tanstack/react-router";
2+
import { reissueAccessTokenApi } from "@/lib/api/auth";
3+
import { isTokenExpired } from "@/lib/utils/jwtUtils";
4+
import { loadAccessToken, removeAccessToken, saveAccessToken } from "@/lib/utils/localStorage";
5+
6+
let reissuePromise: Promise<string | null> | null = null;
7+
8+
const getValidAccessToken = (): string | null => {
9+
const accessToken = loadAccessToken();
10+
if (!accessToken) {
11+
return null;
12+
}
13+
14+
if (isTokenExpired(accessToken)) {
15+
removeAccessToken();
16+
return null;
17+
}
18+
19+
return accessToken;
20+
};
21+
22+
export const clearSession = () => {
23+
removeAccessToken();
24+
};
25+
26+
export const reissueAccessTokenIfPossible = async (): Promise<string | null> => {
27+
if (reissuePromise) {
28+
return reissuePromise;
29+
}
30+
31+
reissuePromise = (async () => {
32+
try {
33+
const response = await reissueAccessTokenApi();
34+
const nextAccessToken = response.data.accessToken;
35+
36+
if (!nextAccessToken) {
37+
clearSession();
38+
return null;
39+
}
40+
41+
saveAccessToken(nextAccessToken);
42+
return nextAccessToken;
43+
} catch {
44+
clearSession();
45+
return null;
46+
} finally {
47+
reissuePromise = null;
48+
}
49+
})();
50+
51+
return reissuePromise;
52+
};
53+
54+
export const ensureSessionToken = async (): Promise<string | null> => {
55+
const validAccessToken = getValidAccessToken();
56+
if (validAccessToken) {
57+
return validAccessToken;
58+
}
59+
60+
return reissueAccessTokenIfPossible();
61+
};
62+
63+
export const requireAdminSession = async (): Promise<string> => {
64+
const token = await ensureSessionToken();
65+
if (!token) {
66+
throw redirect({ to: "/auth/login" });
67+
}
68+
69+
return token;
70+
};
71+
72+
export const redirectIfAuthenticated = async () => {
73+
const token = await ensureSessionToken();
74+
if (token) {
75+
throw redirect({ to: "/scores" });
76+
}
77+
};

apps/admin/src/lib/utils/localStorage.ts

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,3 @@
1-
export const loadRefreshToken = () => {
2-
try {
3-
return localStorage.getItem("refreshToken");
4-
} catch (err) {
5-
console.error("Could not load refresh token", err);
6-
return null;
7-
}
8-
};
9-
10-
export const saveRefreshToken = (token: string) => {
11-
try {
12-
localStorage.setItem("refreshToken", token);
13-
} catch (err) {
14-
console.error("Could not save refresh token", err);
15-
}
16-
};
17-
18-
export const removeRefreshToken = () => {
19-
try {
20-
localStorage.removeItem("refreshToken");
21-
} catch (err) {
22-
console.error("Could not remove refresh token", err);
23-
}
24-
};
25-
261
export const loadAccessToken = () => {
272
try {
283
return localStorage.getItem("accessToken");

apps/admin/src/routes/auth/login.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
77
import { Input } from "@/components/ui/input";
88
import { Label } from "@/components/ui/label";
99
import { adminSignInApi } from "@/lib/api/auth";
10-
import { saveAccessToken, saveRefreshToken } from "@/lib/utils/localStorage";
10+
import { redirectIfAuthenticated } from "@/lib/auth/session";
11+
import { saveAccessToken } from "@/lib/utils/localStorage";
1112

1213
export const Route = createFileRoute("/auth/login")({
14+
beforeLoad: async () => {
15+
await redirectIfAuthenticated();
16+
},
1317
component: LoginPage,
1418
});
1519

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

3337
try {
3438
const response = await signInMutation.mutateAsync({ nextEmail: email, nextPassword: password });
35-
const { accessToken, refreshToken } = response.data;
39+
const { accessToken } = response.data;
3640

3741
saveAccessToken(accessToken);
38-
saveRefreshToken(refreshToken);
3942

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

0 commit comments

Comments
 (0)