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),