diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index 2a5498831fb..034c639a179 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -45,6 +45,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({ 'general', 'billing', 'secrets', + 'connected-accounts', 'organization', 'usage', 'access-control', @@ -166,6 +167,21 @@ describe('WorkspaceSettingsSectionPage', () => { expect(mockSectionPrefetch).not.toHaveBeenCalled() }) + it('gates direct Connected accounts links before loading the settings panel', async () => { + mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'redirect-general' }) + + await expect(WorkspaceSettingsSectionPage(pageProps('connected-accounts'))).rejects.toThrow( + 'NEXT_REDIRECT:/workspace/workspace-b/settings/general' + ) + expect(mockAuthorizeSection).toHaveBeenCalledWith({ + workspaceId: 'workspace-b', + userId: 'viewer-a', + section: 'connected-accounts', + }) + expect(mockGetHostContext).not.toHaveBeenCalled() + expect(mockGetQueryClient).not.toHaveBeenCalled() + }) + it('redirects unavailable visible-catalog sections to General', async () => { mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'redirect-general' }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 00db817fd3b..1247284618e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -29,6 +29,11 @@ const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').th const Secrets = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) ) +const OrganizationConnectedAccounts = dynamic(() => + import('@/ee/credential-groups/components/organization-connected-accounts').then( + (m) => m.OrganizationConnectedAccounts + ) +) const Sandboxes = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes').then( (m) => m.Sandboxes @@ -160,6 +165,9 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'browser' && } {effectiveSection === 'terminal' && } {effectiveSection === 'secrets' && } + {effectiveSection === 'connected-accounts' && organizationId && ( + + )} {effectiveSection === 'access-control' && organizationId && ( { { id: 'organization', label: 'Members', section: 'organization' }, { id: 'usage', label: 'Usage tracking', section: 'organization' }, { id: 'secrets', label: 'Secrets', section: 'workspace' }, + { id: 'connected-accounts', label: 'Connected accounts', section: 'organization' }, { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, { id: 'mcp', label: 'MCP tools', section: 'workspace' }, { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, @@ -84,6 +85,7 @@ describe('unified settings navigation', () => { expect(idsForSection('organization')).toEqual([ 'organization', 'usage', + 'connected-accounts', 'access-control', 'audit-logs', 'whitelabeling', @@ -144,9 +146,9 @@ describe('resolveSettingsSection', () => { expect(resolveSettingsSection('')).toBeNull() }) - it('does not expose credential group management through workspace settings', () => { + it('resolves organization connected accounts in the unified settings shell', () => { expect(resolveSettingsSection('credential-groups')).toBeNull() - expect(resolveSettingsSection('connected-accounts')).toBeNull() + expect(resolveSettingsSection('connected-accounts')?.id).toBe('connected-accounts') }) it('carries the catalog label through as the header title', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx index aa586968481..118bae39c16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx @@ -186,6 +186,21 @@ function expectWorkspaceLinks() { } describe('workspace SettingsSidebar organization rollout', () => { + it('hides Connected accounts when credential groups are disabled', () => { + hostContext.features = { ...hostContext.features!, credentialGroups: false } + renderSidebar() + + expect(workspaceLink('connected-accounts')).toBeNull() + expectWorkspaceLinks() + }) + + it('does not offer organization accounts in a personal workspace', () => { + hostContext.hostOrganizationId = null + renderSidebar() + + expect(workspaceLink('connected-accounts')).toBeNull() + }) + it.each([false, undefined])( 'keeps organization settings in the workspace for an admin when rollout is %s', (enabled) => { @@ -196,6 +211,7 @@ describe('workspace SettingsSidebar organization rollout', () => { expect(workspaceLink('billing')).toHaveTextContent('Subscription') expect(workspaceLink('usage')).toHaveTextContent('Usage tracking') expect(workspaceLink('sso')).toHaveTextContent('Single sign-on') + expect(workspaceLink('connected-accounts')).toHaveTextContent('Connected accounts') expect(container.querySelector('a[href^="/o/"]')).toBeNull() expectWorkspaceLinks() } @@ -205,6 +221,8 @@ describe('workspace SettingsSidebar organization rollout', () => { hostContext.features = undefined renderSidebar() + expect(workspaceLink('connected-accounts')).toBeNull() + expect(workspaceLink('organization')).toHaveTextContent('Members') expect(workspaceLink('billing')).toHaveTextContent('Subscription') expect(container.querySelector('a[href^="/o/"]')).toBeNull() @@ -221,7 +239,7 @@ describe('workspace SettingsSidebar organization rollout', () => { expect(links).toHaveLength(1) expect(links[0]).toHaveAttribute('href', '/o/host-org/settings/members') expect(links[0]).toHaveTextContent('Organization') - for (const section of ['organization', 'billing', 'usage', 'sso']) { + for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) { expect(workspaceLink(section)).toBeNull() } expectWorkspaceLinks() @@ -233,7 +251,7 @@ describe('workspace SettingsSidebar organization rollout', () => { renderSidebar() expect(workspaceLink('organization')).toHaveTextContent('Members') - for (const section of ['billing', 'usage', 'sso']) { + for (const section of ['billing', 'usage', 'sso', 'connected-accounts']) { expect(workspaceLink(section)).toBeNull() } expect(container.querySelector('a[href^="/o/"]')).toBeNull() @@ -259,7 +277,7 @@ describe('workspace SettingsSidebar organization rollout', () => { renderSidebar() expect(container.querySelector('a[href^="/o/"]')).toBeNull() - for (const section of ['organization', 'billing', 'usage', 'sso']) { + for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) { expect(workspaceLink(section)).toBeNull() } expectWorkspaceLinks() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index 5a4012ca158..642a8ea2679 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -149,6 +149,14 @@ export function SettingsSidebar({ const navigationItems = useMemo(() => { return allNavigationItems.filter((item) => { + if (item.id === 'connected-accounts') { + return Boolean( + hostContext.hostOrganizationId && + isOrgAdminOrOwner && + hostContext.features?.credentialGroups && + !hostContext.features?.organizationSearch + ) + } if ( hostContext.hostOrganizationId && ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) && diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index 8af92604619..c495c028a33 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -105,6 +105,7 @@ describe('settings navigation boundaries', () => { 'organization', 'usage', 'secrets', + 'connected-accounts', 'custom-tools', 'mcp', 'apikeys', @@ -297,6 +298,7 @@ describe('settings navigation boundaries', () => { 'access-control', 'audit-logs', 'billing', + 'connected-accounts', 'data-drains', 'data-retention', 'organization', @@ -314,6 +316,7 @@ describe('settings navigation boundaries', () => { expect(UNIFIED_TO_ORGANIZATION_SECTION).toEqual({ organization: 'members', billing: 'billing', + 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', 'audit-logs': 'audit-logs', sso: 'sso', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 10b55a5c9f9..57ba6cb85e5 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -96,6 +96,7 @@ export interface SettingsNavigationItem
{ } export type UnifiedSettingsSection = + | 'connected-accounts' | 'general' | 'desktop' | 'browser' @@ -531,6 +532,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] { label: 'Connected accounts', icon: GridOffset, + unified: { + id: 'connected-accounts', + description: 'Manage accounts shared with your organization’s workflows.', + group: 'organization', + order: 1, + organizationSection: 'connected-accounts', + }, planes: { account: { id: 'connected-accounts', diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index 8c2e4f8a05f..ca9793b6b91 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -31,6 +31,8 @@ const mocks = vi.hoisted(() => ({ isForkingAvailableForWorkspace: vi.fn(), isOrganizationOnEnterprisePlan: vi.fn(), isOrganizationSettingsSectionAvailable: vi.fn(), + isScopedCredentialGroupsAvailable: vi.fn(), + isKnowledgeMemberAccessAvailable: vi.fn(), isPlatformAdmin: vi.fn(), resolveVerifiedUserAccessControlContext: vi.fn(), resolveWorkspaceNavigation: vi.fn(), @@ -43,6 +45,7 @@ vi.mock('@/components/settings/navigation', () => ({ UNIFIED_TO_ORGANIZATION_SECTION: { organization: 'members', billing: 'billing', + 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', }, UNIFIED_TO_WORKSPACE_SECTION: { @@ -60,6 +63,12 @@ vi.mock('@/lib/billing/core/subscription', () => ({ vi.mock('@/lib/core/config/deployment-shape', () => ({ getDeploymentShape: () => mocks.deploymentShape, })) +vi.mock('@/lib/credential-groups/scoped-availability', () => ({ + isScopedCredentialGroupsAvailable: mocks.isScopedCredentialGroupsAvailable, +})) +vi.mock('@/lib/knowledge/access/availability', () => ({ + isKnowledgeMemberAccessAvailable: mocks.isKnowledgeMemberAccessAvailable, +})) vi.mock('@/lib/organizations/settings-access', () => ({ canOpenOrganizationSettingsSection: mocks.canOpenOrganizationSettingsSection, })) @@ -114,6 +123,8 @@ describe('authorizeWorkspaceSettingsSection', () => { mocks.isForkingAvailableForWorkspace.mockResolvedValue(true) mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(true) mocks.isOrganizationSettingsSectionAvailable.mockReturnValue(true) + mocks.isScopedCredentialGroupsAvailable.mockResolvedValue(true) + mocks.isKnowledgeMemberAccessAvailable.mockResolvedValue(false) mocks.isPlatformAdmin.mockResolvedValue(true) mocks.canOpenOrganizationSettingsSection.mockResolvedValue(true) mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ config: {} }) @@ -238,6 +249,65 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() }) + it.each([ + { groups: true, search: false, allowed: true }, + { groups: false, search: false, allowed: false }, + { groups: true, search: true, allowed: false }, + { groups: false, search: true, allowed: false }, + ])( + 'gates Connected accounts with organization groups=$groups and search=$search', + async ({ groups, search, allowed }) => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.isScopedCredentialGroupsAvailable.mockResolvedValue(groups) + mocks.isKnowledgeMemberAccessAvailable.mockResolvedValue(search) + + await expect(authorize('connected-accounts')).resolves.toEqual( + allowed ? { allowed: true } : { allowed: false, disposition: 'redirect-general' } + ) + expect(mocks.canOpenOrganizationSettingsSection).toHaveBeenCalledWith( + 'organization-1', + 'viewer-1', + 'connected-accounts' + ) + expect(mocks.isScopedCredentialGroupsAvailable).toHaveBeenCalledWith({ + kind: 'organization', + organizationId: 'organization-1', + }) + if (groups) { + expect(mocks.isKnowledgeMemberAccessAvailable).toHaveBeenCalledWith({ + organizationId: 'organization-1', + }) + } + expect(mocks.isOrganizationOnEnterprisePlan).not.toHaveBeenCalled() + } + ) + + it('does not infer organization admin access from workspace admin access', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false) + + await expect(authorize('connected-accounts')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + expect(mocks.isScopedCredentialGroupsAvailable).not.toHaveBeenCalled() + }) + + it('requires a host organization for Connected accounts', async () => { + await expect(authorize('connected-accounts')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() + }) + + it('propagates feature lookup failures instead of opening Connected accounts', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.isKnowledgeMemberAccessAvailable.mockRejectedValue(new Error('Feature lookup failed')) + + await expect(authorize('connected-accounts')).rejects.toThrow('Feature lookup failed') + }) + it('requires current organization access and plan availability for enterprise sections', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false) diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 632370d2815..90e00290cf5 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -12,6 +12,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { getDeploymentShape } from '@/lib/core/config/deployment-shape' import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access' import { isPlatformAdmin } from '@/lib/permissions/super-user' +import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access' import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' @@ -86,6 +87,14 @@ async function canOpenOrganizationSection( return input.section === 'billing' && workspace.billedAccountUserId === input.userId } + if (organizationSection === 'connected-accounts') { + return authorizeOrganizationSettingsSection({ + organizationId: workspace.organizationId, + userId: input.userId, + section: organizationSection, + }) + } + const needsEnterprisePlan = organizationSection !== 'members' && organizationSection !== 'billing' const [canOpenSection, isEnterpriseOrganization] = await Promise.all([ canOpenOrganizationSettingsSection(workspace.organizationId, input.userId, organizationSection),