From 465bdbd12b8f608753907fc960dd24ed4b9f0abe Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 23 Aug 2026 04:16:12 -0700 Subject: [PATCH 01/36] fix(settings): keep billing header stable (#7010) --- .claude/rules/sim-settings-pages.md | 8 +- .../settings/[section]/settings.tsx | 1 - .../components/billing/billing.test.tsx | 124 +++++++++++++----- .../settings/components/billing/billing.tsx | 31 +++-- .../settings/settings-header-shell.test.tsx | 10 +- .../components/settings/settings-panel.tsx | 19 ++- 6 files changed, 137 insertions(+), 56 deletions(-) diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 2cff0545957..b65deabafe3 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -13,8 +13,9 @@ The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via `SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned action chips), a scroll region, and a centered `max-w-[48rem]` content column led by a **title + description from navigation metadata**. The chrome stays mounted -across section navigation (it never re-renders or re-lays-out). Each section -renders through the **`SettingsPanel`** registrar +across section navigation. Its routed title and description are available before +the section body resolves. Each section renders through the **`SettingsPanel`** +registrar (`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds the shell its header data and renders only the section body. Sections supply **data**, never chrome. @@ -82,6 +83,9 @@ return ( `children` instead and omit the prop. - `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a detail sub-view that needs a different heading; normal pages never pass these. + A top-level page's header identity must remain stable while its data loads: + never replace navigation metadata with client-fetched copy after first paint. + Put data-dependent context in the page body instead. - `scrollContainerRef?: React.Ref` — forwards a ref to the scroll region (e.g. programmatic scroll-to-bottom). diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 2eebb5a5b80..edb01ca9214 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -177,7 +177,6 @@ export function SettingsPage({ section }: SettingsPageProps) { )} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx index a270afc4070..4b75e1440d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.test.tsx @@ -174,6 +174,14 @@ vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () = ), })) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ + SettingsEmptyState: ({ children, tone }: { children: ReactNode; tone?: 'muted' | 'error' }) => ( +
+ {children} +
+ ), +})) + vi.mock( '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section', () => ({ @@ -269,13 +277,7 @@ describe('Billing payer scope', () => { it('uses the target organization DTO for annual, canceled, credit, cap, and link state', async () => { await act(async () => { - root.render( - - ) + root.render() }) expect(mockUseSubscriptionData).toHaveBeenCalledWith( @@ -290,9 +292,7 @@ describe('Billing payer scope', () => { container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') expect(container.textContent).toContain('Organization Max for Teams plan') - expect(container.textContent).toContain( - 'Target organization’s subscription governs Production.' - ) + expect(container.querySelector('main > p')).toBeNull() expect(container.textContent).toContain('billed annually') expect(container.textContent).toContain('Access until') expect(container.textContent).toContain('Subscription canceled') @@ -316,19 +316,45 @@ describe('Billing payer scope', () => { it('uses a guaranteed personal payer workspace for account upgrades', async () => { await act(async () => { - root.render() + root.render() }) expect( container.querySelector('a[href="/workspace/personal-workspace/upgrade"]')?.textContent ).toBe('Explore personal plans') expect(container.textContent).toContain('Personal Pro plan') - expect(container.textContent).toContain( - 'Your personal subscription governs Personal workspace.' - ) }) - it('does not show a governing subscription description for a free personal workspace', async () => { + it('does not override the route-owned header while billing transitions from loading to success', async () => { + mockPersonalQuery.current = { + data: undefined, + error: null, + isLoading: true, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + expect(container.innerHTML).toBe('') + + mockPersonalQuery.current = { + data: { success: true, context: 'user', data: PERSONAL_DATA }, + error: null, + isLoading: false, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + expect(container.textContent).toContain('Personal Pro plan') + expect(container.querySelector('main > p')).toBeNull() + }) + + it('does not add a dynamic header description for a free personal workspace', async () => { mockPersonalQuery.current = { data: { success: true, @@ -340,7 +366,7 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render() + root.render() }) expect(container.textContent).toContain('Personal Free plan') @@ -368,13 +394,7 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render( - - ) + root.render() }) expect(container.textContent).toContain('Organization Free plan') @@ -398,13 +418,7 @@ describe('Billing payer scope', () => { } await act(async () => { - root.render( - - ) + root.render() }) expect(container.textContent).toContain('Organization Max for Teams plan ended') @@ -415,4 +429,54 @@ describe('Billing payer scope', () => { container.querySelector('a[href="/workspace/organization-workspace/upgrade"]')?.textContent ).toBe('Explore organization plans') }) + + it('renders the canonical error state when the active billing query fails', async () => { + mockPersonalQuery.current = { + data: undefined, + error: new Error('Billing temporarily unavailable'), + isLoading: false, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + const errorState = container.querySelector('[data-testid="settings-empty-state"]') + expect(errorState).toHaveAttribute('data-tone', 'error') + expect(errorState?.textContent).toBe('Billing temporarily unavailable') + }) + + it('keeps cached billing content visible when a background refresh fails', async () => { + mockPersonalQuery.current = { + data: { success: true, context: 'user', data: PERSONAL_DATA }, + error: new Error('Background refresh failed'), + isLoading: false, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + expect(container.textContent).toContain('Personal Pro plan') + expect(container.querySelector('[data-testid="settings-empty-state"]')).toBeNull() + }) + + it('renders the canonical fallback error when billing completes without data', async () => { + mockOrganizationQuery.current = { + data: undefined, + error: null, + isLoading: false, + refetch: vi.fn(), + } + + await act(async () => { + root.render() + }) + + const errorState = container.querySelector('[data-testid="settings-empty-state"]') + expect(errorState).toHaveAttribute('data-tone', 'error') + expect(errorState?.textContent).toBe('Failed to load billing information') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx index 31febdeaecc..0cc86a7be47 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/billing/billing.tsx @@ -46,6 +46,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { CreditUsageSection } from '@/app/workspace/[workspaceId]/settings/components/billing/components/credit-usage-section/credit-usage-section' import { UsageLimitField } from '@/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field' import { getSubscriptionPermissions } from '@/app/workspace/[workspaceId]/settings/components/billing/subscription-permissions' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' @@ -103,20 +104,15 @@ interface BillingProps { scope: 'account' | 'organization' organizationId?: string creditUsageHref?: string - governingWorkspaceName?: string } -export function Billing({ - scope, - organizationId, - creditUsageHref, - governingWorkspaceName, -}: BillingProps) { +export function Billing({ scope, organizationId, creditUsageHref }: BillingProps) { const router = useRouter() const isOrganizationScope = scope === 'organization' const { data: subscriptionData, + error: subscriptionError, isLoading: isSubscriptionLoading, refetch: refetchSubscription, } = useSubscriptionData({ @@ -127,6 +123,7 @@ export function Billing({ const { data: organizationBillingData, + error: organizationBillingError, isLoading: isOrgBillingLoading, refetch: refetchOrganizationBilling, } = useOrganizationBilling(billingOrganizationId || '', { enabled: isOrganizationScope }) @@ -157,6 +154,7 @@ export function Billing({ ? (organizationBilling?.subscriptionStatus ?? 'inactive') : (subscriptionData?.data?.status ?? 'inactive') const isLoading = isOrganizationScope ? isOrgBillingLoading : isSubscriptionLoading + const billingError = isOrganizationScope ? organizationBillingError : subscriptionError const subscription = { isFree: isFree(plan), @@ -403,7 +401,15 @@ export function Billing({ } if (isLoading) return null - if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) return null + if (isOrganizationScope ? !organizationBilling : !subscriptionData?.data) { + return ( + + + {getErrorMessage(billingError, 'Failed to load billing information')} + + + ) + } const planName = getDisplayPlanName(subscription.plan) const billingInterval = isOrganizationScope @@ -458,16 +464,9 @@ export function Billing({ const explorePlansLabel = isOrganizationScope ? 'Explore organization plans' : 'Explore personal plans' - const subscriptionOwner = isOrganizationScope - ? `${organizationBilling?.organizationName ?? 'The organization'}’s subscription` - : 'Your personal subscription' - const settingsDescription = - governingWorkspaceName && subscription.isPaid - ? `${subscriptionOwner} governs ${governingWorkspaceName}.` - : undefined return ( - +
diff --git a/apps/sim/components/settings/settings-header-shell.test.tsx b/apps/sim/components/settings/settings-header-shell.test.tsx index 40cd2a33bde..e2d588159ff 100644 --- a/apps/sim/components/settings/settings-header-shell.test.tsx +++ b/apps/sim/components/settings/settings-header-shell.test.tsx @@ -41,7 +41,7 @@ function renderHeader(actions: SettingsAction[]) { root.render( - +
@@ -152,7 +152,11 @@ describe('SettingsHeaderShell static meta', () => { it('yields to a body that registers its own header', () => { renderWithMeta( - +
) @@ -192,7 +196,7 @@ describe('SettingsHeaderShell static meta', () => { it('falls back to the meta title when the body unmounts mid-navigation', () => { renderWithMeta( - +
) diff --git a/apps/sim/components/settings/settings-panel.tsx b/apps/sim/components/settings/settings-panel.tsx index 5a688d142a3..da919ad88be 100644 --- a/apps/sim/components/settings/settings-panel.tsx +++ b/apps/sim/components/settings/settings-panel.tsx @@ -38,17 +38,28 @@ export function SettingsSectionProvider({ ) } -interface SettingsPanelProps { +interface SettingsPanelBaseProps { children?: ReactNode actions?: SettingsAction[] - back?: SettingsBackAction search?: SettingsHeaderSearch - title?: string - description?: string docsLink?: string scrollContainerRef?: Ref } +type SettingsPanelProps = SettingsPanelBaseProps & + ( + | { + back: SettingsBackAction + title?: string + description?: string + } + | { + back?: undefined + title?: never + description?: never + } + ) + export function SettingsPanel({ children, actions, From f3867694b84042e8dfc522dbb3df382dbea2aab8 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sun, 23 Aug 2026 10:31:42 -0700 Subject: [PATCH 02/36] fix(files): allowlist the schemes a markdown link may target (#7012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `normalizeLinkHref` rejected only `file` for a `scheme://` target, so any other scheme was returned unchanged. `scheme://` is well-formed for every scheme, so the check let through spellings that are not navigable targets at all. - Keep a scheme only when it is http(s), ftp(s), mailto, or tel; drop the rest - Leave an existing link alone when a committed target normalizes away, rather than unsetting it — the editor seeds that field with the current href, so committing an untouched one previously removed the link Detection is unchanged for relative, anchor, protocol-relative, and bare-domain targets. A document's stored markdown is untouched: normalization runs on the render and edit paths, never on parse or serialize, so a target that is refused still round-trips verbatim. --- .../rich-markdown-editor/markdown-fidelity.ts | 21 ++++--- .../menus/link-editing.test.ts | 46 ++++++++++++++ .../menus/link-editing.tsx | 12 +++- .../rich-markdown-editor/round-trip.test.ts | 62 +++++++++++++++++++ 4 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts index 4470187fefa..fd46d579527 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity.ts @@ -76,21 +76,24 @@ export function applyFrontmatter(frontmatter: string, body: string): string { return frontmatter + body } -/** A leading `scheme://` URL (network protocol). */ -const SCHEME_URL = /^([a-z][a-z0-9+.-]*):\/\//i /** A leading `scheme:` token (per the URL grammar). */ const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i /** A bare `host:port` (digits after the colon) — looks scheme-like but is really a domain. */ const HOST_PORT = /^[a-z0-9.-]+:\d+(?:[/?#]|$)/i +/** + * The only schemes a document link may target — an allowlist, because `scheme://` is well-formed for + * every scheme: rejecting just the ones known to be dangerous leaves the next one through, and + * `javascript://…` is a valid URL whose `//` run is merely a comment. + */ +const SAFE_SCHEME = /^(?:(?:https?|ftps?):\/\/|(?:mailto|tel):)/i + /** * Normalize a user-entered link target: prefix a bare domain with `https://` so it doesn't resolve * as an in-app relative URL, while leaving already-qualified, relative (`./other.md`, `../doc.md`), and - * protocol-relative URLs intact. Dangerous schemes are rejected outright rather than trusted or mangled: - * any `scheme:` without `//` other than `mailto:`/`tel:` (so `javascript:`, `data:`, `vbscript:`, - * `blob:`, …), and `file://` (local file access). Other network `scheme://` URLs (`http(s)`, `ftp`, …) - * pass through. A bare `host:port` (digits after the colon) is a domain, not a scheme, so it still gets - * the `https://` prefix. + * protocol-relative URLs intact. A scheme is kept only when {@link SAFE_SCHEME} matches; every other + * one is dropped to `''`, which callers render as inert text rather than a link. A bare `host:port` + * (digits after the colon) is a domain, not a scheme, so it still gets the `https://` prefix. */ export function normalizeLinkHref(href: string): string { const trimmed = href.trim() @@ -99,9 +102,7 @@ export function normalizeLinkHref(href: string): string { if (trimmed.startsWith('//')) return `https:${trimmed}` if (trimmed.startsWith('/')) return trimmed if (trimmed.startsWith('./') || trimmed.startsWith('../')) return trimmed - if (/^(?:mailto|tel):/i.test(trimmed)) return trimmed - const schemed = trimmed.match(SCHEME_URL) - if (schemed) return /^file$/i.test(schemed[1]) ? '' : trimmed + if (SAFE_SCHEME.test(trimmed)) return trimmed if (HAS_SCHEME.test(trimmed) && !HOST_PORT.test(trimmed)) return '' return `https://${trimmed}` } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts new file mode 100644 index 00000000000..e66652f8e54 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.test.ts @@ -0,0 +1,46 @@ +import type { ChainedCommands } from '@tiptap/core' +import { describe, expect, it, vi } from 'vitest' +import { applyLink } from './link-editing' + +function chainSpy() { + const calls: string[] = [] + const chain = { + extendMarkRange: vi.fn(() => chain), + setLink: vi.fn(({ href }: { href: string }) => { + calls.push(`setLink:${href}`) + return chain + }), + unsetLink: vi.fn(() => { + calls.push('unsetLink') + return chain + }), + run: vi.fn(() => true), + } + return { chain: chain as unknown as ChainedCommands, calls } +} + +describe('applyLink', () => { + it('sets a link for a target that survives normalization', () => { + const { chain, calls } = chainSpy() + applyLink(chain, ' sim.ai ') + expect(calls).toEqual(['setLink:https://sim.ai']) + }) + + it('removes the link when the field is cleared', () => { + const { chain, calls } = chainSpy() + applyLink(chain, ' ') + expect(calls).toEqual(['unsetLink']) + }) + + /** + * The field is seeded with the raw href, so committing one untouched must not be read as "remove". + * Dropping an unsafe target is a refusal to link, not an instruction to delete what is already there. + */ + it('leaves the existing link untouched when the target normalizes away', () => { + for (const target of ['javascript://%0aalert(1)', 'customproto://host/path']) { + const { chain, calls } = chainSpy() + applyLink(chain, target) + expect(calls).toEqual([]) + } + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx index f294e88a950..8cbd0dc7b6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-editing.tsx @@ -4,11 +4,17 @@ import { normalizeLinkHref } from '../markdown-fidelity' /** * Applies a link to the chain's current selection: normalizes `rawHref`, expands to the full link - * mark, and sets it — or removes the link when the href is empty/unsafe. The caller supplies a chain - * already focused with the target selection (the captured bubble-menu range / the hovered link range). + * mark, and sets it. Clearing the field removes the link; a target that survives normalization + * replaces it. A target that normalizes away is neither set nor removed — the editor seeds this field + * with the raw href, so committing an untouched one would otherwise delete a link the user only + * opened, and dropping an unsafe target is not the same instruction as "remove this link". The + * caller supplies a chain already focused with the target selection (the captured bubble-menu range / + * the hovered link range). */ export function applyLink(chain: ChainedCommands, rawHref: string): void { - const href = normalizeLinkHref(rawHref.trim()) + const trimmed = rawHref.trim() + const href = normalizeLinkHref(trimmed) + if (!href && trimmed) return chain.extendMarkRange('link') if (href) chain.setLink({ href }) else chain.unsetLink() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts index c8745e5e861..961fd1d86df 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts @@ -5,6 +5,7 @@ * be idempotent (a second pass changes nothing) so autosave never churns. Mirrors the exact * pipeline the editor uses: split frontmatter out, serialize the body, re-attach + clean up. */ +import type { JSONContent } from '@tiptap/core' import { Editor } from '@tiptap/core' import { afterEach, describe, expect, it } from 'vitest' import { createMarkdownContentExtensions } from './extensions' @@ -14,6 +15,7 @@ import { postProcessSerializedMarkdown, splitFrontmatter, } from './markdown-fidelity' +import { parseMarkdownToDoc } from './markdown-parse' let editor: Editor | null = null @@ -126,6 +128,66 @@ describe('markdown-fidelity utils', () => { expect(normalizeLinkHref('blob:https://x.com/uuid')).toBe('') expect(normalizeLinkHref('vbscript:msgbox(1)')).toBe('') expect(normalizeLinkHref('localhost:3000/path')).toBe('https://localhost:3000/path') + // Adding `//` doesn't make a scheme safe, and an unknown scheme is dropped rather than trusted — + // the allowlist is the whole rule. + expect(normalizeLinkHref('javascript://%0aalert(1)')).toBe('') + expect(normalizeLinkHref('customproto://host/path')).toBe('') + }) + + /** + * The property that matters, stated over the spellings a browser collapses before it resolves a + * scheme: whatever comes back must not be executable. Padding and interior tabs/newlines are the + * usual way a blocked scheme is smuggled past a matcher that only reads the literal text. + */ + it('never returns a target that resolves to an executable scheme', () => { + const tab = String.fromCharCode(9) + const lf = String.fromCharCode(10) + const nbsp = String.fromCharCode(160) + const inputs = [ + 'javascript://%0aalert(1)', + 'javascript:alert(1)', + 'JAVASCRIPT://x', + ' javascript:alert(1) ', + `${nbsp}javascript:alert(1)`, + `java${tab}script://alert(1)`, + `java${lf}script:alert(1)`, + 'data://text/html, - - - - - - )} + {isHosted && } ) } diff --git a/apps/sim/app/(landing)/x-page-view-tracker.test.tsx b/apps/sim/app/(landing)/x-page-view-tracker.test.tsx new file mode 100644 index 00000000000..6d1d5f94860 --- /dev/null +++ b/apps/sim/app/(landing)/x-page-view-tracker.test.tsx @@ -0,0 +1,46 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { navigation, mockTwq } = vi.hoisted(() => ({ + navigation: { pathname: '/pricing' }, + mockTwq: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname })) + +import { XPageViewTracker } from '@/app/(landing)/x-page-view-tracker' + +let root: Root | null = null + +function render(): void { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + if (!root) root = createRoot(document.createElement('div')) + act(() => root?.render()) +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + navigation.pathname = '/pricing' + window.twq = undefined + vi.clearAllMocks() +}) + +describe('XPageViewTracker', () => { + it('skips the pixel automatic first view and tracks later path changes once', () => { + window.twq = mockTwq + render() + expect(mockTwq).not.toHaveBeenCalled() + + navigation.pathname = '/demo' + render() + render() + + expect(mockTwq).toHaveBeenCalledOnce() + expect(mockTwq).toHaveBeenCalledWith('config', 'q5xbl') + }) +}) diff --git a/apps/sim/app/(landing)/x-page-view-tracker.tsx b/apps/sim/app/(landing)/x-page-view-tracker.tsx index 5db1e0439fd..f216359eb47 100644 --- a/apps/sim/app/(landing)/x-page-view-tracker.tsx +++ b/apps/sim/app/(landing)/x-page-view-tracker.tsx @@ -1,38 +1,22 @@ 'use client' import { useEffect, useRef } from 'react' -import { usePathname, useSearchParams } from 'next/navigation' +import { usePathname } from 'next/navigation' -declare global { - interface Window { - twq?: (...args: unknown[]) => void - } -} - -// next/script dedupes by id and never reloads on remount, so this must be -// module-scope (not a ref) to survive LandingLayout unmounting/remounting. let hasTrackedInitialPageView = false /** - * The X pixel base code only auto-tracks the first page load; LandingLayout - * persists across client-side navigations, so the pixel never sees the rest. - * Re-fires the pixel's PageView via `twq('config', ...)` on every navigation - * after the first. + * The consent-gated X pixel tracks its first page when it loads. Re-fires the + * PageView for later client navigations. */ export function XPageViewTracker() { const pathname = usePathname() - const searchParams = useSearchParams() - const query = searchParams.toString() - // Instance-scoped (not module-scoped) so a Strict Mode replay of this - // mount's effect is skipped, while a fresh mount — returning to the landing - // layout from the app — starts empty and tracks the view again. - const lastTrackedUrlRef = useRef(null) + const lastTrackedPathRef = useRef(null) useEffect(() => { - const url = query ? `${pathname}?${query}` : pathname - if (lastTrackedUrlRef.current === url) return - lastTrackedUrlRef.current = url + if (lastTrackedPathRef.current === pathname) return + lastTrackedPathRef.current = pathname if (!hasTrackedInitialPageView) { hasTrackedInitialPageView = true @@ -40,7 +24,7 @@ export function XPageViewTracker() { } window.twq?.('config', 'q5xbl') - }, [pathname, query]) + }, [pathname]) return null } diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx index 3fbeab7f772..bd9d4bfdb7b 100644 --- a/apps/sim/app/_shell/consent/consent-banner.tsx +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -1,11 +1,9 @@ 'use client' -import { useEffect } from 'react' import { useHeadlessConsentUI } from '@c15t/nextjs/headless' import { Chip } from '@sim/emcn' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import Link from 'next/link' -import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' import { CONSENT_LINK_CLASS, ConsentPreferences } from '@/app/_shell/consent/consent-preferences' /** Shared expo-out easing and timings, matching the toast stack's motion. */ @@ -30,19 +28,13 @@ const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const * It follows the visitor's theme. Every surface it can appear on either pins * the light layer on `` through `ThemeProvider`'s forced theme, or is a * themed app page where inheriting is what should happen — the card no longer - * decides for itself. Inside the workspace it never renders at all; consent is - * managed from Settings → Privacy there. + * decides for itself. */ export function ConsentBanner() { const { banner, dialog, openDialog, performAction, saveCustomPreferences } = useHeadlessConsentUI() const prefersReducedMotion = useReducedMotion() - useEffect(() => { - window.addEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog) - return () => window.removeEventListener(OPEN_CONSENT_PREFERENCES_EVENT, openDialog) - }, [openDialog]) - const isExpanded = dialog.isVisible const surfaceName = isExpanded ? 'dialog' : 'banner' const { allowedActions } = isExpanded ? dialog : banner diff --git a/apps/sim/app/_shell/consent/consent-preferences-trigger.test.tsx b/apps/sim/app/_shell/consent/consent-preferences-trigger.test.tsx new file mode 100644 index 00000000000..d72e22be78c --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-preferences-trigger.test.tsx @@ -0,0 +1,45 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockOpenDialog } = vi.hoisted(() => ({ mockOpenDialog: vi.fn() })) + +vi.mock('@c15t/nextjs/headless', () => ({ + useHeadlessConsentUI: () => ({ openDialog: mockOpenDialog }), +})) +vi.mock('@sim/emcn', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})) + +import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger' + +let root: Root | null = null + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +describe('ConsentPreferencesTrigger', () => { + it('opens c15t preferences from an accessible button', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + act(() => root?.render(Cookie settings)) + const button = container.querySelector('button') + act(() => button?.click()) + + expect(button?.type).toBe('button') + expect(button?.textContent).toBe('Cookie settings') + expect(mockOpenDialog).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/_shell/consent/consent-preferences-trigger.tsx b/apps/sim/app/_shell/consent/consent-preferences-trigger.tsx new file mode 100644 index 00000000000..b7cfdba6e9c --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-preferences-trigger.tsx @@ -0,0 +1,25 @@ +'use client' + +import type { ReactNode } from 'react' +import { useHeadlessConsentUI } from '@c15t/nextjs/headless' +import { Button, cn } from '@sim/emcn' + +interface ConsentPreferencesTriggerProps { + children: ReactNode + className?: string +} + +export function ConsentPreferencesTrigger({ children, className }: ConsentPreferencesTriggerProps) { + const { openDialog } = useHeadlessConsentUI() + + return ( + + ) +} diff --git a/apps/sim/app/_shell/consent/consent-provider.test.tsx b/apps/sim/app/_shell/consent/consent-provider.test.tsx index b4ad6f0f87b..60045a81071 100644 --- a/apps/sim/app/_shell/consent/consent-provider.test.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.test.tsx @@ -1,42 +1,42 @@ /** * @vitest-environment jsdom */ +import type { ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' -const { mockPathname, mockDynamicImport } = vi.hoisted(() => ({ - mockPathname: vi.fn(), - mockDynamicImport: vi.fn(), +vi.mock('@/app/_shell/consent/consent-store-provider', () => ({ + ConsentStoreProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), })) - -vi.mock('next/navigation', () => ({ usePathname: mockPathname })) - -/** - * Stands in for the lazily-loaded runtime and records whether the chunk was - * asked for at all — that, not just the absence of a banner, is what the - * workspace gate is for. - */ -vi.mock('next/dynamic', () => ({ - default: (loader: () => Promise) => { - return function LazyRuntime() { - mockDynamicImport(loader) - return - } - }, +vi.mock('@/lib/consent/tracking-consent', () => ({ + TrackingConsentProvider: ({ children }: { children: ReactNode }) => children, +})) +vi.mock('@/app/_shell/consent/consent-banner', () => ({ + ConsentBanner: () => , +})) +vi.mock('@/app/_shell/consent/google-analytics-page-view-tracker', () => ({ + GoogleAnalyticsPageViewTracker: () => , })) import { ConsentProvider } from '@/app/_shell/consent/consent-provider' let root: Root | null = null -function renderAt(pathname: string): HTMLDivElement { - mockPathname.mockReturnValue(pathname) +function render(): HTMLDivElement { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - act(() => root?.render()) + act(() => + root?.render( + + + + ) + ) return container } @@ -47,23 +47,12 @@ afterEach(() => { }) describe('ConsentProvider', () => { - it.each(['/', '/pricing', '/login', '/cookie-policy', '/upgrade', '/workspaces'])( - 'mounts the consent runtime on %s', - (pathname) => { - const container = renderAt(pathname) - - expect(container.querySelector('[data-testid="runtime"]')).not.toBeNull() - expect(mockDynamicImport).toHaveBeenCalled() - } - ) - - it.each(['/workspace', '/workspace/abc', '/workspace/abc/logs'])( - 'mounts nothing on %s', - (pathname) => { - const container = renderAt(pathname) - - expect(container.querySelector('[data-testid="runtime"]')).toBeNull() - expect(mockDynamicImport).not.toHaveBeenCalled() - } - ) + it('wraps the application and presents the policy-controlled consent surface', () => { + const container = render() + + expect(container.querySelector('[data-testid="store"]')).not.toBeNull() + expect(container.querySelector('[data-testid="application"]')).not.toBeNull() + expect(container.querySelector('[data-testid="analytics"]')).not.toBeNull() + expect(container.querySelector('[data-testid="banner"]')).not.toBeNull() + }) }) diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index 97e544005c1..a30745054b2 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -1,43 +1,29 @@ 'use client' -import dynamic from 'next/dynamic' -import { usePathname } from 'next/navigation' +import type { ReactNode } from 'react' +import { TrackingConsentProvider } from '@/lib/consent/tracking-consent' +import { ConsentBanner } from '@/app/_shell/consent/consent-banner' +import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' +import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker' -/** - * The cookie-consent runtime, loaded on the client only and only once this - * component renders it — the root layout renders it behind `isHosted`, so a - * self-hosted deployment never fetches the chunk, never reaches Sim's consent - * backend, and never sees the banner. Deferring it also keeps the third-party - * store out of the server render and off the landing page's hydration path; the - * banner cannot paint before its geo lookup resolves anyway. - */ -const ConsentRuntime = dynamic( - () => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime), - { ssr: false } -) - -const WORKSPACE_SEGMENT = 'workspace' +interface ConsentProviderProps { + children: ReactNode +} /** - * Mounts the consent runtime everywhere except the workspace. - * - * Inside the product a floating consent card is the wrong surface — a signed-in - * user manages this from Settings → Privacy, which mounts the same store. The - * check sits above the `dynamic()` rather than inside the loaded module so the - * workspace pays neither the chunk nor the consent init request: gating within - * the module would still have downloaded it, on the surface with the most hard - * loads. - * - * The gap this leaves — a visitor who reaches the workspace with no consent - * record is not prompted — closes when the analytics scripts move behind - * consent, since nothing non-essential loads without a record at all. + * Owns hosted Sim's consent lifecycle across every route. The banner stays off + * until the resolved jurisdiction policy requires it and then appears on every + * entry route, including a direct workspace visit. Privacy settings remain the + * durable control after the initial decision. */ -export function ConsentProvider() { - const pathname = usePathname() - - if (pathname.split('/')[1] === WORKSPACE_SEGMENT) { - return null - } - - return +export function ConsentProvider({ children }: ConsentProviderProps) { + return ( + + + {children} + + + + + ) } diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx deleted file mode 100644 index 243c1ed6b66..00000000000 --- a/apps/sim/app/_shell/consent/consent-runtime.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client' - -import { ConsentBanner } from '@/app/_shell/consent/consent-banner' -import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' - -/** - * The consent banner and the store it reads. Loaded lazily and client-only by - * {@link ConsentProvider}, which also decides where it may mount. - */ -export function ConsentRuntime() { - return ( - - - - ) -} diff --git a/apps/sim/app/_shell/consent/consent-store-provider.test.tsx b/apps/sim/app/_shell/consent/consent-store-provider.test.tsx index 69837317939..1c80f3b63d5 100644 --- a/apps/sim/app/_shell/consent/consent-store-provider.test.tsx +++ b/apps/sim/app/_shell/consent/consent-store-provider.test.tsx @@ -47,7 +47,14 @@ describe('ConsentStoreProvider', () => { mode: 'hosted', backendURL: 'https://sim-sim.inth.app', consentCategories: ['necessary', 'measurement', 'marketing'], - store: { iframeBlockerConfig: { disableAutomaticBlocking: true } }, + scripts: [ + expect.objectContaining({ id: 'gtag', category: 'measurement', alwaysLoad: true }), + expect.objectContaining({ id: 'ahrefs-analytics', category: 'measurement' }), + ], + store: { + reloadOnConsentRevoked: true, + iframeBlockerConfig: { disableAutomaticBlocking: true }, + }, }) }) }) diff --git a/apps/sim/app/_shell/consent/consent-store-provider.tsx b/apps/sim/app/_shell/consent/consent-store-provider.tsx index 09791fd56f4..79403e3022e 100644 --- a/apps/sim/app/_shell/consent/consent-store-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-store-provider.tsx @@ -7,6 +7,7 @@ import { CONSENT_CATEGORIES, DEV_CONSENT_COUNTRY, } from '@/lib/consent/constants' +import { GLOBAL_CONSENT_SCRIPTS } from '@/lib/consent/scripts' /** * Imported from `@c15t/nextjs/headless`, not the package root: the headless @@ -26,19 +27,18 @@ const CONSENT_OPTIONS = { mode: 'hosted', backendURL: CONSENT_BACKEND_URL, consentCategories: [...CONSENT_CATEGORIES], - store: { iframeBlockerConfig: { disableAutomaticBlocking: true } }, + scripts: [...GLOBAL_CONSENT_SCRIPTS], + store: { + reloadOnConsentRevoked: true, + iframeBlockerConfig: { disableAutomaticBlocking: true }, + }, ...(DEV_CONSENT_COUNTRY ? { overrides: { country: DEV_CONSENT_COUNTRY } } : {}), } satisfies ConsentManagerOptions /** - * The consent store, for the two surfaces that read it: the banner on public - * pages and the Privacy settings page inside the workspace. - * - * They mount separately — the banner sits behind an `ssr: false` boundary that - * cannot wrap the app, so nothing reaches it through React context — yet share - * one store, because `getOrCreateConsentRuntime` caches manager and store by - * the option values. Keeping the options private to this component is what - * makes that structural: two call sites cannot drift into two stores. + * The single consent store for hosted Sim. It wraps the entire application so + * script loading, the public banner, and workspace privacy settings cannot + * observe different consent state. */ export function ConsentStoreProvider({ children }: { children: ReactNode }) { return {children} diff --git a/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.test.tsx b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.test.tsx new file mode 100644 index 00000000000..a16cd8e717d --- /dev/null +++ b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.test.tsx @@ -0,0 +1,64 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { consent, navigation, mockTrackGooglePageView } = vi.hoisted(() => ({ + consent: { hasFetchedBanner: false, measurement: false, gtagLoaded: false }, + navigation: { pathname: '/pricing' }, + mockTrackGooglePageView: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname })) +vi.mock('@c15t/nextjs/headless', () => ({ + useConsentManager: () => ({ + has: (category: string) => category === 'measurement' && consent.measurement, + hasFetchedBanner: consent.hasFetchedBanner, + loadedScripts: { gtag: consent.gtagLoaded }, + }), +})) +vi.mock('@/lib/analytics/google', () => ({ + trackGooglePageView: mockTrackGooglePageView, +})) + +import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker' + +let root: Root | null = null + +function render(): void { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + if (!root) root = createRoot(document.createElement('div')) + act(() => root?.render()) +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + consent.hasFetchedBanner = false + consent.measurement = false + consent.gtagLoaded = false + navigation.pathname = '/pricing' + vi.clearAllMocks() +}) + +describe('GoogleAnalyticsPageViewTracker', () => { + it('tracks only later path changes after consent and the automatic first view', () => { + render() + expect(mockTrackGooglePageView).not.toHaveBeenCalled() + + consent.hasFetchedBanner = true + consent.measurement = true + consent.gtagLoaded = true + render() + expect(mockTrackGooglePageView).not.toHaveBeenCalled() + + navigation.pathname = '/demo' + render() + render() + + expect(mockTrackGooglePageView).toHaveBeenCalledOnce() + expect(mockTrackGooglePageView).toHaveBeenCalledWith('/demo') + }) +}) diff --git a/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.tsx b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.tsx new file mode 100644 index 00000000000..6e39fe972a5 --- /dev/null +++ b/apps/sim/app/_shell/consent/google-analytics-page-view-tracker.tsx @@ -0,0 +1,28 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useConsentManager } from '@c15t/nextjs/headless' +import { usePathname } from 'next/navigation' +import { trackGooglePageView } from '@/lib/analytics/google' + +/** Tracks Next.js client navigations after c15t has loaded the consent-aware tag. */ +export function GoogleAnalyticsPageViewTracker() { + const pathname = usePathname() + const { has, hasFetchedBanner, loadedScripts } = useConsentManager() + const lastTrackedPathRef = useRef(null) + + useEffect(() => { + if (!hasFetchedBanner || !has('measurement') || !loadedScripts.gtag) return + + if (lastTrackedPathRef.current === null) { + lastTrackedPathRef.current = pathname + return + } + if (lastTrackedPathRef.current === pathname) return + + lastTrackedPathRef.current = pathname + trackGooglePageView(pathname) + }, [has, hasFetchedBanner, loadedScripts.gtag, pathname]) + + return null +} diff --git a/apps/sim/app/_shell/providers/posthog-provider.test.tsx b/apps/sim/app/_shell/providers/posthog-provider.test.tsx new file mode 100644 index 00000000000..229fb5b1c7b --- /dev/null +++ b/apps/sim/app/_shell/providers/posthog-provider.test.tsx @@ -0,0 +1,141 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { consent, mockCapture, mockInit, mockOptIn, mockOptOut, mockPostHog, mockSetPostHogClient } = + vi.hoisted(() => { + const posthog = { + __loaded: false, + capture: vi.fn(), + init: vi.fn(), + opt_in_capturing: vi.fn(), + opt_out_capturing: vi.fn(), + } + posthog.init.mockImplementation(() => { + posthog.__loaded = true + }) + return { + consent: { isResolved: false, measurement: false, marketing: false }, + mockCapture: posthog.capture, + mockInit: posthog.init, + mockOptIn: posthog.opt_in_capturing, + mockOptOut: posthog.opt_out_capturing, + mockPostHog: posthog, + mockSetPostHogClient: vi.fn(), + } + }) + +vi.mock('@/lib/consent/tracking-consent', () => ({ useTrackingConsent: () => consent })) +vi.mock('@/lib/core/config/env', () => ({ + getEnv: (name: string) => + name === 'NEXT_PUBLIC_POSTHOG_ENABLED' ? 'true' : 'phc_test_project_key', + isTruthy: (value: string) => value === 'true', + publicEnvMissingAtModuleInit: false, +})) +vi.mock('@/lib/posthog/client', () => ({ setPostHogClient: mockSetPostHogClient })) +vi.mock('@/lib/posthog/exception-filter', () => ({ preparePostHogEvent: vi.fn() })) +vi.mock('posthog-js', () => ({ + default: mockPostHog, +})) +vi.mock('posthog-js/react', () => ({ + PostHogProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function render(): HTMLDivElement { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container ??= document.createElement('div') + document.body.appendChild(container) + root ??= createRoot(container) + act(() => + root?.render( + + + + ) + ) + return container +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + container = null + consent.isResolved = false + consent.measurement = false + mockPostHog.__loaded = false + localStorage.clear() + sessionStorage.clear() + vi.clearAllMocks() +}) + +describe('PostHogProvider consent gating', () => { + it('initializes and publishes PostHog only while measurement consent is granted', async () => { + localStorage.setItem('ph_phc_test_project_key_posthog', 'identity') + localStorage.setItem('ph_other_project_posthog', 'other-identity') + localStorage.setItem('application_preference', 'keep') + const container = render() + const application = container.querySelector('[data-testid="application"]') + + expect(mockInit).not.toHaveBeenCalled() + expect(localStorage.getItem('ph_phc_test_project_key_posthog')).toBe('identity') + expect(application).not.toBeNull() + expect(container.querySelector('[data-testid="posthog-provider"]')).not.toBeNull() + + consent.isResolved = true + consent.measurement = true + render() + + await vi.waitFor(() => expect(mockInit).toHaveBeenCalledTimes(1)) + expect(mockInit).toHaveBeenCalledWith( + 'phc_test_project_key', + expect.objectContaining({ + opt_out_capturing_by_default: true, + opt_out_persistence_by_default: true, + }) + ) + expect(mockOptIn).toHaveBeenCalledWith({ captureEventName: false }) + expect(mockSetPostHogClient).toHaveBeenLastCalledWith( + expect.objectContaining({ capture: mockCapture }) + ) + expect(container.querySelector('[data-testid="posthog-provider"]')).not.toBeNull() + expect(container.querySelector('[data-testid="application"]')).toBe(application) + + consent.measurement = false + render() + + expect(mockOptOut).toHaveBeenCalledTimes(1) + expect(mockSetPostHogClient).toHaveBeenLastCalledWith(null) + expect(container.querySelector('[data-testid="posthog-provider"]')).not.toBeNull() + expect(container.querySelector('[data-testid="application"]')).toBe(application) + expect(localStorage.getItem('ph_phc_test_project_key_posthog')).toBeNull() + expect(localStorage.getItem('ph_other_project_posthog')).toBe('other-identity') + expect(localStorage.getItem('application_preference')).toBe('keep') + }) + + it('clears only this project persistence after an initial denial', () => { + localStorage.setItem('ph_phc_test_project_key_posthog', 'identity') + localStorage.setItem('__ph_opt_in_out_phc_test_project_key', '1') + sessionStorage.setItem('ph_phc_test_project_key_window_id', 'window-id') + localStorage.setItem('ph_other_project_posthog', 'other-identity') + + render() + consent.isResolved = true + render() + + expect(mockInit).not.toHaveBeenCalled() + expect(localStorage.getItem('ph_phc_test_project_key_posthog')).toBeNull() + expect(localStorage.getItem('__ph_opt_in_out_phc_test_project_key')).toBeNull() + expect(sessionStorage.getItem('ph_phc_test_project_key_window_id')).toBeNull() + expect(localStorage.getItem('ph_other_project_posthog')).toBe('other-identity') + }) +}) diff --git a/apps/sim/app/_shell/providers/posthog-provider.tsx b/apps/sim/app/_shell/providers/posthog-provider.tsx index 095646f29de..f6a42361367 100644 --- a/apps/sim/app/_shell/providers/posthog-provider.tsx +++ b/apps/sim/app/_shell/providers/posthog-provider.tsx @@ -1,127 +1,173 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useEffect } from 'react' import { createLogger } from '@sim/logger' -import type { PostHog } from 'posthog-js' +import posthog from 'posthog-js' +import { PostHogProvider as PHProvider } from 'posthog-js/react' +import { useTrackingConsent } from '@/lib/consent/tracking-consent' import { getEnv, isTruthy, publicEnvMissingAtModuleInit } from '@/lib/core/config/env' -import { settlePostHogClient } from '@/lib/posthog/client' -import { dropUnactionableExceptions } from '@/lib/posthog/exception-filter' +import { setPostHogClient } from '@/lib/posthog/client' +import { preparePostHogEvent } from '@/lib/posthog/exception-filter' const logger = createLogger('PostHogProvider') -export function PostHogProvider({ children }: { children: React.ReactNode }) { - const [Provider, setProvider] = useState | null>(null) - const clientRef = useRef(null) +/** Removes this PostHog project's browser state after a settled analytics denial. */ +function clearPostHogBrowserState(posthogKey: string): void { + const persistenceKey = `ph_${posthogKey + .replace(/\+/g, 'PL') + .replace(/\//g, 'SL') + .replace(/=/g, 'EQ')}_posthog` + const storageKeys = [ + persistenceKey, + `ph_${posthogKey}_window_id`, + `ph_${posthogKey}_primary_window_exists`, + `__ph_opt_in_out_${posthogKey}`, + ] + + try { + for (const storage of [window.localStorage, window.sessionStorage]) { + for (const key of storageKeys) storage.removeItem(key) + } + } catch {} + + try { + const simDomain = + window.location.hostname === 'sim.ai' || window.location.hostname.endsWith('.sim.ai') + ? '; Domain=.sim.ai' + : '' + + for (const key of storageKeys) { + document.cookie = `${key}=; Max-Age=0; Path=/; SameSite=Lax` + if (simDomain) document.cookie = `${key}=; Max-Age=0; Path=/; SameSite=Lax${simDomain}` + } + } catch {} +} + +interface PostHogProviderProps { + children: React.ReactNode + consentRequired?: boolean +} + +export function PostHogProvider({ children, consentRequired = false }: PostHogProviderProps) { + const { isResolved, measurement } = useTrackingConsent() + const canInitialize = !consentRequired || (isResolved && measurement) useEffect(() => { - const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED') const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY') + if (!canInitialize) { + setPostHogClient(null) + if (posthog.__loaded) posthog.opt_out_capturing() + if (consentRequired && isResolved && !measurement && posthogKey) { + clearPostHogBrowserState(posthogKey) + } + return () => setPostHogClient(null) + } + + const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED') + if (!isTruthy(posthogEnabled) || !posthogKey) { - settlePostHogClient(null) - return + setPostHogClient(null) + if (posthog.__loaded) posthog.opt_out_capturing() + return () => setPostHogClient(null) } - Promise.all([import('posthog-js'), import('posthog-js/react')]) - .then(([posthogModule, { PostHogProvider: PHProvider }]) => { - const posthog = posthogModule.default - if (!posthog.__loaded) { - posthog.init(posthogKey, { - api_host: '/ingest', - ui_host: 'https://us.posthog.com', - defaults: '2025-05-24', - person_profiles: 'identified_only', - autocapture: false, - capture_pageview: false, - capture_pageleave: false, - capture_performance: false, - capture_dead_clicks: false, - enable_heatmaps: false, - /** - * PostHog's own error tracking, wired to `window.onerror` and - * `unhandledrejection`. This is the app-wide net: React error - * boundaries only see errors thrown inside the tree they wrap, and - * a failed chunk load, a rejected promise, or anything thrown from - * an event handler or socket callback reaches none of them. - * - * `capture_console_errors` stays off. It is not error reporting — - * it captures every `console.error`, which here means React's - * hydration and dev warnings (the ones `HydrationErrorHandler` - * already filters out as noise) drowning the real exceptions. - */ - capture_exceptions: { - capture_unhandled_errors: true, - capture_unhandled_rejections: true, - capture_console_errors: false, + try { + if (!posthog.__loaded) { + posthog.init(posthogKey, { + api_host: '/ingest', + ui_host: 'https://us.posthog.com', + defaults: '2025-05-24', + person_profiles: 'identified_only', + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + capture_performance: false, + capture_dead_clicks: false, + enable_heatmaps: false, + /** + * PostHog's own error tracking, wired to `window.onerror` and + * `unhandledrejection`. This is the app-wide net: React error + * boundaries only see errors thrown inside the tree they wrap, and + * a failed chunk load, a rejected promise, or anything thrown from + * an event handler or socket callback reaches none of them. + * + * `capture_console_errors` stays off. It is not error reporting — + * it captures every `console.error`, which here means React's + * hydration and dev warnings (the ones `HydrationErrorHandler` + * already filters out as noise) drowning the real exceptions. + */ + capture_exceptions: { + capture_unhandled_errors: true, + capture_unhandled_rejections: true, + capture_console_errors: false, + }, + /** + * Drops the browser artifacts that autocapture cannot help but + * see — resize-loop notices, opaque cross-origin failures, and + * cancelled requests. Filtering here rather than with a PostHog + * suppression rule keeps the list reviewable in the diff and stops + * the events before they leave the browser. + */ + before_send: preparePostHogEvent, + opt_out_capturing_by_default: true, + opt_out_persistence_by_default: true, + disable_session_recording: true, + session_recording: { + maskAllInputs: false, + maskInputOptions: { + password: true, + email: false, }, /** - * Drops the browser artifacts that autocapture cannot help but - * see — resize-loop notices, opaque cross-origin failures, and - * cancelled requests. Filtering here rather than with a PostHog - * suppression rule keeps the list reviewable in the diff and stops - * the events before they leave the browser. + * None of these nodes are painted, so replay fidelity is + * unchanged, while each full snapshot serializes fewer nodes on + * the main thread and ships a smaller payload. + * + * Enumerated rather than `true`/`'all'` on purpose — those + * presets also enable `headTitleMutations`, which would drop + * `document.title` changes and lose the page identity a replay + * viewer reads while scrubbing. */ - before_send: dropUnactionableExceptions, - disable_session_recording: true, - session_recording: { - maskAllInputs: false, - maskInputOptions: { - password: true, - email: false, - }, - /** - * None of these nodes are painted, so replay fidelity is - * unchanged, while each full snapshot serializes fewer nodes on - * the main thread and ships a smaller payload. - * - * Enumerated rather than `true`/`'all'` on purpose — those - * presets also enable `headTitleMutations`, which would drop - * `document.title` changes and lose the page identity a replay - * viewer reads while scrubbing. - */ - slimDOMOptions: { - script: true, - comment: true, - headFavicon: true, - headWhitespace: true, - headMetaDescKeywords: true, - headMetaSocial: true, - headMetaRobots: true, - headMetaHttpEquiv: true, - headMetaAuthorship: true, - headMetaVerification: true, - }, - recordCrossOriginIframes: false, - recordHeaders: false, - recordBody: false, + slimDOMOptions: { + script: true, + comment: true, + headFavicon: true, + headWhitespace: true, + headMetaDescKeywords: true, + headMetaSocial: true, + headMetaRobots: true, + headMetaHttpEquiv: true, + headMetaAuthorship: true, + headMetaVerification: true, }, - persistence: 'localStorage+cookie', - }) - } - /** - * Releases anything captured while the imports above were in flight. - * Must run after `init`, since `capture` is a silent no-op until then. - */ - settlePostHogClient(posthog) - - if (publicEnvMissingAtModuleInit) { - posthog.capture('runtime_env_missing_at_module_init') - } - clientRef.current = posthog - setProvider(() => PHProvider) - }) - .catch((err) => { - settlePostHogClient(null) - logger.error('Failed to load PostHog', { error: err }) - }) - }, []) - - if (Provider && clientRef.current) { - return {children} - } - - return <>{children} + recordCrossOriginIframes: false, + recordHeaders: false, + recordBody: false, + }, + persistence: 'localStorage+cookie', + }) + } + /** + * A prior withdrawal persists PostHog's opt-out marker. c15t is the + * source of truth, so a settled grant must explicitly clear that marker + * without emitting PostHog's synthetic opt-in event. + */ + posthog.opt_in_capturing({ captureEventName: false }) + setPostHogClient(posthog) + + if (publicEnvMissingAtModuleInit) { + posthog.capture('runtime_env_missing_at_module_init') + } + } catch (err) { + setPostHogClient(null) + logger.error('Failed to load PostHog', { error: err }) + } + + return () => { + setPostHogClient(null) + } + }, [canInitialize, consentRequired, isResolved, measurement]) + + return {children} } diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 6a338b43a47..81aed9feb9b 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -33,11 +33,21 @@ export const viewport: Viewport = { export const metadata: Metadata = generateBrandedMetadata() -const GTM_ID = 'GTM-T7PHSRX5' as const -const GA_ID = 'G-DR7YBE70VS' as const - export default function RootLayout({ children }: { children: React.ReactNode }) { const themeCSS = generateThemeCSS() + const application = ( + + + + + + {children} + + + + + + ) return ( @@ -226,70 +236,13 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {/* Google Tag Manager — hosted only */} - {isHosted && ( -