web: fix auth session races, ws token drift, and unread clear behavior
Some checks failed
CI / test (push) Failing after 2m20s

This commit is contained in:
Codex
2026-03-09 02:17:14 +03:00
parent 4fa657ff7a
commit ad2e0ede42
6 changed files with 96 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import type { AxiosError, InternalAxiosRequestConfig } from "axios";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api/v1"; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api/v1";
@@ -15,3 +16,37 @@ http.interceptors.request.use((config) => {
} }
return config; return config;
}); });
let refreshInFlight: Promise<void> | 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);
}
}
);

View File

@@ -96,6 +96,7 @@ export function MessageComposer() {
const [text, setText] = useState(""); const [text, setText] = useState("");
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const wsTokenRef = useRef<string | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null); const recorderRef = useRef<MediaRecorder | null>(null);
const recordingStreamRef = useRef<MediaStream | null>(null); const recordingStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<BlobPart[]>([]); const chunksRef = useRef<BlobPart[]>([]);
@@ -252,9 +253,21 @@ export function MessageComposer() {
if (typingStopTimerRef.current !== null) { if (typingStopTimerRef.current !== null) {
window.clearTimeout(typingStopTimerRef.current); window.clearTimeout(typingStopTimerRef.current);
} }
wsRef.current?.close();
wsRef.current = null;
wsTokenRef.current = null;
}; };
}, [previewUrl]); }, [previewUrl]);
useEffect(() => {
const activeToken = accessToken ?? null;
if (wsRef.current && wsTokenRef.current !== activeToken) {
wsRef.current.close();
wsRef.current = null;
}
wsTokenRef.current = activeToken;
}, [accessToken]);
useEffect(() => { useEffect(() => {
if (!activeChatId && recordingStateRef.current !== "idle") { if (!activeChatId && recordingStateRef.current !== "idle") {
stopRecord(false); stopRecord(false);
@@ -318,11 +331,16 @@ export function MessageComposer() {
if (!accessToken || !activeChatId) { if (!accessToken || !activeChatId) {
return null; return null;
} }
if (wsRef.current && wsTokenRef.current !== accessToken) {
wsRef.current.close();
wsRef.current = null;
}
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
return wsRef.current; return wsRef.current;
} }
const wsUrl = buildWsUrl(accessToken); const wsUrl = buildWsUrl(accessToken);
wsRef.current = new WebSocket(wsUrl); wsRef.current = new WebSocket(wsUrl);
wsTokenRef.current = accessToken;
return wsRef.current; return wsRef.current;
} }

View File

@@ -160,7 +160,7 @@ export function useRealtime() {
window.dispatchEvent(new CustomEvent("bm:chat-updated", { detail: { chatId } })); window.dispatchEvent(new CustomEvent("bm:chat-updated", { detail: { chatId } }));
scheduleReloadChats(); scheduleReloadChats();
if (chatStore.activeChatId === chatId || (chatStore.messagesByChat[chatId]?.length ?? 0) > 0) { 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); uniqueChatIds.add(storeAfter.activeChatId);
} }
for (const chatId of uniqueChatIds) { for (const chatId of uniqueChatIds) {
await storeAfter.loadMessages(chatId); await storeAfter.loadMessages(chatId, { markRead: false });
} }
} }

View File

