From f567913d508fc8aa576991b9df51a30fe4dd558d Mon Sep 17 00:00:00 2001 From: "chip-peanut-bot[bot]" <262992217+chip-peanut-bot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:48:47 +0000 Subject: [PATCH 01/22] fix: prevent duplicate Crisp conversations with session continuity tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CRISP_TOKEN_ID support using a deterministic SHA-256 hash of the user's ID. This ensures the same user always resumes the same Crisp conversation, even if cookies are cleared or they switch devices. Previously, Crisp relied solely on browser cookies for session identity. When cookies were lost (cleared, incognito, iframe cookie partitioning in Safari/Firefox), a new anonymous session was created — leading to duplicate conversations for the same user. Changes: - New hook: useCrispTokenId — generates stable token from SHA-256(userId) - crisp-proxy/page.tsx — sets CRISP_TOKEN_ID before Crisp script loads - useCrispProxyUrl — passes token as URL param to proxy page - SupportDrawer — wires up the token hook - crisp.ts — clears CRISP_TOKEN_ID on logout/session reset - global.d.ts — adds CRISP_TOKEN_ID and CRISP_WEBSITE_ID to Window type Ref: https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/ --- src/app/crisp-proxy/page.tsx | 11 ++++- src/components/Global/SupportDrawer/index.tsx | 4 +- src/hooks/useCrispProxyUrl.ts | 8 +++- src/hooks/useCrispTokenId.ts | 48 +++++++++++++++++++ src/types/global.d.ts | 2 + src/utils/crisp.ts | 5 ++ 6 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useCrispTokenId.ts diff --git a/src/app/crisp-proxy/page.tsx b/src/app/crisp-proxy/page.tsx index 37e9c8c22f..70a7003a2c 100644 --- a/src/app/crisp-proxy/page.tsx +++ b/src/app/crisp-proxy/page.tsx @@ -25,8 +25,17 @@ function CrispProxyContent() { lock_full_view: true, cross_origin_cookies: true, // Essential for session persistence in iframes } + + // Set CRISP_TOKEN_ID for session continuity — must be set before Crisp loads. + // This ensures the same user always gets the same conversation, even if + // cookies are cleared or they switch devices/browsers. + // @see https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/ + const crispTokenId = searchParams.get('crisp_token_id') + if (crispTokenId) { + ;(window as any).CRISP_TOKEN_ID = crispTokenId + } } - }, []) + }, [searchParams]) useEffect(() => { if (typeof window === 'undefined') return diff --git a/src/components/Global/SupportDrawer/index.tsx b/src/components/Global/SupportDrawer/index.tsx index 74e2eb5a7d..bc4aeb6ec0 100644 --- a/src/components/Global/SupportDrawer/index.tsx +++ b/src/components/Global/SupportDrawer/index.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { useModalsContext } from '@/context/ModalsContext' import { useCrispUserData } from '@/hooks/useCrispUserData' +import { useCrispTokenId } from '@/hooks/useCrispTokenId' import { useCrispProxyUrl } from '@/hooks/useCrispProxyUrl' import { Drawer, DrawerContent, DrawerTitle } from '../Drawer' import PeanutLoading from '../PeanutLoading' @@ -10,9 +11,10 @@ import PeanutLoading from '../PeanutLoading' const SupportDrawer = () => { const { isSupportModalOpen, setIsSupportModalOpen, supportPrefilledMessage: prefilledMessage } = useModalsContext() const userData = useCrispUserData() + const crispTokenId = useCrispTokenId() const [isLoading, setIsLoading] = useState(true) - const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage) + const crispProxyUrl = useCrispProxyUrl(userData, prefilledMessage, crispTokenId) useEffect(() => { // Listen for ready message from proxy iframe diff --git a/src/hooks/useCrispProxyUrl.ts b/src/hooks/useCrispProxyUrl.ts index 29b9b739fc..971fd34f7e 100644 --- a/src/hooks/useCrispProxyUrl.ts +++ b/src/hooks/useCrispProxyUrl.ts @@ -10,12 +10,17 @@ import { type CrispUserData } from '@/hooks/useCrispUserData' * * @param userData - User data to encode in URL * @param prefilledMessage - Optional message to prefill in chat + * @param crispTokenId - Stable token for Crisp session continuity (prevents duplicate conversations) * @returns URL path to crisp-proxy page with encoded parameters */ -export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: string): string { +export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: string, crispTokenId?: string): string { return useMemo(() => { const params = new URLSearchParams() + if (crispTokenId) { + params.append('crisp_token_id', crispTokenId) + } + if (userData.email) { params.append('user_email', userData.email) } @@ -55,6 +60,7 @@ export function useCrispProxyUrl(userData: CrispUserData, prefilledMessage?: str const queryString = params.toString() return queryString ? `/crisp-proxy?${queryString}` : '/crisp-proxy' }, [ + crispTokenId, userData.email, userData.fullName, userData.username, diff --git a/src/hooks/useCrispTokenId.ts b/src/hooks/useCrispTokenId.ts new file mode 100644 index 0000000000..d30506b72a --- /dev/null +++ b/src/hooks/useCrispTokenId.ts @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react' +import { useAuth } from '@/context/authContext' + +/** + * Salt for Crisp token generation. + * This prevents raw userId from being used as the Crisp token, + * adding a layer of separation between internal IDs and external services. + */ +const CRISP_TOKEN_SALT = 'peanut-crisp-session-v1' + +/** + * Generates a deterministic Crisp session token from a userId using SHA-256. + * + * This ensures the same user always gets the same Crisp conversation, + * preventing duplicate sessions when cookies are cleared or devices change. + * + * @see https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/ + */ +async function generateCrispToken(userId: string): Promise { + const data = new TextEncoder().encode(`${CRISP_TOKEN_SALT}:${userId}`) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') + + // Format as UUID v4-like string for Crisp compatibility + // (Crisp docs recommend UUID format for tokens) + return [hashHex.slice(0, 8), hashHex.slice(8, 12), hashHex.slice(12, 16), hashHex.slice(16, 20), hashHex.slice(20, 32)].join('-') +} + +/** + * Hook that returns a stable Crisp token ID derived from the current user's ID. + * Returns undefined when not authenticated or still computing. + */ +export function useCrispTokenId(): string | undefined { + const { userId } = useAuth() + const [tokenId, setTokenId] = useState(undefined) + + useEffect(() => { + if (!userId) { + setTokenId(undefined) + return + } + + generateCrispToken(userId).then(setTokenId).catch(() => setTokenId(undefined)) + }, [userId]) + + return tokenId +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 790eb5e5b0..9b6bf210b6 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,4 +1,6 @@ interface Window { gtag?: (command: string, ...args: unknown[]) => void $crisp?: Array + CRISP_TOKEN_ID?: string | null + CRISP_WEBSITE_ID?: string } diff --git a/src/utils/crisp.ts b/src/utils/crisp.ts index 289fd3ba25..41f1a57e8e 100644 --- a/src/utils/crisp.ts +++ b/src/utils/crisp.ts @@ -61,6 +61,11 @@ export function resetCrispSession(crispInstance: any): void { if (!crispInstance || typeof window === 'undefined') return try { + // Clear CRISP_TOKEN_ID before resetting session to fully unbind the user. + // This prevents the next anonymous session from inheriting the previous user's conversation. + // @see https://docs.crisp.chat/guides/chatbox-sdks/web-sdk/session-continuity/ + ;(window as any).CRISP_TOKEN_ID = null + crispInstance.push(['do', 'session:reset']) } catch (e) { console.debug('[Crisp] Could not reset session:', e) From 57af5ee5b8bfaba26f1819f953cbf537f68b022f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 12 Mar 2026 11:07:21 +0000 Subject: [PATCH 02/22] fix: set CRISP_TOKEN_ID in inline script to prevent race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move token setting from useEffect into inline