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 1/6] 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 37e9c8c22..70a7003a2 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 74e2eb5a7..bc4aeb6ec 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 29b9b739f..971fd34f7 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 000000000..d30506b72 --- /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 790eb5e5b..9b6bf210b 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 289fd3ba2..41f1a57e8 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 2/6] 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