Skip to content

Commit f9b3e3e

Browse files
committed
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.
1 parent 7bfd78c commit f9b3e3e

12 files changed

Lines changed: 240 additions & 295 deletions

File tree

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

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

45+
interface ConsentPreferencesProps {
46+
/**
47+
* Called after a switch stages its new value, for a surface that commits per
48+
* toggle. The banner omits it and commits from its own footer instead.
49+
*/
50+
onChange?: () => void
51+
}
52+
4553
/**
4654
* The per-category consent switches, shared by the two surfaces that offer
4755
* 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.
56+
* to `selectedConsents`; whether that is then committed is the caller's, via
57+
* {@link ConsentPreferencesProps.onChange}.
5058
*
51-
* Must be rendered inside a `ConsentManagerProvider`.
59+
* Must be rendered inside a `ConsentStoreProvider`.
5260
*/
53-
export function ConsentPreferences() {
61+
export function ConsentPreferences({ onChange }: ConsentPreferencesProps) {
5462
const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } =
5563
useConsentManager()
5664

@@ -78,7 +86,10 @@ export function ConsentPreferences() {
7886
id={inputId}
7987
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
8088
disabled={type.disabled}
81-
onCheckedChange={(checked) => setSelectedConsent(type.name, checked)}
89+
onCheckedChange={(checked) => {
90+
setSelectedConsent(type.name, checked)
91+
onChange?.()
92+
}}
8293
/>
8394
</li>
8495
)

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: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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 } = vi.hoisted(() => ({
10+
mockUseConsentManager: vi.fn(),
11+
mockSaveConsents: vi.fn(),
12+
}))
13+
14+
vi.mock('@sim/emcn', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
15+
vi.mock('@c15t/nextjs/headless', () => ({ useConsentManager: mockUseConsentManager }))
16+
vi.mock('@/app/_shell/consent/consent-store-provider', () => ({
17+
ConsentStoreProvider: ({ children }: { children: ReactNode }) => children,
18+
}))
19+
vi.mock('@/app/_shell/consent/consent-preferences', () => ({
20+
CONSENT_LINK_CLASS: 'link',
21+
ConsentPreferences: ({ onChange }: { onChange?: () => void }) => (
22+
<button type='button' data-testid='toggle' onClick={onChange} />
23+
),
24+
}))
25+
26+
import { CookiePreferences } from '@/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences'
27+
28+
let root: Root | null = null
29+
30+
beforeEach(() => {
31+
mockUseConsentManager.mockReturnValue({
32+
saveConsents: mockSaveConsents.mockResolvedValue(undefined),
33+
})
34+
})
35+
36+
afterEach(() => {
37+
act(() => root?.unmount())
38+
root = null
39+
vi.clearAllMocks()
40+
})
41+
42+
describe('CookiePreferences', () => {
43+
it('commits on every toggle, matching the telemetry switch beside it', () => {
44+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
45+
const container = document.createElement('div')
46+
document.body.appendChild(container)
47+
root = createRoot(container)
48+
act(() => root?.render(<CookiePreferences />))
49+
50+
expect(mockSaveConsents).not.toHaveBeenCalled()
51+
act(() => container.querySelector<HTMLButtonElement>('[data-testid="toggle"]')?.click())
52+
53+
// `saveConsents('custom')` reads `selectedConsents` from the store at call
54+
// time and the switch's `setSelectedConsent` write is synchronous, so the
55+
// value this toggle staged is the one committed.
56+
expect(mockSaveConsents).toHaveBeenCalledWith('custom', { uiSource: 'settings' })
57+
})
58+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
3+
import { useConsentManager } from '@c15t/nextjs/headless'
4+
import { toast } from '@sim/emcn'
5+
import { getErrorMessage } from '@sim/utils/errors'
6+
import Link from 'next/link'
7+
import { CONSENT_LINK_CLASS, ConsentPreferences } from '@/app/_shell/consent/consent-preferences'
8+
import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider'
9+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
10+
11+
/**
12+
* Body of the cookies section, split out because it reads the consent store,
13+
* which only exists below the provider.
14+
*/
15+
function CookiePreferencesBody() {
16+
const { saveConsents } = useConsentManager()
17+
18+
/**
19+
* Each toggle commits, matching the telemetry switch directly above it — one
20+
* interaction model on the page, and no "unsaved consent" state to reason
21+
* about. The banner stages instead, because its footer owns the commit.
22+
*/
23+
const commit = async () => {
24+
try {
25+
await saveConsents('custom', { uiSource: 'settings' })
26+
} catch (error) {
27+
toast.error(getErrorMessage(error, 'Could not save your cookie preferences'))
28+
}
29+
}
30+
31+
return (
32+
<SettingsSection label='Cookies'>
33+
<div className='flex flex-col gap-3'>
34+
<ConsentPreferences onChange={commit} />
35+
<p className='text-[var(--text-muted)] text-small'>
36+
Your choice applies to this browser and is kept for 365 days. The{' '}
37+
<Link
38+
href='/cookie-policy'
39+
target='_blank'
40+
rel='noopener noreferrer'
41+
className={CONSENT_LINK_CLASS}
42+
>
43+
Cookie Policy
44+
</Link>{' '}
45+
lists what each category covers.
46+
</p>
47+
</div>
48+
</SettingsSection>
49+
)
50+
}
51+
52+
/** The cookies section, with the store it reads. */
53+
export function CookiePreferences() {
54+
return (
55+
<ConsentStoreProvider>
56+
<CookiePreferencesBody />
57+
</ConsentStoreProvider>
58+
)
59+
}
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+
}

apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx

Lines changed: 18 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,18 @@ import { Camera, Check, CircleInfo, Pencil } from '@sim/emcn/icons'
2020
import { createLogger } from '@sim/logger'
2121
import Image from 'next/image'
2222
import { useRouter } from 'next/navigation'
23-
import { requestJson } from '@/lib/api/client/request'
24-
import { telemetryContract } from '@/lib/api/contracts/telemetry'
23+
import { useQueryState } from 'nuqs'
2524
import { signOut, useSession } from '@/lib/auth/auth-client'
2625
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
2726
import { isHosted } from '@/lib/core/config/env-flags'
2827
import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone'
2928
import { getBaseUrl } from '@/lib/core/utils/urls'
3029
import { DeleteAccountModal } from '@/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal'
30+
import { PrivacyView } from '@/app/workspace/[workspaceId]/settings/components/general/components/privacy-view'
31+
import {
32+
generalViewParam,
33+
generalViewUrlKeys,
34+
} from '@/app/workspace/[workspaceId]/settings/components/general/search-params'
3135
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
3236
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
3337
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -92,6 +96,10 @@ export function General() {
9296
setName(profile.name)
9397
}
9498

99+
const [view, setView] = useQueryState(generalViewParam.key, {
100+
...generalViewParam.parser,
101+
...generalViewUrlKeys,
102+
})
95103
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
96104
const resetPassword = useResetPassword()
97105

@@ -246,30 +254,16 @@ export function General() {
246254
}
247255
}
248256

249-
const handleTelemetryToggle = async (checked: boolean) => {
250-
if (checked !== settings?.telemetryEnabled && !updateSetting.isPending) {
251-
await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked })
252-
253-
if (checked) {
254-
if (typeof window !== 'undefined') {
255-
requestJson(telemetryContract, {
256-
body: {
257-
category: 'consent',
258-
action: 'enable_from_settings',
259-
timestamp: new Date().toISOString(),
260-
},
261-
}).catch(() => {})
262-
}
263-
}
264-
}
265-
}
266-
267257
const imageUrl = profilePictureUrl || profile?.image || brandConfig.logoUrl
268258

