Skip to content

Commit ff23279

Browse files
authored
feat(consent): manage cookies from Settings, not from a card in the workspace (#6835)
* 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 <html> 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. * fix(consent): keep Privacy on one settings surface Projecting the section into the account plane put it in a catalog that buildPlaneSettingsItems does not gate on requiresHosted, so a self-hosted deployment would list a Privacy entry, and AccountSettingsRenderer's catch-all rendered Mothership for it. The unified settings already gate the section and redirect self-hosted deployments to General, so the section lives there only.
1 parent 9328e66 commit ff23279

16 files changed

Lines changed: 613 additions & 189 deletions

File tree

apps/sim/app/_shell/consent/consent-banner.tsx

Lines changed: 14 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,39 @@
11
'use client'
22

33
import { useEffect } from 'react'
4-
import { useConsentManager, useHeadlessConsentUI } from '@c15t/nextjs/headless'
5-
import { Chip, Label, Switch } from '@sim/emcn'
4+
import { useHeadlessConsentUI } from '@c15t/nextjs/headless'
5+
import { Chip } from '@sim/emcn'
66
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
77
import Link from 'next/link'
8-
import { type ConsentCategory, OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants'
9-
10-
interface ConsentCategoryCopy {
11-
title: string
12-
description: string
13-
}
14-
15-
/**
16-
* Sim's own wording per category. The runtime ships generic descriptions; these
17-
* say what the cookies actually do here.
18-
*
19-
* Typed by name rather than by {@link ConsentCategory} because the runtime's
20-
* union is wider than the three categories we configure — a policy that adds
21-
* one server-side falls back to the runtime's description instead of
22-
* disappearing. The `satisfies` still requires an entry for each of ours.
23-
*/
24-
const CONSENT_CATEGORY_COPY: Record<string, ConsentCategoryCopy | undefined> = {
25-
necessary: {
26-
title: 'Necessary',
27-
description: 'Sign-in and security. Always on.',
28-
},
29-
measurement: {
30-
title: 'Analytics',
31-
description: 'Shows us how Sim is used so we can make it better.',
32-
},
33-
marketing: {
34-
title: 'Marketing',
35-
description: 'Measures which campaigns bring builders to Sim.',
36-
},
37-
} satisfies Record<ConsentCategory, ConsentCategoryCopy>
8+
import { OPEN_CONSENT_PREFERENCES_EVENT } from '@/lib/consent/constants'
9+
import { CONSENT_LINK_CLASS, ConsentPreferences } from '@/app/_shell/consent/consent-preferences'
3810

3911
/** Shared expo-out easing and timings, matching the toast stack's motion. */
4012
const EASE = [0.22, 1, 0.36, 1] as const
4113
const ENTER_TRANSITION = { duration: 0.28, ease: EASE } as const
4214
const EXPAND_TRANSITION = { duration: 0.22, ease: EASE } as const
4315

44-
const NO_CATEGORIES: ReturnType<ReturnType<typeof useConsentManager>['getDisplayedConsents']> = []
45-
4616
const CATEGORIES_COLLAPSED = { height: 0, opacity: 0 } as const
4717
const CATEGORIES_OPEN = { height: 'auto', opacity: 1 } as const
4818

49-
/**
50-
* A copy of `PROSE_TYPE.link` rather than an import: the banner lives in the
51-
* app shell and the token lives in the landing route group, and a shell module
52-
* reaching into a route group is the wrong direction for one class string.
53-
*/
54-
const LINK_CLASS =
55-
'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]'
56-
5719
/**
5820
* Cookie consent banner — a non-modal card docked bottom-left, opposite the
5921
* toast stack and wearing the same chrome. It never dims, blocks, or reflows
60-
* the page, and "Customize" expands this same card into per-category switches
61-
* rather than opening a dialog over the app.
22+
* the page, and "Customize" expands this same card into the per-category
23+
* switches rather than opening a dialog over the app.
6224
*
6325
* Visibility and the available actions come from the jurisdiction policy the
6426
* consent runtime resolves, so the banner is absent entirely where no consent
6527
* is required and never offers an action the policy does not allow. Accept and
6628
* reject carry identical weight, which GDPR requires.
6729
*
68-
* The card pins the `light` token layer rather than following the visitor's
69-
* theme, as every other public surface does (`LandingShell`, `AuthShell`, the
70-
* chat interfaces, the public file view). Consent is asked for on a first
71-
* visit, which lands on one of those. A record expiring against a live session
72-
* is the one path that renders this card over the themed app, where it will
73-
* read light-on-dark; accepted as the rarer case.
30+
* It follows the visitor's theme. Every surface it can appear on either pins
31+
* the light layer on `<html>` through `ThemeProvider`'s forced theme, or is a
32+
* themed app page where inheriting is what should happen — the card no longer
33+
* decides for itself. Inside the workspace it never renders at all; consent is
34+
* managed from Settings → Privacy there.
7435
*/
7536
export function ConsentBanner() {
76-
const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } =
77-
useConsentManager()
7837
const { banner, dialog, openDialog, performAction, saveCustomPreferences } =
7938
useHeadlessConsentUI()
8039
const prefersReducedMotion = useReducedMotion()
@@ -87,13 +46,6 @@ export function ConsentBanner() {
8746
const isExpanded = dialog.isVisible
8847
const surfaceName = isExpanded ? 'dialog' : 'banner'
8948
const { allowedActions } = isExpanded ? dialog : banner
90-
/**
91-
* The store's own selector, not a hand-rolled filter over `consentTypes`: the
92-
* shipped defaults mark every category except `necessary` as `display: false`,
93-
* so filtering on that flag silently renders a one-row list. It re-filters and
94-
* re-allocates on every call, so only the expanded card pays for it.
95-
*/
96-
const categories = isExpanded ? getDisplayedConsents() : NO_CATEGORIES
9749
const enterOffset = prefersReducedMotion ? 0 : 8
9850

9951
return (
@@ -105,13 +57,13 @@ export function ConsentBanner() {
10557
animate={{ opacity: 1, y: 0 }}
10658
exit={{ opacity: 0, y: enterOffset }}
10759
transition={ENTER_TRANSITION}
108-
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'
60+
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'
10961
>
11062
<div className='flex flex-col gap-1'>
11163
<p className='text-[var(--text-body)] text-sm leading-5'>Cookies</p>
11264
<p className='text-[var(--text-muted)] text-small leading-[18px]'>
11365
We use cookies to run Sim, understand how it is used, and improve it. Read our{' '}
114-
<Link href='/cookie-policy' className={LINK_CLASS}>
66+
<Link href='/cookie-policy' className={CONSENT_LINK_CLASS}>
11567
Cookie Policy
11668
</Link>
11769
.
@@ -128,28 +80,7 @@ export function ConsentBanner() {
12880
transition={EXPAND_TRANSITION}
12981
className='overflow-hidden'
13082
>
131-
<ul className='flex flex-col gap-3'>
132-
{categories.map((type) => {
133-
const copy = CONSENT_CATEGORY_COPY[type.name]
134-
const inputId = `consent-${type.name}`
135-
return (
136-
<li key={type.name} className='flex items-start justify-between gap-3'>
137-
<div className='flex min-w-0 flex-col gap-1'>
138-
<Label htmlFor={inputId}>{copy?.title ?? type.name}</Label>
139-
<p className='text-[var(--text-muted)] text-caption leading-4'>
140-
{copy?.description ?? type.description}
141-
</p>
142-
</div>
143-
<Switch
144-
id={inputId}
145-
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
146-
disabled={type.disabled}
147-
onCheckedChange={(checked) => setSelectedConsent(type.name, checked)}
148-
/>
149-
</li>
150-
)
151-
})}
152-
</ul>
83+
<ConsentPreferences />
15384
</motion.div>
15485
)}
15586
</AnimatePresence>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client'
2+
3+
import { useConsentManager } from '@c15t/nextjs/headless'
4+
import { Label, Switch } from '@sim/emcn'
5+
import type { ConsentCategory } from '@/lib/consent/constants'
6+
7+
/**
8+
* Inline link chrome for the consent surfaces, matching `PROSE_TYPE.link` on the
9+
* legal pages. Copied rather than imported because both consumers sit outside
10+
* the landing route group that owns that token, and defined here — the module
11+
* they already share — so the copy exists once.
12+
*/
13+
export const CONSENT_LINK_CLASS =
14+
'text-[var(--text-primary)] underline underline-offset-2 transition-colors hover:text-[var(--text-body)]'
15+
16+
interface ConsentCategoryCopy {
17+
title: string
18+
description: string
19+
}
20+
21+
/**
22+
* Sim's own wording per category. The runtime ships generic descriptions; these
23+
* say what the cookies actually do here.
24+
*
25+
* Typed by name rather than by {@link ConsentCategory} because the runtime's
26+
* union is wider than the three categories we configure — a policy that adds
27+
* one server-side falls back to the runtime's description instead of
28+
* disappearing. The `satisfies` still requires an entry for each of ours.
29+
*/
30+
const CONSENT_CATEGORY_COPY: Record<string, ConsentCategoryCopy | undefined> = {
31+
necessary: {
32+
title: 'Necessary',
33+
description: 'Sign-in and security. Always on.',
34+
},
35+
measurement: {
36+
title: 'Analytics',
37+
description: 'Shows us how Sim is used so we can make it better.',
38+
},
39+
marketing: {
40+
title: 'Marketing',
41+
description: 'Measures which campaigns bring builders to Sim.',
42+
},
43+
} satisfies Record<ConsentCategory, ConsentCategoryCopy>
44+
45+
/**
46+
* The per-category consent switches, shared by the two surfaces that offer
47+
* them: the banner's expanded state and the Privacy settings page. Both write
48+
* to `selectedConsents`; committing is the caller's, since the banner saves
49+
* from its own footer and settings saves from the shell's header.
50+
*
51+
* Must be rendered inside a `ConsentManagerProvider`.
52+
*/
53+
export function ConsentPreferences() {
54+
const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } =
55+
useConsentManager()
56+
57+
/**
58+
* The store's own selector, not a hand-rolled filter over `consentTypes`: the
59+
* shipped defaults mark every category except `necessary` as `display: false`,
60+
* so filtering on that flag silently renders a one-row list.
61+
*/
62+
const categories = getDisplayedConsents()
63+
64+
return (
65+
<ul className='flex flex-col gap-3'>
66+
{categories.map((type) => {
67+
const copy = CONSENT_CATEGORY_COPY[type.name]
68+
const inputId = `consent-${type.name}`
69+
return (
70+
<li key={type.name} className='flex items-start justify-between gap-3'>
71+
<div className='flex min-w-0 flex-col gap-1'>
72+
<Label htmlFor={inputId}>{copy?.title ?? type.name}</Label>
73+
<p className='text-[var(--text-muted)] text-caption leading-4'>
74+
{copy?.description ?? type.description}
75+
</p>
76+
</div>
77+
<Switch
78+
id={inputId}
79+
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
80+
disabled={type.disabled}
81+
onCheckedChange={(checked) => setSelectedConsent(type.name, checked)}
82+
/>
83+
</li>
84+
)
85+
})}
86+
</ul>
87+
)
88+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockPathname, mockDynamicImport } = vi.hoisted(() => ({
9+
mockPathname: vi.fn(),
10+
mockDynamicImport: vi.fn(),
11+
}))
12+
13+
vi.mock('next/navigation', () => ({ usePathname: mockPathname }))
14+
15+
/**
16+
* Stands in for the lazily-loaded runtime and records whether the chunk was
17+
* asked for at all — that, not just the absence of a banner, is what the
18+
* workspace gate is for.
19+
*/
20+
vi.mock('next/dynamic', () => ({
21+
default: (loader: () => Promise<unknown>) => {
22+
return function LazyRuntime() {
23+
mockDynamicImport(loader)
24+
return <span data-testid='runtime' />
25+
}
26+
},
27+
}))
28+
29+
import { ConsentProvider } from '@/app/_shell/consent/consent-provider'
30+
31+
let root: Root | null = null
32+
33+
function renderAt(pathname: string): HTMLDivElement {
34+
mockPathname.mockReturnValue(pathname)
35+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
36+
const container = document.createElement('div')
37+
document.body.appendChild(container)
38+
root = createRoot(container)
39+
act(() => root?.render(<ConsentProvider />))
40+
return container
41+
}
42+
43+
afterEach(() => {
44+
act(() => root?.unmount())
45+
root = null
46+
vi.clearAllMocks()
47+
})
48+
49+
describe('ConsentProvider', () => {
50+
it.each(['/', '/pricing', '/login', '/cookie-policy', '/upgrade', '/workspaces'])(
51+
'mounts the consent runtime on %s',
52+
(pathname) => {
53+
const container = renderAt(pathname)
54+
55+
expect(container.querySelector('[data-testid="runtime"]')).not.toBeNull()
56+
expect(mockDynamicImport).toHaveBeenCalled()
57+
}
58+
)
59+
60+
it.each(['/workspace', '/workspace/abc', '/workspace/abc/logs'])(
61+
'mounts nothing on %s',
62+
(pathname) => {
63+
const container = renderAt(pathname)
64+
65+
expect(container.querySelector('[data-testid="runtime"]')).toBeNull()
66+
expect(mockDynamicImport).not.toHaveBeenCalled()
67+
}
68+
)
69+
})
Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,43 @@
11
'use client'
22

33
import dynamic from 'next/dynamic'
4+
import { usePathname } from 'next/navigation'
45

56
/**
67
* The cookie-consent runtime, loaded on the client only and only once this
7-
* component is rendered — the root layout renders it behind `isHosted`, so a
8+
* component renders it — the root layout renders it behind `isHosted`, so a
89
* self-hosted deployment never fetches the chunk, never reaches Sim's consent
910
* backend, and never sees the banner. Deferring it also keeps the third-party
1011
* store out of the server render and off the landing page's hydration path; the
1112
* banner cannot paint before its geo lookup resolves anyway.
12-
*
13-
* It mounts alongside the app rather than wrapping it because an `ssr: false`
14-
* boundary around the tree would disable SSR for every route. Nothing can reach
15-
* the store through context as a result, which is what
16-
* `OPEN_CONSENT_PREFERENCES_EVENT` exists for.
1713
*/
18-
export const ConsentProvider = dynamic(
14+
const ConsentRuntime = dynamic(
1915
() => import('@/app/_shell/consent/consent-runtime').then((m) => m.ConsentRuntime),
2016
{ ssr: false }
2117
)
18+
19+
const WORKSPACE_SEGMENT = 'workspace'
20+
21+
/**
22+
* Mounts the consent runtime everywhere except the workspace.
23+
*
24+
* Inside the product a floating consent card is the wrong surface — a signed-in
25+
* user manages this from Settings → Privacy, which mounts the same store. The
26+
* check sits above the `dynamic()` rather than inside the loaded module so the
27+
* workspace pays neither the chunk nor the consent init request: gating within
28+
* the module would still have downloaded it, on the surface with the most hard
29+
* loads.
30+
*
31+
* The gap this leaves — a visitor who reaches the workspace with no consent
32+
* record is not prompted — closes when the analytics scripts move behind
33+
* consent, since nothing non-essential loads without a record at all.
34+
*/
35+
export function ConsentProvider() {
36+
const pathname = usePathname()
37+
38+
if (pathname.split('/')[1] === WORKSPACE_SEGMENT) {
39+
return null
40+
}
41+
42+
return <ConsentRuntime />
43+
}

0 commit comments

Comments
 (0)