From c4ce597d3119ab2378dbabf8e27453968dede8aa Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Fri, 5 Jun 2026 12:43:20 -0700 Subject: [PATCH 1/2] Add login-page cookie consent that gates HubSpot/GA and keeps PostHog flags working cookieless on reject --- .eslintrc | 5 +- forge/routes/ui/index.js | 34 +++++-- frontend/src/App.vue | 5 + frontend/src/components/CookieConsent.vue | 72 ++++++++++++++ frontend/src/stores/account-auth.js | 2 + frontend/src/stores/cookie-consent.ts | 52 ++++++++++ frontend/src/types/window.d.ts | 11 +++ .../frontend/stores/cookie-consent.spec.js | 96 +++++++++++++++++++ 8 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/CookieConsent.vue create mode 100644 frontend/src/stores/cookie-consent.ts create mode 100644 frontend/src/types/window.d.ts create mode 100644 test/unit/frontend/stores/cookie-consent.spec.js diff --git a/.eslintrc b/.eslintrc index c5fc56c99c..2811d61f26 100644 --- a/.eslintrc +++ b/.eslintrc @@ -82,7 +82,10 @@ }, "parserOptions": { "ecmaVersion": 2022, - "sourceType": "module" + "sourceType": "module", + "parser": { + "ts": "@typescript-eslint/parser" + } }, "rules": { // plugin:vue diff --git a/forge/routes/ui/index.js b/forge/routes/ui/index.js index 4b9d4e8d7d..1c83590763 100644 --- a/forge/routes/ui/index.js +++ b/forge/routes/ui/index.js @@ -41,12 +41,12 @@ module.exports = async function (app) { const apihost = telemetry.frontend.posthog.apiurl || 'https://app.posthog.com' const apikey = telemetry.frontend.posthog.apikey const options = { - api_host: apihost + api_host: apihost, + cookieless_mode: 'on_reject' } if ('capture_pageview' in telemetry.frontend.posthog) { options.capture_pageview = telemetry.frontend.posthog.capture_pageview } - // TODO: object to string in the injection script injection += `` - injection += `` + // Deferred until consent is given - the cookie-consent store calls this on accept. + injection += `` } if (support?.enabled && support.frontend?.hubspot?.trackingcode) { const trackingCode = support.frontend.hubspot.trackingcode - injection += ` - - - ` + // Deferred until consent is given - the cookie-consent store calls this on accept. + injection += `` } if (telemetry.frontend?.sentry?.dsn) { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 74fa175a3d..f7bfedcdb6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -58,12 +58,14 @@ + + + diff --git a/frontend/src/stores/account-auth.js b/frontend/src/stores/account-auth.js index cc60a600d7..c0a2d280b8 100644 --- a/frontend/src/stores/account-auth.js +++ b/frontend/src/stores/account-auth.js @@ -8,6 +8,7 @@ import userApi from '../api/user.js' import { useAccountSettingsStore } from '@/stores/account-settings.js' import { useAccountStore } from '@/stores/account.js' import { useContextStore } from '@/stores/context.js' +import { useCookieConsentStore } from '@/stores/cookie-consent' import { useProductAssistantStore } from '@/stores/product-assistant.js' import { useProductBrokersStore } from '@/stores/product-brokers.js' import { useProductExpertInsightsAgentStore } from '@/stores/product-expert-insights-agent.js' @@ -204,6 +205,7 @@ export const useAccountAuthStore = defineStore('account-auth', { useUxDrawersStore().$reset() useUxStore().$reset() useContextStore().$reset() + useCookieConsentStore().reset() useProductTablesStore().$reset() useProductBrokersStore().$reset() useProductAssistantStore().$reset() diff --git a/frontend/src/stores/cookie-consent.ts b/frontend/src/stores/cookie-consent.ts new file mode 100644 index 0000000000..c5834fbf6d --- /dev/null +++ b/frontend/src/stores/cookie-consent.ts @@ -0,0 +1,52 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +type ConsentDecision = 'accepted' | 'rejected' | null + +export const useCookieConsentStore = defineStore('cookie-consent', () => { + const decision = ref(null) + + const analyticsEnabled = computed(() => { + return !!(window.posthog || window._ffLoadHubSpot || window._ffLoadGoogleAnalytics) + }) + const shouldShowBanner = computed(() => decision.value === null && analyticsEnabled.value) + + function applyDecision () { + if (decision.value === 'accepted') { + try { + window.posthog?.opt_in_capturing() + } catch (err) { + console.error('posthog error opting in', err) + } + window._ffLoadHubSpot?.() + window._ffLoadGoogleAnalytics?.() + } else if (decision.value === 'rejected') { + try { + window.posthog?.opt_out_capturing() + } catch (err) { + console.error('posthog error opting out', err) + } + } + } + + function accept () { + decision.value = 'accepted' + applyDecision() + } + + function reject () { + decision.value = 'rejected' + applyDecision() + } + + function reset () { + decision.value = null + } + + return { decision, analyticsEnabled, shouldShowBanner, accept, reject, applyDecision, reset } +}, { + persist: { + pick: ['decision'], + storage: localStorage + } +}) diff --git a/frontend/src/types/window.d.ts b/frontend/src/types/window.d.ts new file mode 100644 index 0000000000..fe6b29aeb5 --- /dev/null +++ b/frontend/src/types/window.d.ts @@ -0,0 +1,11 @@ +export {} + +declare global { + interface Window { + posthog?: any + _hsq?: unknown[] + _ffhstc?: string + _ffLoadHubSpot?: () => void + _ffLoadGoogleAnalytics?: () => void + } +} diff --git a/test/unit/frontend/stores/cookie-consent.spec.js b/test/unit/frontend/stores/cookie-consent.spec.js new file mode 100644 index 0000000000..ab96367268 --- /dev/null +++ b/test/unit/frontend/stores/cookie-consent.spec.js @@ -0,0 +1,96 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCookieConsentStore } from '@/stores/cookie-consent' + +describe('cookie-consent store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + window.posthog = { opt_in_capturing: vi.fn(), opt_out_capturing: vi.fn() } + window._ffLoadHubSpot = vi.fn() + window._ffLoadGoogleAnalytics = vi.fn() + }) + + afterEach(() => { + delete window.posthog + delete window._ffLoadHubSpot + delete window._ffLoadGoogleAnalytics + }) + + describe('shouldShowBanner', () => { + it('is true before a decision when analytics are enabled', () => { + const store = useCookieConsentStore() + expect(store.shouldShowBanner).toBe(true) + }) + + it('is false once a decision has been made', () => { + const store = useCookieConsentStore() + store.decision = 'rejected' + expect(store.shouldShowBanner).toBe(false) + }) + + it('is false when no analytics tools are configured (self-hosted)', () => { + delete window.posthog + delete window._ffLoadHubSpot + delete window._ffLoadGoogleAnalytics + const store = useCookieConsentStore() + expect(store.analyticsEnabled).toBe(false) + expect(store.shouldShowBanner).toBe(false) + }) + }) + + describe('accept', () => { + it('opts into PostHog and loads HubSpot + Google Analytics', () => { + const store = useCookieConsentStore() + store.accept() + expect(store.decision).toBe('accepted') + expect(window.posthog.opt_in_capturing).toHaveBeenCalled() + expect(window._ffLoadHubSpot).toHaveBeenCalled() + expect(window._ffLoadGoogleAnalytics).toHaveBeenCalled() + }) + }) + + describe('reject', () => { + it('opts out of PostHog and does not load HubSpot or Google Analytics', () => { + const store = useCookieConsentStore() + store.reject() + expect(store.decision).toBe('rejected') + expect(window.posthog.opt_out_capturing).toHaveBeenCalled() + expect(window._ffLoadHubSpot).not.toHaveBeenCalled() + expect(window._ffLoadGoogleAnalytics).not.toHaveBeenCalled() + }) + }) + + describe('applyDecision', () => { + it('re-applies an accepted decision', () => { + const store = useCookieConsentStore() + store.decision = 'accepted' + store.applyDecision() + expect(window.posthog.opt_in_capturing).toHaveBeenCalled() + expect(window._ffLoadHubSpot).toHaveBeenCalled() + }) + + it('re-applies a rejected decision', () => { + const store = useCookieConsentStore() + store.decision = 'rejected' + store.applyDecision() + expect(window.posthog.opt_out_capturing).toHaveBeenCalled() + }) + + it('does nothing when no decision has been stored', () => { + const store = useCookieConsentStore() + store.applyDecision() + expect(window.posthog.opt_in_capturing).not.toHaveBeenCalled() + expect(window.posthog.opt_out_capturing).not.toHaveBeenCalled() + }) + }) + + describe('reset', () => { + it('clears the decision so the banner shows again', () => { + const store = useCookieConsentStore() + store.decision = 'accepted' + store.reset() + expect(store.decision).toBeNull() + }) + }) +}) From 5035be5612301e10225030b6cbea51deb0a28801 Mon Sep 17 00:00:00 2001 From: Noley Holland Date: Fri, 5 Jun 2026 12:48:11 -0700 Subject: [PATCH 2/2] Add back in code todo --- forge/routes/ui/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/forge/routes/ui/index.js b/forge/routes/ui/index.js index 1c83590763..2556130d0e 100644 --- a/forge/routes/ui/index.js +++ b/forge/routes/ui/index.js @@ -47,6 +47,7 @@ module.exports = async function (app) { if ('capture_pageview' in telemetry.frontend.posthog) { options.capture_pageview = telemetry.frontend.posthog.capture_pageview } + // TODO: object to string in the injection script injection += `