web: fix auth session races, ws token drift, and unread clear behavior
Some checks failed
CI / test (push) Failing after 2m20s
Some checks failed
CI / test (push) Failing after 2m20s
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
});
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user