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 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 02:25:17 +09:00
parent ecc00f8739
commit 3860e7e88a
2 changed files with 42 additions and 1 deletions

View File

@ -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 <DocsMilestonePage onOpenAuthoring={() => 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 <Navigate to="/login" replace />;
if (needsHydration) return null; // 복원 완료까지 렌더 보류(빈 workspaces로 화면 오판 방지)
return <>{children}</>;
}

View File

@ -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<AuthState>((set, get) => ({
});
},
applySession: (user, workspaces) => set({ user, workspaces, isAuthenticated: true }),
selectEvent: (eventId) => set({ currentEventId: eventId }),
logout: () => {