From 34452060f60962ea0372ee294201131d4ff25a99 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 18 Aug 2026 17:57:24 -0700 Subject: [PATCH 1/2] feat(consent): manage cookies from Settings, never from a card in the workspace The banner no longer mounts inside the workspace at all. The gate sits above the dynamic() boundary rather than inside the lazily-loaded module, so the product pays neither the consent chunk nor its init request on the surface with the most hard loads. A signed-in user manages the same choice from Settings -> Privacy, which shares one store with the banner: the options live in ConsentStoreProvider and are not exported, so two call sites cannot drift into two stores. The banner also stops pinning the light token layer and simply inherits. The cause it was working around is that LandingShell pins light on a wrapper inside the page while keeps the visitor's theme, so landing routes missing from ThemeProvider's hand-written list rendered light pages under dark root chrome. LANDING_ROUTES becomes one source of truth in lib/landing/routes, read by both next.config (COEP) and ThemeProvider (forced light) -- the same drift that let /cookie-policy ship without its COEP exemption. Diffed old against new across every real route: 16 landing routes gain the correct theme and nothing regresses. /cli/auth and /credential-groups/complete are added too; both render AuthShell and were never covered. Verified the shared-store assumption directly rather than trusting the docs: getOrCreateConsentRuntime returns the same store and manager for equal options. --- .../sim/app/_shell/consent/consent-banner.tsx | 97 ++---------- .../_shell/consent/consent-preferences.tsx | 88 +++++++++++ .../_shell/consent/consent-provider.test.tsx | 69 +++++++++ .../app/_shell/consent/consent-provider.tsx | 36 ++++- .../app/_shell/consent/consent-runtime.tsx | 33 +--- ...st.tsx => consent-store-provider.test.tsx} | 32 ++-- .../_shell/consent/consent-store-provider.tsx | 45 ++++++ .../app/_shell/providers/theme-provider.tsx | 62 +++++--- .../settings/[section]/settings.tsx | 9 +- .../components/privacy/privacy.test.tsx | 146 ++++++++++++++++++ .../settings/components/privacy/privacy.tsx | 93 +++++++++++ .../[workspaceId]/settings/navigation.test.ts | 2 + apps/sim/components/settings/navigation.ts | 23 ++- apps/sim/lib/landing/routes.ts | 44 ++++++ apps/sim/next.config.ts | 33 +--- 15 files changed, 622 insertions(+), 190 deletions(-) create mode 100644 apps/sim/app/_shell/consent/consent-preferences.tsx create mode 100644 apps/sim/app/_shell/consent/consent-provider.test.tsx rename apps/sim/app/_shell/consent/{consent-runtime.test.tsx => consent-store-provider.test.tsx} (64%) create mode 100644 apps/sim/app/_shell/consent/consent-store-provider.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/privacy/privacy.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/privacy/privacy.tsx create mode 100644 apps/sim/lib/landing/routes.ts diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx index 230033ff19c..3fbeab7f772 100644 --- a/apps/sim/app/_shell/consent/consent-banner.tsx +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -1,80 +1,39 @@ 'use client' import { useEffect } from 'react' -import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless' -import { Chip, Label, Switch } from '@sim/emcn' +import { useHeadlessConsentUI } from '@c15t/nextjs/headless' +import { Chip } from '@sim/emcn' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import Link from 'next/link' -import { type ConsentCategory, OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants' - -interface ConsentCategoryCopy { - title: string - description: string -} - -/** - * Sim's own wording per category. The runtime ships generic descriptions; these - * say what the cookies actually do here. - * - * Typed by name rather than by {@link ConsentCategory} because the runtime's - * union is wider than the three categories we configure — a policy that adds - * one server-side falls back to the runtime's description instead of - * disappearing. The `satisfies` still requires an entry for each of ours. - */ -const CONSENT_CATEGORY_COPY: Record = { - necessary: { - title: 'Necessary', - description: 'Sign-in and security. Always on.', - }, - measurement: { - title: 'Analytics', - description: 'Shows us how Sim is used so we can make it better.', - }, - marketing: { - title: 'Marketing', - description: 'Measures which campaigns bring builders to Sim.', - }, -} satisfies Record +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. */ const EASE = [0.22, 1, 0.36, 1] as const const ENTER_TRANSITION = { duration: 0.28, ease: EASE } as const const EXPAND_TRANSITION = { duration: 0.22, ease: EASE } as const -const NO_CATEGORIES: ReturnType['getDisplayedConsents']> = [] - const CATEGORIES_COLLAPSED = { height: 0, opacity: 0 } as const const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const -/** - * A copy of `PROSE_TYPE.link` rather than an import: the banner lives in the - * app shell and the token lives in the landing route group, and a shell module - * reaching into a route group is the wrong direction for one class string. - */ -const LINK_CLASS = - 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]' - /** * Cookie consent banner — a non-modal card docked bottom-left, opposite the * toast stack and wearing the same chrome. It never dims, blocks, or reflows - * the page, and "Customize" expands this same card into per-category switches - * rather than opening a dialog over the app. + * the page, and "Customize" expands this same card into the per-category + * switches rather than opening a dialog over the app. * * Visibility and the available actions come from the jurisdiction policy the * consent runtime resolves, so the banner is absent entirely where no consent * is required and never offers an action the policy does not allow. Accept and * reject carry identical weight, which GDPR requires. * - * The card pins the `light` token layer rather than following the visitor's - * theme, as every other public surface does (`LandingShell`, `AuthShell`, the - * chat interfaces, the public file view). Consent is asked for on a first - * visit, which lands on one of those. A record expiring against a live session - * is the one path that renders this card over the themed app, where it will - * read light-on-dark; accepted as the rarer case. + * 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. */ export function ConsentBanner() { - const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = - useConsentManager() const { banner, dialog, openDialog, performAction, saveCustomPreferences } = useHeadlessConsentUI() const prefersReducedMotion = useReducedMotion() @@ -87,13 +46,6 @@ export function ConsentBanner() { const isExpanded = dialog.isVisible const surfaceName = isExpanded ? 'dialog' : 'banner' const { allowedActions } = isExpanded ? dialog : banner - /** - * The store's own selector, not a hand-rolled filter over `consentTypes`: the - * shipped defaults mark every category except `necessary` as `display: false`, - * so filtering on that flag silently renders a one-row list. It re-filters and - * re-allocates on every call, so only the expanded card pays for it. - */ - const categories = isExpanded ? getDisplayedConsents() : NO_CATEGORIES const enterOffset = prefersReducedMotion ? 0 : 8 return ( @@ -105,13 +57,13 @@ export function ConsentBanner() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: enterOffset }} transition={ENTER_TRANSITION} - className='light fixed bottom-4 left-4 z-[var(--z-toast)] flex w-[min(100vw-2rem,380px)] flex-col gap-3 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)] p-4 shadow-overlay' + className='fixed bottom-4 left-4 z-[var(--z-toast)] flex w-[min(100vw-2rem,380px)] flex-col gap-3 overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--bg)] p-4 shadow-overlay' >

Cookies

We use cookies to run Sim, understand how it is used, and improve it. Read our{' '} - + Cookie Policy . @@ -128,28 +80,7 @@ export function ConsentBanner() { transition={EXPAND_TRANSITION} className='overflow-hidden' > -

    - {categories.map((type) => { - const copy = CONSENT_CATEGORY_COPY[type.name] - const inputId = `consent-${type.name}` - return ( -
  • -
    - -

    - {copy?.description ?? type.description} -

    -
    - setSelectedConsent(type.name, checked)} - /> -
  • - ) - })} -
+ )} diff --git a/apps/sim/app/_shell/consent/consent-preferences.tsx b/apps/sim/app/_shell/consent/consent-preferences.tsx new file mode 100644 index 00000000000..36946c22814 --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-preferences.tsx @@ -0,0 +1,88 @@ +'use client' + +import { useConsentManager } from '@c15t/nextjs/headless' +import { Label, Switch } from '@sim/emcn' +import type { ConsentCategory } from '@/lib/consent/constants' + +/** + * Inline link chrome for the consent surfaces, matching `PROSE_TYPE.link` on the + * legal pages. Copied rather than imported because both consumers sit outside + * the landing route group that owns that token, and defined here — the module + * they already share — so the copy exists once. + */ +export const CONSENT_LINK_CLASS = + 'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]' + +interface ConsentCategoryCopy { + title: string + description: string +} + +/** + * Sim's own wording per category. The runtime ships generic descriptions; these + * say what the cookies actually do here. + * + * Typed by name rather than by {@link ConsentCategory} because the runtime's + * union is wider than the three categories we configure — a policy that adds + * one server-side falls back to the runtime's description instead of + * disappearing. The `satisfies` still requires an entry for each of ours. + */ +const CONSENT_CATEGORY_COPY: Record = { + necessary: { + title: 'Necessary', + description: 'Sign-in and security. Always on.', + }, + measurement: { + title: 'Analytics', + description: 'Shows us how Sim is used so we can make it better.', + }, + marketing: { + title: 'Marketing', + description: 'Measures which campaigns bring builders to Sim.', + }, +} satisfies Record + +/** + * The per-category consent switches, shared by the two surfaces that offer + * them: the banner's expanded state and the Privacy settings page. Both write + * to `selectedConsents`; committing is the caller's, since the banner saves + * from its own footer and settings saves from the shell's header. + * + * Must be rendered inside a `ConsentManagerProvider`. + */ +export function ConsentPreferences() { + const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } = + useConsentManager() + + /** + * The store's own selector, not a hand-rolled filter over `consentTypes`: the + * shipped defaults mark every category except `necessary` as `display: false`, + * so filtering on that flag silently renders a one-row list. + */ + const categories = getDisplayedConsents() + + return ( +
    + {categories.map((type) => { + const copy = CONSENT_CATEGORY_COPY[type.name] + const inputId = `consent-${type.name}` + return ( +
  • +
    + +

    + {copy?.description ?? type.description} +

    +
    + setSelectedConsent(type.name, checked)} + /> +
  • + ) + })} +
