Skip to content

Commit f82085a

Browse files
authored
refactor(consent): fold cookie preferences into General > Privacy (#6837)
* refactor(consent): fold cookie preferences into General > Privacy The consent settings were a top-level tab of their own, which is the wrong weight for something a user opens once. They are now a sub-view of General, reached from the Privacy section that already held the telemetry toggle, and that toggle moves with them so one page owns everything Sim collects. Cookies render only on the hosted service, the only deployment that sets them; telemetry renders everywhere, so the sub-view is useful on a self-hosted deployment too. Each cookie switch commits on change rather than staging behind a Save, matching the telemetry switch directly above it -- one interaction model per page, and no unsaved-consent state. saveConsents('custom') reads selectedConsents from the store at call time and the switch's write is synchronous, so the value a toggle stages is the value it commits. The open sub-view lives in the URL, so it is linkable and Back closes it. * fix(consent): keep the old /settings/privacy link working The section moved into General, so the path no longer resolves. Redirect it to the replacement view through TOP_LEVEL_REDIRECTS, which the route already uses for the integrations and skills moves. * fix(consent): stop two cookie toggles from racing, and revert a failed one Each save sends the whole selectedConsents snapshot, so two quick toggles could finish out of order and land the older choice. The switches now lock while a commit is in flight, exactly as the telemetry switch does on its own mutation, and a failed commit puts the switch back instead of showing a preference that was never recorded. Also stop the General blurb promising cookie controls on a self-hosted deployment, where the sub-view only carries telemetry.
1 parent 7bfd78c commit f82085a

13 files changed

Lines changed: 332 additions & 296 deletions

File tree

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

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,29 @@ const CONSENT_CATEGORY_COPY: Record<string, ConsentCategoryCopy | undefined> = {
4242
},
4343
} satisfies Record<ConsentCategory, ConsentCategoryCopy>
4444