269259
if (isLoading) {
270260
return null
271261
}
272262

263+
if (view === 'privacy') {
264+
return <PrivacyView onBack={() => setView(null)} />
265+
}
266+
273267
const actions: SettingsAction[] = [
274268
...(isHosted
275269
? [
@@ -563,16 +557,12 @@ export function General() {
563557
<SettingsSection label='Privacy'>
564558
<div className='flex flex-col gap-3'>
565559
<div className='flex items-center justify-between'>
566-
<Label htmlFor='telemetry'>Allow anonymous telemetry</Label>
567-
<Switch
568-
id='telemetry'
569-
checked={settings?.telemetryEnabled ?? true}
570-
onCheckedChange={handleTelemetryToggle}
571-
/>
560+
<Label>Privacy settings</Label>
561+
<Chip onClick={() => setView('privacy')}>Manage</Chip>
572562
</div>
573563
<p className='text-[var(--text-muted)] text-small'>
574-
We use OpenTelemetry to collect anonymous usage data to improve Sim. You can opt-out
575-
at any time.
564+
Choose what Sim may collect about how you use it — anonymous telemetry, and which
565+
cookies this browser allows.
576566
</p>
577567
</div>
578568
</SettingsSection>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { parseAsStringLiteral } from 'nuqs/server'
2+
3+
/**
4+
* The sub-view open inside General. Only `privacy` exists today; the literal
5+
* parser means an unknown value from an old link falls back to General rather
6+
* than rendering an empty detail pane.
7+
*/
8+
export const generalViewParam = {
9+
key: 'view',
10+
parser: parseAsStringLiteral(['privacy'] as const),
11+
} as const
12+
13+
/** Opening the sub-view is a destination — Back should return to General. */
14+
export const generalViewUrlKeys = {
15+
history: 'push',
16+
clearOnDefault: true,
17+
} as const

0 commit comments

Comments
 (0)