diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 1426f3c..b48a045 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,4 +1,6 @@ +import React from 'react'; import { Navigate, Outlet, Route, Routes, useNavigate } from 'react-router-dom'; +import { authApi } from './api/endpoints'; import { useAuthStore } from './store/authStore'; import { landingPathFor } from './lib/roleTrack'; import { LoginPage } from './screens/login/LoginPage'; @@ -95,10 +97,45 @@ function DocsMilestoneRoute() { return navigate('/docs/authoring')} />; } -/** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */ +/** + * 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) + * 새로고침 재수화: 토큰(sessionStorage)은 살아있는데 스토어가 빈 상태(user=null·workspaces=[])면 + * /api/auth/me + /api/auth/workspaces 로 복원한다 — 없으면 행사스코프 화면 전체가 + * "행사를 선택해 주세요"로 깨진다(2026-07-14 소유자 제보). 401이면 토큰 무효 → 로그아웃. + */ function RequireAuth({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const user = useAuthStore((s) => s.user); + const applySession = useAuthStore((s) => s.applySession); + const logout = useAuthStore((s) => s.logout); + const needsHydration = isAuthenticated && user == null; + + React.useEffect(() => { + if (!needsHydration) return; + let cancelled = false; + void Promise.all([authApi.me(), authApi.workspaces()]) + .then(([me, workspaces]) => { + if (cancelled) return; + applySession( + { + userId: me.userId, + displayName: me.displayName, + hallManager: me.hallManager, + roleCode: me.roleCode ?? null, + }, + workspaces, + ); + }) + .catch(() => { + if (!cancelled) logout(); + }); + return () => { + cancelled = true; + }; + }, [needsHydration, applySession, logout]); + if (!isAuthenticated) return ; + if (needsHydration) return null; // 복원 완료까지 렌더 보류(빈 workspaces로 화면 오판 방지) return <>{children}; } diff --git a/src/frontend/src/store/authStore.ts b/src/frontend/src/store/authStore.ts index 9312b2b..92a9efd 100644 --- a/src/frontend/src/store/authStore.ts +++ b/src/frontend/src/store/authStore.ts @@ -13,6 +13,8 @@ interface AuthState { currentEventId: string | null; isAuthenticated: boolean; applyLogin: (res: LoginResponse) => void; + /** 새로고침 세션 재수화 — 토큰은 살아있지만 스토어가 빈 상태(user=null)일 때 /me·/workspaces 응답으로 복원. */ + applySession: (user: AuthUser, workspaces: WorkspaceDto[]) => void; selectEvent: (eventId: string) => void; logout: () => void; currentWorkspace: () => WorkspaceDto | null; @@ -38,6 +40,8 @@ export const useAuthStore = create((set, get) => ({ }); }, + applySession: (user, workspaces) => set({ user, workspaces, isAuthenticated: true }), + selectEvent: (eventId) => set({ currentEventId: eventId }), logout: () => {