45+
/** The runtime's category union, without re-declaring it. */
46+
type ConsentCategoryName = Parameters<ReturnType<typeof useConsentManager>['setSelectedConsent']>[0]
47+
48+
interface ConsentPreferencesProps {
49+
/**
50+
* Called after a switch stages its new value, for a surface that commits per
51+
* toggle. `revert` puts the category back, for a commit that then fails. The
52+
* banner omits this and commits from its own footer instead.
53+
*/
54+
onChange?: (change: { name: ConsentCategoryName; revert: () => void }) => void
55+
/** Locks every switch, e.g. while a commit is in flight. */
56+
disabled?: boolean
57+
}
58+
4559
/**
4660
* The per-category consent switches, shared by the two surfaces that offer
4761
* 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.
62+
* to `selectedConsents`; whether that is then committed is the caller's, via
63+
* {@link ConsentPreferencesProps.onChange}.
5064
*
51-
* Must be rendered inside a `ConsentManagerProvider`.
65+
* Must be rendered inside a `ConsentStoreProvider`.
5266
*/
53-
export function ConsentPreferences() {
67+
export function ConsentPreferences({ onChange, disabled = false }: ConsentPreferencesProps) {
5468
const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } =
5569
useConsentManager()
5670

@@ -77,8 +91,14 @@ export function ConsentPreferences() {
7791
<Switch
7892
id={inputId}
7993
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
80-
disabled={type.disabled}
81-
onCheckedChange={(checked) => setSelectedConsent(type.name, checked)}
94+
disabled={type.disabled || disabled}
95+
onCheckedChange={(checked) => {
96+
setSelectedConsent(type.name, checked)
97+
onChange?.({
98+
name: type.name,
99+
revert: () => setSelectedConsent(type.name, !checked),
100+
})
101+
}}
82102
/>
83103
</li>
84104
)

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const SECTION_ALIASES: Readonly<Record<string, SettingsSection>> = {
4444
const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => string>> = {
4545
integrations: (workspaceId) => `/workspace/${workspaceId}/integrations`,
4646
skills: (workspaceId) => `/workspace/${workspaceId}/skills`,
47+
// Cookie preferences moved into General; keep old links working.
48+
privacy: (workspaceId) => `/workspace/${workspaceId}/settings/general?view=privacy`,
4749
}
4850

4951
const WORKSPACE_SECTION_MAP: Partial<Record<SettingsSection, WorkspaceSettingsSection>> = {

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useEffect } from 'react'
44
import dynamic from 'next/dynamic'
55
import { usePostHog } from 'posthog-js/react'
66
import { useSession } from '@/lib/auth/auth-client'
7-
import { isHosted } from '@/lib/core/config/env-flags'
87
import { captureEvent } from '@/lib/posthog/client'
98
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
109
import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
@@ -105,9 +104,6 @@ const DataRetentionSettings = dynamic(() =>
105104
const DataDrainsSettings = dynamic(() =>
106105
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
107106
)
108-
const Privacy = dynamic(() =>
109-
import('@/app/workspace/[workspaceId]/settings/components/privacy/privacy').then((m) => m.Privacy)
110-
)
111107
const Desktop = dynamic(() =>
112108
import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop').then((m) => m.Desktop)
113109
)
@@ -146,9 +142,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
146142
? 'general'
147143
: normalizedSection === 'mothership' && !sessionLoading && !isAdminRole
148144
? 'general'
149-
: normalizedSection === 'privacy' && !isHosted
150-
? 'general'
151-
: normalizedSection
145+
: normalizedSection
152146
const organizationId = hostContext.hostOrganizationId
153147
const meta = getSettingsSectionMeta(effectiveSection)
154148

@@ -163,7 +157,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
163157
return (
164158
<SettingsSectionProvider section={effectiveSection} meta={meta ?? undefined}>
165159
{effectiveSection === 'general' && <General />}
166-
{effectiveSection === 'privacy' && <Privacy />}
167160
{effectiveSection === 'desktop' && <Desktop />}
168161
{effectiveSection === 'browser' && <Browser />}
169162
{effectiveSection === 'terminal' && <Terminal />}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import type { ReactNode } from 'react'
5+
import { act } from 'react'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
const { mockUseConsentManager, mockSaveConsents, mockToastError, mockRevert, lastProps } =
10+
vi.hoisted(() => ({
11+
mockUseConsentManager: vi.fn(),
12+
mockSaveConsents: vi.fn(),
13+
mockToastError: vi.fn(),
14+
mockRevert: vi.fn(),
15+
lastProps: vi.fn(),
16+
}))
17+
18+
vi.mock('@sim/emcn', () => ({ toast: { success: vi.fn(), error: mockToastError } }))
19+
vi.mock('@c15t/nextjs/headless', () => ({ useConsentManager: mockUseConsentManager }))
20+
vi.mock('@/app/_shell/consent/consent-store-provider', () => ({
21+
ConsentStoreProvider: ({ children }: { children: ReactNode }) => children,
22+
}))
23+
vi.mock('@/app/_shell/consent/consent-preferences', () => ({
24+
CONSENT_LINK_CLASS: 'link',
25+
ConsentPreferences: (props: {
26+
onChange?: (change: { name: string; revert: () => void }) => void
27+
disabled?: boolean
28+
}) => {
29+
lastProps(props)
30+
return (
31+
<button
32+
type='button'
33+
data-testid='toggle'
34+
disabled={props.disabled}
35+
onClick={() => props.onChange?.({ name: 'measurement', revert: mockRevert })}
36+
/>
37+
)
38+
},
39+
}))
40+
41+
import { CookiePreferences } from '@/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences'
42+
43+
let root: Root | null = null
44+
45+
/** The props the switch list was last rendered with. */
46+
function props() {
47+
return lastProps.mock.calls.at(-1)?.[0] as { disabled?: boolean }
48+
}
49+
50+
function render() {
51+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
52+
const container = document.createElement('div')
53+
document.body.appendChild(container)
54+
root = createRoot(container)
55+
act(() => root?.render(<CookiePreferences />))
56+
return container
57+
}
58+
59+
/** Resolves the pending save on demand, so the in-flight state is observable. */
60+
function deferredSave() {
61+
let resolve!: () => void
62+
let reject!: (error: Error) => void
63+
mockSaveConsents.mockReturnValue(
64+
new Promise<void>((res, rej) => {
65+
resolve = res
66+
reject = rej
67+
})
68+
)
69+
return { resolve, reject }
70+
}
71+
72+
beforeEach(() => {
73+
mockUseConsentManager.mockReturnValue({ saveConsents: mockSaveConsents })
74+
mockSaveConsents.mockResolvedValue(undefined)
75+
})
76+
77+
afterEach(() => {
78+
act(() => root?.unmount())
79+
root = null
80+
vi.clearAllMocks()
81+
})
82+
83+
describe('CookiePreferences', () => {
84+
it('commits on every toggle, matching the telemetry switch beside it', async () => {
85+
const container = render()
86+
87+
expect(mockSaveConsents).not.toHaveBeenCalled()
88+
await act(async () => {
89+
container.querySelector<HTMLButtonElement>('[data-testid="toggle"]')?.click()
90+
})
91+
92+
// `saveConsents('custom')` reads `selectedConsents` from the store at call
93+
// time and the switch's `setSelectedConsent` write is synchronous, so the
94+
// value this toggle staged is the one committed.
95+
expect(mockSaveConsents).toHaveBeenCalledWith('custom', { uiSource: 'settings' })
96+
})
97+
98+
it('locks the switches while a commit is in flight, so two toggles cannot race', async () => {
99+
const pending = deferredSave()
100+
const container = render()
101+
102+
act(() => {
103+
container.querySelector<HTMLButtonElement>('[data-testid="toggle"]')?.click()
104+
})
105+
expect(props().disabled).toBe(true)
106+
107+
await act(async () => {
108+
pending.resolve()
109+
})
110+
expect(props().disabled).toBe(false)
111+
})
112+
113+
it('puts the switch back when the commit fails', async () => {
114+
mockSaveConsents.mockRejectedValue(new Error('network down'))
115+
const container = render()
116+
117+
await act(async () => {
118+
container.querySelector<HTMLButtonElement>('[data-testid="toggle"]')?.click()
119+
})
120+
121+
expect(mockRevert).toHaveBeenCalledTimes(1)
122+
expect(mockToastError).toHaveBeenCalled()
123+
expect(props().disabled).toBe(false)
124+
})
125+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { useConsentManager } from '@c15t/nextjs/headless'
5+
import { toast } from '@sim/emcn'
6+
import { getErrorMessage } from '@sim/utils/errors'
7+
import Link from 'next/link'
8+
import { CONSENT_LINK_CLASS, ConsentPreferences } from '@/app/_shell/consent/consent-preferences'
9+
import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider'
10+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
11+
12+
/**
13+
* Body of the cookies section, split out because it reads the consent store,
14+
* which only exists below the provider.
15+
*/
16+
function CookiePreferencesBody() {
17+
const { saveConsents } = useConsentManager()
18+
const [saving, setSaving] = useState(false)
19+
20+
/**
21+
* Each toggle commits, matching the telemetry switch directly above it — one
22+
* interaction model on the page, and no "unsaved consent" state to reason
23+
* about. The banner stages instead, because its footer owns the commit.
24+
*
25+
* The switches lock while a commit is in flight, as the telemetry switch does
26+
* on its own mutation. Without that, two quick toggles race: each save sends
27+
* the whole `selectedConsents` snapshot, so the slower request can land last
28+
* and overwrite the newer choice. A failed commit puts the switch back rather
29+
* than leaving it showing a preference that was never recorded.
30+
*/
31+
const commit = async ({ revert }: { revert: () => void }) => {
32+
setSaving(true)
33+
try {
34+
await saveConsents('custom', { uiSource: 'settings' })
35+
} catch (error) {
36+
revert()
37+
toast.error(getErrorMessage(error, 'Could not save your cookie preferences'))
38+
} finally {
39+
setSaving(false)
40+
}
41+
}
42+
43+
return (
44+
<SettingsSection label='Cookies'>
45+
<div className='flex flex-col gap-3'>
46+
<ConsentPreferences onChange={commit} disabled={saving} />
47+
<p className='text-[var(--text-muted)] text-small'>
48+
Your choice applies to this browser and is kept for 365 days. The{' '}
49+
<Link
50+
href='/cookie-policy'
51+
target='_blank'
52+
rel='noopener noreferrer'
53+
className={CONSENT_LINK_CLASS}
54+
>
55+
Cookie Policy
56+
</Link>{' '}
57+
lists what each category covers.
58+
</p>
59+
</div>
60+
</SettingsSection>
61+
)
62+
}
63+
64+
/** The cookies section, with the store it reads. */
65+
export function CookiePreferences() {
66+
return (
67+
<ConsentStoreProvider>
68+
<CookiePreferencesBody />
69+
</ConsentStoreProvider>
70+
)
71+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use client'
2+
3+
import { ArrowLeft, Label, Switch } from '@sim/emcn'
4+
import { requestJson } from '@/lib/api/client/request'
5+
import { telemetryContract } from '@/lib/api/contracts/telemetry'
6+
import { isHosted } from '@/lib/core/config/env-flags'
7+
import { CookiePreferences } from '@/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences'
8+
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
9+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
10+
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
11+
12+
interface PrivacyViewProps {
13+
onBack: () => void
14+
}
15+
16+
/**
17+
* Privacy sub-view of General — the one place a signed-in user changes what Sim
18+
* may collect.
19+
*
20+
* A detail sub-view rather than its own settings tab: the nav is already long,
21+
* and a tab a user opens once and never returns to is the wrong weight for it.
22+
* Telemetry shows everywhere; cookies only on the hosted service, which is the
23+
* only deployment that sets them.
24+
*/
25+
export function PrivacyView({ onBack }: PrivacyViewProps) {
26+
const { data: settings } = useGeneralSettings()
27+
const updateSetting = useUpdateGeneralSetting()
28+
29+
const handleTelemetryToggle = async (checked: boolean) => {
30+
if (checked === settings?.telemetryEnabled || updateSetting.isPending) return
31+
32+
await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
33+
34+
if (checked && typeof window !== 'undefined') {
35+
requestJson(telemetryContract, {
36+
body: {
37+
category: 'consent',
38+
action: 'enable_from_settings',
39+
timestamp: new Date().toISOString(),
40+
},
41+
}).catch(() => {})
42+
}
43+
}
44+
45+
return (
46+
<SettingsPanel
47+
back={{ text: 'General', icon: ArrowLeft, onSelect: onBack }}
48+
title='Privacy'
49+
description='Control what Sim collects about how you use it.'
50+
>
51+
<SettingsSection label='Telemetry'>
52+
<div className='flex flex-col gap-3'>
53+
<div className='flex items-center justify-between'>
54+
<Label htmlFor='telemetry'>Allow anonymous telemetry</Label>
55+
<Switch
56+
id='telemetry'
57+
checked={settings?.telemetryEnabled ?? true}
58+
onCheckedChange={handleTelemetryToggle}
59+
/>
60+
</div>
61+
<p className='text-[var(--text-muted)] text-small'>
62+
We use OpenTelemetry to collect anonymous usage data to improve Sim. You can opt-out at
63+
any time.
64+
</p>
65+
</div>
66+
</SettingsSection>
67+
68+
{isHosted && <CookiePreferences />}
69+
</SettingsPanel>
70+
)
71+
}

0 commit comments

Comments
 (0)