@@ -44,7 +44,7 @@ export function ChatsPage() {
useEffect(() => { useEffect(() => {
if (activeChatId) { if (activeChatId) {
void loadMessages(activeChatId); void loadMessages(activeChatId, { markRead: true });
} }
}, [activeChatId, loadMessages]); }, [activeChatId, loadMessages]);

View File

@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { loginRequest, meRequest, refreshRequest } from "../api/auth"; import { loginRequest, meRequest, refreshRequest } from "../api/auth";
import type { AuthUser } from "../chat/types"; import type { AuthUser } from "../chat/types";
import { useChatStore } from "./chatStore";
interface AuthState { interface AuthState {
accessToken: string | null; accessToken: string | null;
@@ -25,7 +26,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
setTokens: (accessToken, refreshToken) => { setTokens: (accessToken, refreshToken) => {
localStorage.setItem(ACCESS_KEY, accessToken); localStorage.setItem(ACCESS_KEY, accessToken);
localStorage.setItem(REFRESH_KEY, refreshToken); localStorage.setItem(REFRESH_KEY, refreshToken);
set({ accessToken, refreshToken }); set({ accessToken, refreshToken, me: null });
}, },
login: async (email, password, otpCode, recoveryCode) => { login: async (email, password, otpCode, recoveryCode) => {
set({ loading: true }); set({ loading: true });
@@ -38,8 +39,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
}, },
loadMe: async () => { loadMe: async () => {
const tokenAtStart = get().accessToken;
if (!tokenAtStart) {
set({ me: null });
return;
}
const me = await meRequest(); const me = await meRequest();
set({ me }); if (get().accessToken === tokenAtStart) {
set({ me });
}
}, },
refresh: async () => { refresh: async () => {
const token = get().refreshToken; const token = get().refreshToken;
@@ -53,6 +61,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logout: () => { logout: () => {
localStorage.removeItem(ACCESS_KEY); localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY); localStorage.removeItem(REFRESH_KEY);
useChatStore.getState().reset();
set({ accessToken: null, refreshToken: null, me: null }); set({ accessToken: null, refreshToken: null, me: null });
} }
})); }));

View File

@@ -69,7 +69,7 @@ interface ChatState {
focusedMessageIdByChat: Record<number, number | null>; focusedMessageIdByChat: Record<number, number | null>;
loadChats: (query?: string) => Promise<void>; loadChats: (query?: string) => Promise<void>;
setActiveChatId: (chatId: number | null) => void; setActiveChatId: (chatId: number | null) => void;
loadMessages: (chatId: number) => Promise<void>; loadMessages: (chatId: number, options?: { markRead?: boolean }) => Promise<void>;
loadMoreMessages: (chatId: number) => Promise<void>; loadMoreMessages: (chatId: number) => Promise<void>;
prependMessage: (chatId: number, message: Message) => boolean; prependMessage: (chatId: number, message: Message) => boolean;
addOptimisticMessage: (params: { addOptimisticMessage: (params: {
@@ -105,6 +105,7 @@ interface ChatState {
setDraft: (chatId: number, text: string) => void; setDraft: (chatId: number, text: string) => void;
clearDraft: (chatId: number) => void; clearDraft: (chatId: number) => void;
setFocusedMessage: (chatId: number, messageId: number | null) => void; setFocusedMessage: (chatId: number, messageId: number | null) => void;
reset: () => void;
} }
export const useChatStore = create<ChatState>((set, get) => ({ export const useChatStore = create<ChatState>((set, get) => ({
@@ -128,7 +129,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
set({ chats, activeChatId: nextActive }); set({ chats, activeChatId: nextActive });
}, },
setActiveChatId: (chatId) => set({ activeChatId: chatId }), 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 unreadCount = get().chats.find((c) => c.id === chatId)?.unread_count ?? 0;
const messages = await getMessages(chatId); const messages = await getMessages(chatId);
const sorted = [...messages].reverse(); const sorted = [...messages].reverse();
@@ -155,12 +157,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
...state.loadingMoreByChat, ...state.loadingMoreByChat,
[chatId]: false [chatId]: false
}, },
chats: state.chats.map((chat) => chats: markRead
chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat ? state.chats.map((chat) =>
) chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat
)
: state.chats
})); }));
const lastMessage = normalized[normalized.length - 1]; const lastMessage = normalized[normalized.length - 1];
if (lastMessage) { if (markRead && lastMessage) {
void updateMessageStatus(chatId, lastMessage.id, "message_read"); void updateMessageStatus(chatId, lastMessage.id, "message_read");
} }
}, },
@@ -515,5 +519,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
...state.focusedMessageIdByChat, ...state.focusedMessageIdByChat,
[chatId]: messageId [chatId]: messageId
} }
})) })),
reset: () => {
saveDraftsToStorage({});
set({
chats: [],
activeChatId: null,
messagesByChat: {},
draftsByChat: {},
hasMoreByChat: {},
loadingMoreByChat: {},
typingByChat: {},
recordingVoiceByChat: {},
recordingVideoByChat: {},
replyToByChat: {},
editingByChat: {},
unreadBoundaryByChat: {},
focusedMessageIdByChat: {}
});
}
})); }));