+ ) +} diff --git a/apps/sim/app/_shell/consent/consent-provider.test.tsx b/apps/sim/app/_shell/consent/consent-provider.test.tsx new file mode 100644 index 00000000000..b4ad6f0f87b --- /dev/null +++ b/apps/sim/app/_shell/consent/consent-provider.test.tsx @@ -0,0 +1,69 @@ +/** + * @vitest-environment jsdom + */ +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('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 + } + }, +})) + +import { ConsentProvider } from '@/app/_shell/consent/consent-provider' + +let root: Root | null = null + +function renderAt(pathname: string): HTMLDivElement { + mockPathname.mockReturnValue(pathname) + ;(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()) + return container +} + +afterEach(() => { + act(() => root?.unmount()) + root = null + vi.clearAllMocks() +}) + +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() + } + ) +}) diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index d1c4f4d6dde..97e544005c1 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -1,21 +1,43 @@ 'use client' import dynamic from 'next/dynamic' +import { usePathname } from 'next/navigation' /** * The cookie-consent runtime, loaded on the client only and only once this - * component is rendered — the root layout renders it behind `isHosted`, so a + * 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. - * - * It mounts alongside the app rather than wrapping it because an `ssr: false` - * boundary around the tree would disable SSR for every route. Nothing can reach - * the store through context as a result, which is what - * `OPEN_CONSENT_PREFERENCES_EVENT` exists for. */ -export const ConsentProvider = dynamic( +const ConsentRuntime = dynamic( () => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime), { ssr: false } ) + +const WORKSPACE_SEGMENT = 'workspace' + +/** + * 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. + */ +export function ConsentProvider() { + const pathname = usePathname() + + if (pathname.split('/')[1] === WORKSPACE_SEGMENT) { + return null + } + + return +} diff --git a/apps/sim/app/_shell/consent/consent-runtime.tsx b/apps/sim/app/_shell/consent/consent-runtime.tsx index adfe5c381cb..243c1ed6b66 100644 --- a/apps/sim/app/_shell/consent/consent-runtime.tsx +++ b/apps/sim/app/_shell/consent/consent-runtime.tsx @@ -1,39 +1,16 @@ 'use client' -import { type ConsentManagerOptions, ConsentManagerProvider } from '@c15t/nextjs/headless' -import { - CONSENT_BACKEND_URL, - CONSENT_CATEGORIES, - DEV_CONSENT_COUNTRY, -} from '@/lib/consent/constants' import { ConsentBanner } from '@/app/_shell/consent/consent-banner' +import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' /** - * Imported from `@c15t/nextjs/headless`, not the package root: the headless - * entry leaves the runtime's own components and stylesheet out of the bundle, - * so {@link ConsentBanner} is the only consent UI that exists. The provider - * still injects a `