-
Notifications
You must be signed in to change notification settings - Fork 3.8k
refactor(consent): fold cookie preferences into General > Privacy #6837
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
125 changes: 125 additions & 0 deletions
125
...orkspace/[workspaceId]/settings/components/general/components/cookie-preferences.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| /** | ||
| * @vitest-environment jsdom | ||
| */ | ||
| import type { ReactNode } from 'react' | ||
| import { act } from 'react' | ||
| import { createRoot, type Root } from 'react-dom/client' | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const { mockUseConsentManager, mockSaveConsents, mockToastError, mockRevert, lastProps } = | ||
| vi.hoisted(() => ({ | ||
| mockUseConsentManager: vi.fn(), | ||
| mockSaveConsents: vi.fn(), | ||
| mockToastError: vi.fn(), | ||
| mockRevert: vi.fn(), | ||
| lastProps: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock('@sim/emcn', () => ({ toast: { success: vi.fn(), error: mockToastError } })) | ||
| vi.mock('@c15t/nextjs/headless', () => ({ useConsentManager: mockUseConsentManager })) | ||
| vi.mock('@/app/_shell/consent/consent-store-provider', () => ({ | ||
| ConsentStoreProvider: ({ children }: { children: ReactNode }) => children, | ||
| })) | ||
| vi.mock('@/app/_shell/consent/consent-preferences', () => ({ | ||
| CONSENT_LINK_CLASS: 'link', | ||
| ConsentPreferences: (props: { | ||
| onChange?: (change: { name: string; revert: () => void }) => void | ||
| disabled?: boolean | ||
| }) => { | ||
| lastProps(props) | ||
| return ( | ||
| <button | ||
| type='button' | ||
| data-testid='toggle' | ||
| disabled={props.disabled} | ||
| onClick={() => props.onChange?.({ name: 'measurement', revert: mockRevert })} | ||
| /> | ||
| ) | ||
| }, | ||
| })) | ||
|
|
||
| import { CookiePreferences } from '@/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences' | ||
|
|
||
| let root: Root | null = null | ||
|
|
||
| /** The props the switch list was last rendered with. */ | ||
| function props() { | ||
| return lastProps.mock.calls.at(-1)?.[0] as { disabled?: boolean } | ||
| } | ||
|
|
||
| function render() { | ||
| ;(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(<CookiePreferences />)) | ||
| return container | ||
| } | ||
|
|
||
| /** Resolves the pending save on demand, so the in-flight state is observable. */ | ||
| function deferredSave() { | ||
| let resolve!: () => void | ||
| let reject!: (error: Error) => void | ||
| mockSaveConsents.mockReturnValue( | ||
| new Promise<void>((res, rej) => { | ||
| resolve = res | ||
| reject = rej | ||
| }) | ||
| ) | ||
| return { resolve, reject } | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| mockUseConsentManager.mockReturnValue({ saveConsents: mockSaveConsents }) | ||
| mockSaveConsents.mockResolvedValue(undefined) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| act(() => root?.unmount()) | ||
| root = null | ||
| vi.clearAllMocks() | ||
| }) | ||
|
|
||
| describe('CookiePreferences', () => { | ||
| it('commits on every toggle, matching the telemetry switch beside it', async () => { | ||
| const container = render() | ||
|
|
||
| expect(mockSaveConsents).not.toHaveBeenCalled() | ||
| await act(async () => { | ||
| container.querySelector<HTMLButtonElement>('[data-testid="toggle"]')?.click() | ||
| }) | ||
|
|
||
| // `saveConsents('custom')` reads `selectedConsents` from the store at call | ||
| // time and the switch's `setSelectedConsent` write is synchronous, so the | ||
| // value this toggle staged is the one committed. | ||
| expect(mockSaveConsents).toHaveBeenCalledWith('custom', { uiSource: 'settings' }) | ||
| }) | ||
|
|
||
| it('locks the switches while a commit is in flight, so two toggles cannot race', async () => { | ||
| const pending = deferredSave() | ||
| const container = render() | ||
|
|
||
| act(() => { | ||
| container.querySelector<HTMLButtonElement>('[data-testid="toggle"]')?.click() | ||
| }) | ||
| expect(props().disabled).toBe(true) | ||
|
|
||
| await act(async () => { | ||
| pending.resolve() | ||
| }) | ||
| expect(props().disabled).toBe(false) | ||
| }) | ||
|
|
||
| it('puts the switch back when the commit fails', async () => { | ||
| mockSaveConsents.mockRejectedValue(new Error('network down')) | ||
| const container = render() | ||
|
|
||
| await act(async () => { | ||
| container.querySelector<HTMLButtonElement>('[data-testid="toggle"]')?.click() | ||
| }) | ||
|
|
||
| expect(mockRevert).toHaveBeenCalledTimes(1) | ||
| expect(mockToastError).toHaveBeenCalled() | ||
| expect(props().disabled).toBe(false) | ||
| }) | ||
| }) |
71 changes: 71 additions & 0 deletions
71
...app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| 'use client' | ||
|
|
||
| import { useState } from 'react' | ||
| import { useConsentManager } from '@c15t/nextjs/headless' | ||
| import { toast } from '@sim/emcn' | ||
| import { getErrorMessage } from '@sim/utils/errors' | ||
| import Link from 'next/link' | ||
| import { CONSENT_LINK_CLASS, ConsentPreferences } from '@/app/_shell/consent/consent-preferences' | ||
| import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' | ||
| import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' | ||
|
|
||
| /** | ||
| * Body of the cookies section, split out because it reads the consent store, | ||
| * which only exists below the provider. | ||
| */ | ||
| function CookiePreferencesBody() { | ||
| const { saveConsents } = useConsentManager() | ||
| const [saving, setSaving] = useState(false) | ||
|
|
||
| /** | ||
| * Each toggle commits, matching the telemetry switch directly above it — one | ||
| * interaction model on the page, and no "unsaved consent" state to reason | ||
| * about. The banner stages instead, because its footer owns the commit. | ||
| * | ||
| * The switches lock while a commit is in flight, as the telemetry switch does | ||
| * on its own mutation. Without that, two quick toggles race: each save sends | ||
| * the whole `selectedConsents` snapshot, so the slower request can land last | ||
| * and overwrite the newer choice. A failed commit puts the switch back rather | ||
| * than leaving it showing a preference that was never recorded. | ||
| */ | ||
| const commit = async ({ revert }: { revert: () => void }) => { | ||
| setSaving(true) | ||
| try { | ||
| await saveConsents('custom', { uiSource: 'settings' }) | ||
| } catch (error) { | ||
| revert() | ||
| toast.error(getErrorMessage(error, 'Could not save your cookie preferences')) | ||
| } finally { | ||
| setSaving(false) | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <SettingsSection label='Cookies'> | ||
| <div className='flex flex-col gap-3'> | ||
| <ConsentPreferences onChange={commit} disabled={saving} /> | ||
| <p className='text-[var(--text-muted)] text-small'> | ||
| Your choice applies to this browser and is kept for 365 days. The{' '} | ||
| <Link | ||
| href='/cookie-policy' | ||
| target='_blank' | ||
| rel='noopener noreferrer' | ||
| className={CONSENT_LINK_CLASS} | ||
| > | ||
| Cookie Policy | ||
| </Link>{' '} | ||
| lists what each category covers. | ||
| </p> | ||
| </div> | ||
| </SettingsSection> | ||
| ) | ||
| } | ||
|
|
||
| /** The cookies section, with the store it reads. */ | ||
| export function CookiePreferences() { | ||
| return ( | ||
| <ConsentStoreProvider> | ||
| <CookiePreferencesBody /> | ||
| </ConsentStoreProvider> | ||
| ) | ||
| } |
71 changes: 71 additions & 0 deletions
71
apps/sim/app/workspace/[workspaceId]/settings/components/general/components/privacy-view.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| 'use client' | ||
|
|
||
| import { ArrowLeft, Label, Switch } from '@sim/emcn' | ||
| import { requestJson } from '@/lib/api/client/request' | ||
| import { telemetryContract } from '@/lib/api/contracts/telemetry' | ||
| import { isHosted } from '@/lib/core/config/env-flags' | ||
| import { CookiePreferences } from '@/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences' | ||
| import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' | ||
| import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' | ||
| import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings' | ||
|
|
||
| interface PrivacyViewProps { | ||
| onBack: () => void | ||
| } | ||
|
|
||
| /** | ||
| * Privacy sub-view of General — the one place a signed-in user changes what Sim | ||
| * may collect. | ||
| * | ||
| * A detail sub-view rather than its own settings tab: the nav is already long, | ||
| * and a tab a user opens once and never returns to is the wrong weight for it. | ||
| * Telemetry shows everywhere; cookies only on the hosted service, which is the | ||
| * only deployment that sets them. | ||
| */ | ||
| export function PrivacyView({ onBack }: PrivacyViewProps) { | ||
| const { data: settings } = useGeneralSettings() | ||
| const updateSetting = useUpdateGeneralSetting() | ||
|
|
||
| const handleTelemetryToggle = async (checked: boolean) => { | ||
| if (checked === settings?.telemetryEnabled || updateSetting.isPending) return | ||
|
|
||
| await updateSetting.mutateAsync({ key: 'telemetryEnabled', value: checked }) | ||
|
|
||
| if (checked && typeof window !== 'undefined') { | ||
| requestJson(telemetryContract, { | ||
| body: { | ||
| category: 'consent', | ||
| action: 'enable_from_settings', | ||
| timestamp: new Date().toISOString(), | ||
| }, | ||
| }).catch(() => {}) | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <SettingsPanel | ||
| back={{ text: 'General', icon: ArrowLeft, onSelect: onBack }} | ||
| title='Privacy' | ||
| description='Control what Sim collects about how you use it.' | ||
| > | ||
| <SettingsSection label='Telemetry'> | ||
| <div className='flex flex-col gap-3'> | ||
| <div className='flex items-center justify-between'> | ||
| <Label htmlFor='telemetry'>Allow anonymous telemetry</Label> | ||
| <Switch | ||
| id='telemetry' | ||
| checked={settings?.telemetryEnabled ?? true} | ||
| onCheckedChange={handleTelemetryToggle} | ||
| /> | ||
| </div> | ||
|
waleedlatif1 marked this conversation as resolved.
|
||
| <p className='text-[var(--text-muted)] text-small'> | ||
| We use OpenTelemetry to collect anonymous usage data to improve Sim. You can opt-out at | ||
| any time. | ||
| </p> | ||
| </div> | ||
| </SettingsSection> | ||
|
|
||
| {isHosted && <CookiePreferences />} | ||
| </SettingsPanel> | ||
| ) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.