diff --git a/apps/sim/app/_shell/consent/consent-preferences.tsx b/apps/sim/app/_shell/consent/consent-preferences.tsx index 36946c22814..faf620b1767 100644 --- a/apps/sim/app/_shell/consent/consent-preferences.tsx +++ b/apps/sim/app/_shell/consent/consent-preferences.tsx @@ -42,15 +42,29 @@ const CONSENT_CATEGORY_COPY: Record = { }, } satisfies Record +/** The runtime's category union, without re-declaring it. */ +type ConsentCategoryName = Parameters['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() @@ -77,8 +91,14 @@ export function ConsentPreferences() { setSelectedConsent(type.name, checked)} + disabled={type.disabled || disabled} + onCheckedChange={(checked) => { + setSelectedConsent(type.name, checked) + onChange?.({ + name: type.name, + revert: () => setSelectedConsent(type.name, !checked), + }) + }} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 1a64d6206a7..2d98d12c5d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -44,6 +44,8 @@ const SECTION_ALIASES: Readonly> = { const TOP_LEVEL_REDIRECTS: Readonly 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> = { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index feaccc33dd8..9ba369ef92a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -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' @@ -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) ) @@ -146,9 +142,7 @@ export function SettingsPage({ section }: SettingsPageProps) { ? 'general' : normalizedSection === 'mothership' && !sessionLoading && !isAdminRole ? 'general' - : normalizedSection === 'privacy' && !isHosted - ? 'general' - : normalizedSection + : normalizedSection const organizationId = hostContext.hostOrganizationId const meta = getSettingsSectionMeta(effectiveSection) @@ -163,7 +157,6 @@ export function SettingsPage({ section }: SettingsPageProps) { return ( {effectiveSection === 'general' && } - {effectiveSection === 'privacy' && } {effectiveSection === 'desktop' && } {effectiveSection === 'browser' && } {effectiveSection === 'terminal' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences.test.tsx new file mode 100644 index 00000000000..c45862f166e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/components/cookie-preferences.test.tsx @@ -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 ( +