From 3860e7e88a4681ecff76d849c5903f4d95afe4b9 Mon Sep 17 00:00:00 2001 From: zio Date: Tue, 14 Jul 2026 02:25:17 +0900 Subject: [PATCH] fix(auth): rehydrate session store on refresh (empty workspaces broke event-scoped screens) Token lives in sessionStorage but user/workspaces existed only in the zustand store, so any full reload left workspaces=[] and every event-scoped screen fell to 'select an event' with an empty selector. RequireAuth now restores /api/auth/me + /api/auth/workspaces once when authenticated with an empty store, and logs out on 401. Co-Authored-By: Claude Fable 5 --- src/frontend/src/App.tsx | 39 ++++++++++++++++++++++++++++- src/frontend/src/store/authStore.ts | 4 +++ 2 files changed, 42 insertions(+), 1 deletion(-) 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: () => {