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..2556130d0e 100644
--- a/forge/routes/ui/index.js
+++ b/forge/routes/ui/index.js
@@ -41,7 +41,8 @@ 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
@@ -55,16 +56,34 @@ module.exports = async function (app) {
if (telemetry.frontend.google?.tag) {
const tag = telemetry.frontend.google.tag
- 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()
+ })
+ })
+})