diff --git a/frontend/src/contexts/auth.context.tsx b/frontend/src/contexts/auth.context.tsx index d6b72228..7fac0e86 100644 --- a/frontend/src/contexts/auth.context.tsx +++ b/frontend/src/contexts/auth.context.tsx @@ -22,6 +22,7 @@ import { useLogout } from "@/hooks/entities/auth-hooks"; import { useApiClient } from "@/hooks/useApiClient"; import { CURRENT_USER_KEY, PUBLIC_PATHS } from "@/hooks/useAuth"; import { useBroadcastChannel } from "@/hooks/useBroadcastChannel"; +import { useTabUuid } from "@/hooks/useTabUuid"; import { useTranslate } from "@/hooks/useTranslate"; import { RouterType } from "@/services/types"; import { IUser } from "@/types/user.types"; @@ -108,8 +109,11 @@ export const AuthProvider = ({ children }: AuthProviderProps): JSX.Element => { setIsReady(true); }, []); + const tabUuidRef = useTabUuid(); + const tabUuid = tabUuidRef.current; + useBroadcastChannel("session", (e) => { - if (e.data === "logout") { + if (e.data.value === "logout" && e.data.uuid !== tabUuid) { router.reload(); } }); diff --git a/frontend/src/hooks/entities/auth-hooks.ts b/frontend/src/hooks/entities/auth-hooks.ts index 86d52d6d..60a69094 100755 --- a/frontend/src/hooks/entities/auth-hooks.ts +++ b/frontend/src/hooks/entities/auth-hooks.ts @@ -23,6 +23,7 @@ import { useFind } from "../crud/useFind"; import { useApiClient } from "../useApiClient"; import { CURRENT_USER_KEY, useAuth, useLogoutRedirection } from "../useAuth"; import { useBroadcastChannel } from "../useBroadcastChannel"; +import { useTabUuid } from "../useTabUuid"; import { useToast } from "../useToast"; import { useTranslate } from "../useTranslate"; @@ -60,6 +61,8 @@ export const useLogout = ( const { toast } = useToast(); const { t } = useTranslate(); const broadcastLogoutAcrossTabs = useBroadcastChannel("session"); + const tabUuidRef = useTabUuid(); + const tabUuid = tabUuidRef.current; return useMutation({ ...options, @@ -70,7 +73,10 @@ export const useLogout = ( }, onSuccess: async () => { queryClient.removeQueries([CURRENT_USER_KEY]); - broadcastLogoutAcrossTabs("logout"); + broadcastLogoutAcrossTabs({ + value: "logout", + uuid: tabUuid || "", + }); await logoutRedirection(); toast.success(t("message.logout_success")); }, diff --git a/frontend/src/hooks/useBroadcastChannel.ts b/frontend/src/hooks/useBroadcastChannel.ts index 8e718996..4d4417a2 100644 --- a/frontend/src/hooks/useBroadcastChannel.ts +++ b/frontend/src/hooks/useBroadcastChannel.ts @@ -8,13 +8,10 @@ import * as React from "react"; -export type BroadcastChannelData = - | string - | number - | boolean - | Record - | undefined - | null; +export type BroadcastChannelData = { + uuid: string; + value: string | number | boolean | Record | undefined | null; +}; /** * React hook to create and manage a Broadcast Channel across multiple browser windows. @@ -24,10 +21,12 @@ export type BroadcastChannelData = * @param handleMessageError [optional] Callback to handle the event generated when `error` is received. * @returns A function to send/post message on the channel. */ -export function useBroadcastChannel( +export function useBroadcastChannel< + T extends BroadcastChannelData = BroadcastChannelData, +>( channelName: string, - handleMessage?: (event: MessageEvent) => void, - handleMessageError?: (event: MessageEvent) => void, + handleMessage?: (event: MessageEvent) => void, + handleMessageError?: (event: MessageEvent) => void, ): (data: T) => void { const channelRef = React.useRef( typeof window !== "undefined" && "BroadcastChannel" in window @@ -52,7 +51,9 @@ export function useBroadcastChannel( * @param initialState Initial state. * @returns Tuple of state and setter for the state. */ -export function useBroadcastState( +export function useBroadcastState< + T extends BroadcastChannelData = BroadcastChannelData, +>( channelName: string, initialState: T, ): [T, React.Dispatch>, boolean] { diff --git a/frontend/src/hooks/useTabUuid.ts b/frontend/src/hooks/useTabUuid.ts new file mode 100644 index 00000000..50f07020 --- /dev/null +++ b/frontend/src/hooks/useTabUuid.ts @@ -0,0 +1,30 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { useEffect, useRef } from "react"; + +import { generateId } from "@/utils/generateId"; + +export const useTabUuid = (key: string = "tab_uuid") => { + const tabUuidRef = useRef(null); + + useEffect(() => { + const storedUuid = sessionStorage.getItem(key); + + if (storedUuid) { + tabUuidRef.current = storedUuid; + } else { + const newUuid = generateId(); + + sessionStorage.setItem(key, newUuid); + tabUuidRef.current = newUuid; + } + }, []); + + return tabUuidRef; +}; diff --git a/widget/src/hooks/useBroadcastChannel.ts b/widget/src/hooks/useBroadcastChannel.ts index 8e718996..4d4417a2 100644 --- a/widget/src/hooks/useBroadcastChannel.ts +++ b/widget/src/hooks/useBroadcastChannel.ts @@ -8,13 +8,10 @@ import * as React from "react"; -export type BroadcastChannelData = - | string - | number - | boolean - | Record - | undefined - | null; +export type BroadcastChannelData = { + uuid: string; + value: string | number | boolean | Record | undefined | null; +}; /** * React hook to create and manage a Broadcast Channel across multiple browser windows. @@ -24,10 +21,12 @@ export type BroadcastChannelData = * @param handleMessageError [optional] Callback to handle the event generated when `error` is received. * @returns A function to send/post message on the channel. */ -export function useBroadcastChannel( +export function useBroadcastChannel< + T extends BroadcastChannelData = BroadcastChannelData, +>( channelName: string, - handleMessage?: (event: MessageEvent) => void, - handleMessageError?: (event: MessageEvent) => void, + handleMessage?: (event: MessageEvent) => void, + handleMessageError?: (event: MessageEvent) => void, ): (data: T) => void { const channelRef = React.useRef( typeof window !== "undefined" && "BroadcastChannel" in window @@ -52,7 +51,9 @@ export function useBroadcastChannel( * @param initialState Initial state. * @returns Tuple of state and setter for the state. */ -export function useBroadcastState( +export function useBroadcastState< + T extends BroadcastChannelData = BroadcastChannelData, +>( channelName: string, initialState: T, ): [T, React.Dispatch>, boolean] { diff --git a/widget/src/hooks/useTabUuid.ts b/widget/src/hooks/useTabUuid.ts new file mode 100644 index 00000000..02363252 --- /dev/null +++ b/widget/src/hooks/useTabUuid.ts @@ -0,0 +1,30 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { useEffect, useRef } from "react"; + +import { generateId } from "../utils/generateId"; + +export const useTabUuid = (key: string = "tab_uuid") => { + const tabUuidRef = useRef(null); + + useEffect(() => { + const storedUuid = sessionStorage.getItem(key); + + if (storedUuid) { + tabUuidRef.current = storedUuid; + } else { + const newUuid = generateId(); + + sessionStorage.setItem(key, newUuid); + tabUuidRef.current = newUuid; + } + }, []); + + return tabUuidRef; +}; diff --git a/widget/src/providers/ChatProvider.tsx b/widget/src/providers/ChatProvider.tsx index f907c428..cf05da4f 100644 --- a/widget/src/providers/ChatProvider.tsx +++ b/widget/src/providers/ChatProvider.tsx @@ -17,6 +17,7 @@ import React, { } from "react"; import { useBroadcastChannel } from "../hooks/useBroadcastChannel"; +import { useTabUuid } from "../hooks/useTabUuid"; import { StdEventType } from "../types/chat-io-messages.types"; import { Direction, @@ -269,7 +270,7 @@ const ChatProvider: React.FC<{ content_type: QuickReplyType.text, text: qr.title, payload: qr.payload, - }) as ISuggestion, + } as ISuggestion), ), ); } else { @@ -452,9 +453,11 @@ const ChatProvider: React.FC<{ setMessage, handleSubscription, }; + const tabUuidRef = useTabUuid(); + const tabUuid = tabUuidRef.current; - useBroadcastChannel("session", (e) => { - if (e.data === "logout") { + useBroadcastChannel("session", ({ data }) => { + if (data.value === "logout" && data.uuid !== tabUuid) { socketCtx.socket.disconnect(); } }); diff --git a/widget/src/utils/generateId.ts b/widget/src/utils/generateId.ts new file mode 100644 index 00000000..44078912 --- /dev/null +++ b/widget/src/utils/generateId.ts @@ -0,0 +1,21 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + + +import { getRandom } from "./safeRandom"; + +export const generateId = () => { + const d = + typeof performance === "undefined" ? Date.now() : performance.now() * 1000; + + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (getRandom() * 16 + d) % 16 | 0; + + return (c == "x" ? r : (r & 0x3) | 0x8).toString(16); + }); +}; diff --git a/widget/src/utils/safeRandom.ts b/widget/src/utils/safeRandom.ts new file mode 100644 index 00000000..a344522b --- /dev/null +++ b/widget/src/utils/safeRandom.ts @@ -0,0 +1,16 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + + +/** + * Return a cryptographically secure random value between 0 and 1 + * + * @returns A cryptographically secure random value between 0 and 1 + */ +export const getRandom = (): number => + window.crypto.getRandomValues(new Uint32Array(1))[0] * Math.pow(2, -32);