From ad2e0ede42795b42518a749c19dc4009bc8688fb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 02:17:14 +0300 Subject: [PATCH] web: fix auth session races, ws token drift, and unread clear behavior --- web/src/api/http.ts | 35 +++++++++++++++++++++++++ web/src/components/MessageComposer.tsx | 18 +++++++++++++ web/src/hooks/useRealtime.ts | 4 +-- web/src/pages/ChatsPage.tsx | 2 +- web/src/store/authStore.ts | 13 ++++++++-- web/src/store/chatStore.ts | 36 +++++++++++++++++++++----- 6 files changed, 96 insertions(+), 12 deletions(-) diff --git a/web/src/api/http.ts b/web/src/api/http.ts index e48c7ef..1506c56 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import type { AxiosError, InternalAxiosRequestConfig } from "axios"; import { useAuthStore } from "../store/authStore"; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api/v1"; @@ -15,3 +16,37 @@ http.interceptors.request.use((config) => { } return config; }); + +let refreshInFlight: Promise | null = null; + +function shouldSkipRefresh(config?: InternalAxiosRequestConfig): boolean { + const url = config?.url ?? ""; + return url.includes("/auth/login") || url.includes("/auth/refresh"); +} + +http.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const status = error.response?.status; + const originalRequest = error.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined; + if (!originalRequest || status !== 401 || originalRequest._retry || shouldSkipRefresh(originalRequest)) { + return Promise.reject(error); + } + + originalRequest._retry = true; + const authStore = useAuthStore.getState(); + + try { + if (!refreshInFlight) { + refreshInFlight = authStore.refresh().finally(() => { + refreshInFlight = null; + }); + } + await refreshInFlight; + return http.request(originalRequest); + } catch (refreshError) { + useAuthStore.getState().logout(); + return Promise.reject(refreshError); + } + } +); diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index d1f1743..e4afa2b 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -96,6 +96,7 @@ export function MessageComposer() { const [text, setText] = useState(""); const wsRef = useRef(null); + const wsTokenRef = useRef(null); const recorderRef = useRef(null); const recordingStreamRef = useRef(null); const chunksRef = useRef([]); @@ -252,9 +253,21 @@ export function MessageComposer() { if (typingStopTimerRef.current !== null) { window.clearTimeout(typingStopTimerRef.current); } + wsRef.current?.close(); + wsRef.current = null; + wsTokenRef.current = null; }; }, [previewUrl]); + useEffect(() => { + const activeToken = accessToken ?? null; + if (wsRef.current && wsTokenRef.current !== activeToken) { + wsRef.current.close(); + wsRef.current = null; + } + wsTokenRef.current = activeToken; + }, [accessToken]); + useEffect(() => { if (!activeChatId && recordingStateRef.current !== "idle") { stopRecord(false); @@ -318,11 +331,16 @@ export function MessageComposer() { if (!accessToken || !activeChatId) { return null; } + if (wsRef.current && wsTokenRef.current !== accessToken) { + wsRef.current.close(); + wsRef.current = null; + } if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { return wsRef.current; } const wsUrl = buildWsUrl(accessToken); wsRef.current = new WebSocket(wsUrl); + wsTokenRef.current = accessToken; return wsRef.current; } diff --git a/web/src/hooks/useRealtime.ts b/web/src/hooks/useRealtime.ts index 8f71109..c7e314f 100644 --- a/web/src/hooks/useRealtime.ts +++ b/web/src/hooks/useRealtime.ts @@ -160,7 +160,7 @@ export function useRealtime() { window.dispatchEvent(new CustomEvent("bm:chat-updated", { detail: { chatId } })); scheduleReloadChats(); if (chatStore.activeChatId === chatId || (chatStore.messagesByChat[chatId]?.length ?? 0) > 0) { - void chatStore.loadMessages(chatId); + void chatStore.loadMessages(chatId, { markRead: false }); } } } @@ -398,7 +398,7 @@ export function useRealtime() { uniqueChatIds.add(storeAfter.activeChatId); } for (const chatId of uniqueChatIds) { - await storeAfter.loadMessages(chatId); + await storeAfter.loadMessages(chatId, { markRead: false }); } } diff --git a/web/src/pages/ChatsPage.tsx b/web/src/pages/ChatsPage.tsx index da5fbd7..05da74b 100644 --- a/web/src/pages/ChatsPage.tsx +++ b/web/src/pages/ChatsPage.tsx @@ -44,7 +44,7 @@ export function ChatsPage() { useEffect(() => { if (activeChatId) { - void loadMessages(activeChatId); + void loadMessages(activeChatId, { markRead: true }); } }, [activeChatId, loadMessages]); diff --git a/web/src/store/authStore.ts b/web/src/store/authStore.ts index 51a051e..b8db5b5 100644 --- a/web/src/store/authStore.ts +++ b/web/src/store/authStore.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { loginRequest, meRequest, refreshRequest } from "../api/auth"; import type { AuthUser } from "../chat/types"; +import { useChatStore } from "./chatStore"; interface AuthState { accessToken: string | null; @@ -25,7 +26,7 @@ export const useAuthStore = create((set, get) => ({ setTokens: (accessToken, refreshToken) => { localStorage.setItem(ACCESS_KEY, accessToken); localStorage.setItem(REFRESH_KEY, refreshToken); - set({ accessToken, refreshToken }); + set({ accessToken, refreshToken, me: null }); }, login: async (email, password, otpCode, recoveryCode) => { set({ loading: true }); @@ -38,8 +39,15 @@ export const useAuthStore = create((set, get) => ({ } }, loadMe: async () => { + const tokenAtStart = get().accessToken; + if (!tokenAtStart) { + set({ me: null }); + return; + } const me = await meRequest(); - set({ me }); + if (get().accessToken === tokenAtStart) { + set({ me }); + } }, refresh: async () => { const token = get().refreshToken; @@ -53,6 +61,7 @@ export const useAuthStore = create((set, get) => ({ logout: () => { localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(REFRESH_KEY); + useChatStore.getState().reset(); set({ accessToken: null, refreshToken: null, me: null }); } })); diff --git a/web/src/store/chatStore.ts b/web/src/store/chatStore.ts index 03f6474..0fd30e5 100644 --- a/web/src/store/chatStore.ts +++ b/web/src/store/chatStore.ts @@ -69,7 +69,7 @@ interface ChatState { focusedMessageIdByChat: Record; loadChats: (query?: string) => Promise; setActiveChatId: (chatId: number | null) => void; - loadMessages: (chatId: number) => Promise; + loadMessages: (chatId: number, options?: { markRead?: boolean }) => Promise; loadMoreMessages: (chatId: number) => Promise; prependMessage: (chatId: number, message: Message) => boolean; addOptimisticMessage: (params: { @@ -105,6 +105,7 @@ interface ChatState { setDraft: (chatId: number, text: string) => void; clearDraft: (chatId: number) => void; setFocusedMessage: (chatId: number, messageId: number | null) => void; + reset: () => void; } export const useChatStore = create((set, get) => ({ @@ -128,7 +129,8 @@ export const useChatStore = create((set, get) => ({ set({ chats, activeChatId: nextActive }); }, setActiveChatId: (chatId) => set({ activeChatId: chatId }), - loadMessages: async (chatId) => { + loadMessages: async (chatId, options) => { + const markRead = options?.markRead === true; const unreadCount = get().chats.find((c) => c.id === chatId)?.unread_count ?? 0; const messages = await getMessages(chatId); const sorted = [...messages].reverse(); @@ -155,12 +157,14 @@ export const useChatStore = create((set, get) => ({ ...state.loadingMoreByChat, [chatId]: false }, - chats: state.chats.map((chat) => - chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat - ) + chats: markRead + ? state.chats.map((chat) => + chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat + ) + : state.chats })); const lastMessage = normalized[normalized.length - 1]; - if (lastMessage) { + if (markRead && lastMessage) { void updateMessageStatus(chatId, lastMessage.id, "message_read"); } }, @@ -515,5 +519,23 @@ export const useChatStore = create((set, get) => ({ ...state.focusedMessageIdByChat, [chatId]: messageId } - })) + })), + reset: () => { + saveDraftsToStorage({}); + set({ + chats: [], + activeChatId: null, + messagesByChat: {}, + draftsByChat: {}, + hasMoreByChat: {}, + loadingMoreByChat: {}, + typingByChat: {}, + recordingVoiceByChat: {}, + recordingVideoByChat: {}, + replyToByChat: {}, + editingByChat: {}, + unreadBoundaryByChat: {}, + focusedMessageIdByChat: {} + }); + } }));