Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions apps/sim/app/_shell/consent/consent-preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,29 @@ const CONSENT_CATEGORY_COPY: Record<string, ConsentCategoryCopy | undefined> = {
},
} satisfies Record<ConsentCategory, ConsentCategoryCopy>

/** The runtime's category union, without re-declaring it. */
type ConsentCategoryName = Parameters<ReturnType<typeof useConsentManager>['setSelectedConsent']>[0]

interface ConsentPreferencesProps {
/**
* Called after a switch stages its new value, for a surface that commits per
* toggle. `revert` puts the category back, for a commit that then fails. The
* banner omits this and commits from its own footer instead.
*/
onChange?: (change: { name: ConsentCategoryName; revert: () => void }) => void
/** Locks every switch, e.g. while a commit is in flight. */
disabled?: boolean
}

/**
* 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.
* to `selectedConsents`; whether that is then committed is the caller's, via
* {@link ConsentPreferencesProps.onChange}.
*
* Must be rendered inside a `ConsentManagerProvider`.
* Must be rendered inside a `ConsentStoreProvider`.
*/
export function ConsentPreferences() {
export function ConsentPreferences({ onChange, disabled = false }: ConsentPreferencesProps) {
const { consents, selectedConsents, setSelectedConsent, getDisplayedConsents } =
useConsentManager()

Expand All @@ -77,8 +91,14 @@ export function ConsentPreferences() {
<Switch
id={inputId}
checked={selectedConsents[type.name] ?? consents[type.name] ?? false}
disabled={type.disabled}
onCheckedChange={(checked) => setSelectedConsent(type.name, checked)}
disabled={type.disabled || disabled}
onCheckedChange={(checked) => {
setSelectedConsent(type.name, checked)
onChange?.({
name: type.name,
revert: () => setSelectedConsent(type.name, !checked),
})
}}
/>
</li>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const SECTION_ALIASES: Readonly<Record<string, SettingsSection>> = {
const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => string>> = {
integrations: (workspaceId) => `/workspace/${workspaceId}/integrations`,
skills: (workspaceId) => `/workspace/${workspaceId}/skills`,
// Cookie preferences moved into General; keep old links working.
privacy: (workspaceId) => `/workspace/${workspaceId}/settings/general?view=privacy`,
}

const WORKSPACE_SECTION_MAP: Partial<Record<SettingsSection, WorkspaceSettingsSection>> = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { useEffect } from 'react'
import dynamic from 'next/dynamic'
import { usePostHog } from 'posthog-js/react'
import { useSession } from '@/lib/auth/auth-client'
import { isHosted } from '@/lib/core/config/env-flags'
import { captureEvent } from '@/lib/posthog/client'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general'
Expand Down Expand Up @@ -105,9 +104,6 @@ const DataRetentionSettings = dynamic(() =>
const DataDrainsSettings = dynamic(() =>
import('@/ee/data-drains/components/data-drains-settings').then((m) => m.DataDrainsSettings)
)
const Privacy = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/privacy/privacy').then((m) => m.Privacy)
)
const Desktop = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/desktop/desktop').then((m) => m.Desktop)
)
Expand Down Expand Up @@ -146,9 +142,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
? 'general'
: normalizedSection === 'mothership' && !sessionLoading && !isAdminRole
? 'general'
: normalizedSection === 'privacy' && !isHosted
? 'general'
: normalizedSection
: normalizedSection
Comment thread
waleedlatif1 marked this conversation as resolved.
const organizationId = hostContext.hostOrganizationId
const meta = getSettingsSectionMeta(effectiveSection)

Expand All @@ -163,7 +157,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
return (
<SettingsSectionProvider section={effectiveSection} meta={meta ?? undefined}>
{effectiveSection === 'general' && <General />}
{effectiveSection === 'privacy' && <Privacy />}
{effectiveSection === 'desktop' && <Desktop />}
{effectiveSection === 'browser' && <Browser />}
{effectiveSection === 'terminal' && <Terminal />}
Expand Down
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)
})
})
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>
)
}
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>
Comment thread
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>
)
}
Loading
Loading