-
Notifications
You must be signed in to change notification settings - Fork 3
fix(admin): 로그인 라이프사이클 정비 및 사이드바 정리 #501
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. 로그아웃 시 refresh cookie도 무효화해 주세요. 🐛 수정 방향 예시- 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" });
+ };추가로 🤖 Prompt for AI Agents |
||
| }; | ||
|
|
||
| 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"> | ||
|
|
@@ -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> | ||
| ); | ||
|
|
||
| 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"); |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.tsRepository: 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 -30Repository: solid-connection/solid-connect-web Length of output: 1310 🏁 Script executed: cat apps/admin/src/components/layout/AdminLayout.tsxRepository: solid-connection/solid-connect-web Length of output: 2519 🏁 Script executed: cat apps/admin/src/lib/api/auth.tsRepository: 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 -20Repository: 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 -10Repository: solid-connection/solid-connect-web Length of output: 107 🏁 Script executed: # Check the actual auth route configuration
cat apps/admin/src/routes/auth/login.tsxRepository: solid-connection/solid-connect-web Length of output: 3636 로그아웃 후 refresh 쿠키로 인한 즉시 재인증 방지 필요합니다.
아래 두 가지가 필요합니다.
구현 예시: 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 |
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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" }); | ||
| } | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
handleLogoutonly removes the access token from localStorage and immediately navigates to/auth/login, but the refresh cookie is left intact. Because/auth/loginnow runsredirectIfAuthenticated, users with a still-valid refresh cookie will get a new access token via/auth/reissueand be redirected back to/scores, so logout does not actually terminate the session in normal authenticated flows.Useful? React with 👍 / 👎.