Skip to content

Commit ae2d7bb

Browse files
committed
fix: restore auth before protected chat access
1 parent 3fd17c1 commit ae2d7bb

4 files changed

Lines changed: 117 additions & 9 deletions

File tree

src/app/chat/[roomId]/page.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,19 @@ interface ChatRoomPageProps {
1717
params: Promise<{ roomId: string }>;
1818
}
1919

20+
function getRoomAccessErrorMessage(error: unknown) {
21+
if (error instanceof Error && error.message) return error.message;
22+
return "채팅방을 볼 수 없습니다.";
23+
}
24+
2025
export default function ChatRoomPage({ params }: ChatRoomPageProps) {
2126
const { roomId } = use(params);
2227
const id = Number(roomId);
2328

2429
const { myUserId } = useSession();
2530

2631
// 방 정보: 딥링크/새로고침 대비 상세 조회(useChatRoom) 우선, 없으면 방 목록 캐시 폴백.
27-
const { data: roomDetail } = useChatRoom(id);
32+
const { data: roomDetail, isError: isRoomError, error: roomError } = useChatRoom(id);
2833
const { data: roomsData } = useChatRooms();
2934
const roomListItem = useMemo(
3035
() => roomsData?.pages.flatMap(page => page.items).find(r => r.id === id),
@@ -48,9 +53,13 @@ export default function ChatRoomPage({ params }: ChatRoomPageProps) {
4853
hasNextPage,
4954
isFetchingNextPage,
5055
fetchNextPage,
56+
isError: isMessagesError,
57+
error: messagesError,
58+
refetch: refetchMessages,
5159
} = useMessages(id);
5260

53-
const { sendMessage, markAsRead, lastError, isConnected } = useChatSocket(id);
61+
const canConnectSocket = Boolean(roomDetail || roomListItem);
62+
const { sendMessage, markAsRead, lastError, isConnected } = useChatSocket(id, canConnectSocket);
5463

5564
const messages = useMemo(
5665
() => messagesData?.pages.flatMap(page => page.items) ?? [],
@@ -64,6 +73,24 @@ export default function ChatRoomPage({ params }: ChatRoomPageProps) {
6473
// eslint-disable-next-line react-hooks/exhaustive-deps
6574
}, [id, isConnected]);
6675

76+
if (!Number.isFinite(id) || isRoomError) {
77+
return (
78+
<div className="bg-bg-primary flex h-screen flex-col">
79+
<ChatHeader title={null} />
80+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
81+
<p className="text-body-1 text-text-primary font-medium">
82+
{!Number.isFinite(id)
83+
? "올바르지 않은 채팅방입니다."
84+
: getRoomAccessErrorMessage(roomError)}
85+
</p>
86+
<p className="text-body-2 text-text-secondary">
87+
채팅방 참여자만 대화 내용을 볼 수 있습니다.
88+
</p>
89+
</div>
90+
</div>
91+
);
92+
}
93+
6794
return (
6895
<div className="bg-bg-primary flex h-screen flex-col">
6996
<ChatHeader title={counterpartyNickname} />
@@ -83,6 +110,19 @@ export default function ChatRoomPage({ params }: ChatRoomPageProps) {
83110
<div className="flex flex-1 items-center justify-center">
84111
<div className="text-body-1 text-text-secondary">메시지를 불러오는 중...</div>
85112
</div>
113+
) : isMessagesError ? (
114+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
115+
<p className="text-body-1 text-text-primary font-medium">
116+
{getRoomAccessErrorMessage(messagesError)}
117+
</p>
118+
<button
119+
type="button"
120+
onClick={() => void refetchMessages()}
121+
className="border-border-primary text-body-2 text-text-primary h-9 rounded-lg border px-4 font-medium"
122+
>
123+
다시 불러오기
124+
</button>
125+
</div>
86126
) : (
87127
<MessageList
88128
messages={messages}

src/app/chat/page.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,22 @@ import EmptyChat from "@/components/chat/EmptyChat";
77
import { CHAT_LIST_TITLE, CHAT_LOADING_MESSAGE } from "@/constants/chat";
88
import { useChatRooms } from "@/hooks/useChatRooms";
99

10+
function getErrorMessage(error: unknown) {
11+
return error instanceof Error ? error.message : "채팅 목록을 불러오지 못했습니다.";
12+
}
13+
1014
export default function ChatListPage() {
1115
const router = useRouter();
12-
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useChatRooms();
16+
const {
17+
data,
18+
isLoading,
19+
isError,
20+
error,
21+
isFetchingNextPage,
22+
hasNextPage,
23+
fetchNextPage,
24+
refetch,
25+
} = useChatRooms();
1326

1427
const rooms = data?.pages.flatMap(page => page.items) ?? [];
1528

@@ -23,6 +36,17 @@ export default function ChatListPage() {
2336
<div className="flex flex-1 items-center justify-center">
2437
<div className="text-body-1 text-text-secondary">{CHAT_LOADING_MESSAGE}</div>
2538
</div>
39+
) : isError ? (
40+
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
41+
<p className="text-body-1 text-text-primary font-medium">{getErrorMessage(error)}</p>
42+
<button
43+
type="button"
44+
onClick={() => void refetch()}
45+
className="border-border-primary text-body-2 text-text-primary h-9 rounded-lg border px-4 font-medium"
46+
>
47+
다시 불러오기
48+
</button>
49+
</div>
2650
) : rooms.length === 0 ? (
2751
<EmptyChat />
2852
) : (

src/hooks/useChatSocket.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export interface UseChatSocketResult {
4646
markAsRead: () => void;
4747
}
4848

49-
export function useChatSocket(roomId: number): UseChatSocketResult {
49+
export function useChatSocket(roomId: number, enabled = true): UseChatSocketResult {
5050
const queryClient = useQueryClient();
5151
const { accessToken, myUserId } = useSession();
5252
const socketRef = useRef<ChatRoomSocket | null>(null);
@@ -55,7 +55,7 @@ export function useChatSocket(roomId: number): UseChatSocketResult {
5555

5656
useEffect(() => {
5757
// 토큰/유저 없으면 연결하지 않는다(WEBSOCKET_UNAUTHORIZED 방지).
58-
if (!accessToken || myUserId === null || !Number.isFinite(roomId)) {
58+
if (!enabled || !accessToken || myUserId === null || !Number.isFinite(roomId)) {
5959
return;
6060
}
6161

@@ -86,7 +86,7 @@ export function useChatSocket(roomId: number): UseChatSocketResult {
8686
void socket.dispose();
8787
};
8888
// roomId/인증이 바뀌면 재연결. queryClient는 안정적.
89-
}, [roomId, accessToken, myUserId, queryClient]);
89+
}, [roomId, enabled, accessToken, myUserId, queryClient]);
9090

9191
return {
9292
isConnected,

src/hooks/useRequireAuth.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { useEffect, useState } from "react";
44

55
import { useRouter } from "next/navigation";
66

7+
import { getMe, reissue } from "@/services/authApi";
78
import { useAuthStore } from "@/stores/useAuthStore";
89

910
const AUTH_STORAGE_KEY = "refit-auth";
11+
let restoreAuthPromise: Promise<boolean> | null = null;
1012

1113
interface StoredAuthState {
1214
accessToken: string | null;
@@ -36,27 +38,66 @@ function getStoredAuthState(): StoredAuthState | null {
3638
}
3739
}
3840

41+
function restoreAuthFromRefreshCookie() {
42+
if (!restoreAuthPromise) {
43+
restoreAuthPromise = (async () => {
44+
try {
45+
const tokenData = await reissue();
46+
if (!tokenData.accessToken) return false;
47+
48+
useAuthStore.setState({ accessToken: tokenData.accessToken });
49+
50+
const me = await getMe();
51+
useAuthStore.setState({ userId: me.userId });
52+
53+
return true;
54+
} catch {
55+
useAuthStore.getState().clearAuth();
56+
return false;
57+
}
58+
})().finally(() => {
59+
restoreAuthPromise = null;
60+
});
61+
}
62+
63+
return restoreAuthPromise;
64+
}
65+
3966
export function useRequireAuth(redirectTo = "/auth") {
4067
const router = useRouter();
4168
const accessToken = useAuthStore(state => state.accessToken);
4269
const [hasStoredAccessToken, setHasStoredAccessToken] = useState(false);
4370
const [isAuthReady, setIsAuthReady] = useState(false);
4471

4572
useEffect(() => {
73+
let cancelled = false;
74+
4675
const syncStoredAuth = () => {
4776
const storedAuth = getStoredAuthState();
4877
const currentAuth = useAuthStore.getState();
4978

79+
if (currentAuth.accessToken) {
80+
setHasStoredAccessToken(false);
81+
setIsAuthReady(true);
82+
return;
83+
}
84+
5085
setHasStoredAccessToken(Boolean(storedAuth?.accessToken));
5186

52-
if (!currentAuth.accessToken && storedAuth?.accessToken) {
87+
if (storedAuth?.accessToken) {
5388
useAuthStore.setState({
5489
accessToken: storedAuth.accessToken,
5590
userId: storedAuth.userId,
5691
});
92+
setIsAuthReady(true);
93+
return;
5794
}
5895

59-
setIsAuthReady(true);
96+
void restoreAuthFromRefreshCookie().finally(() => {
97+
if (!cancelled) {
98+
setIsAuthReady(true);
99+
}
100+
});
60101
};
61102

62103
const unsubscribe = useAuthStore.persist?.onFinishHydration?.(syncStoredAuth);
@@ -68,7 +109,10 @@ export function useRequireAuth(redirectTo = "/auth") {
68109
useAuthStore.persist?.rehydrate?.();
69110
}
70111

71-
return unsubscribe;
112+
return () => {
113+
cancelled = true;
114+
unsubscribe?.();
115+
};
72116
}, []);
73117

74118
useEffect(() => {

0 commit comments

Comments
 (0)