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
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
'general',
'billing',
'secrets',
'connected-accounts',
'organization',
'usage',
'access-control',
Expand Down Expand Up @@ -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' })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,6 +165,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'browser' && <Browser />}
{effectiveSection === 'terminal' && <Terminal />}
{effectiveSection === 'secrets' && <Secrets />}
{effectiveSection === 'connected-accounts' && organizationId && (
<OrganizationConnectedAccounts organizationId={organizationId} />
)}
{effectiveSection === 'access-control' && organizationId && (
<AccessControl
organizationId={organizationId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('unified settings navigation', () => {
{ 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' },
Expand Down Expand Up @@ -84,6 +85,7 @@ describe('unified settings navigation', () => {
expect(idsForSection('organization')).toEqual([
'organization',
'usage',
'connected-accounts',
'access-control',
'audit-logs',
'whitelabeling',
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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()
}
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) &&
Expand Down
3 changes: 3 additions & 0 deletions apps/sim/components/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe('settings navigation boundaries', () => {
'organization',
'usage',
'secrets',
'connected-accounts',
'custom-tools',
'mcp',
'apikeys',
Expand Down Expand Up @@ -297,6 +298,7 @@ describe('settings navigation boundaries', () => {
'access-control',
'audit-logs',
'billing',
'connected-accounts',
'data-drains',
'data-retention',
'organization',
Expand All @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/components/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export interface SettingsNavigationItem<Section extends string = string> {
}

export type UnifiedSettingsSection =
| 'connected-accounts'
| 'general'
| 'desktop'
| 'browser'
Expand Down Expand Up @@ -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',
Expand Down
70 changes: 70 additions & 0 deletions apps/sim/lib/settings/application/workspace-section-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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: {
Expand All @@ -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,
}))
Expand Down Expand Up @@ -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: {} })
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/settings/application/workspace-section-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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),
Expand Down
Loading