Skip to content

Commit 5f05cca

Browse files
fix(settings): show connected accounts without search
1 parent 0bba808 commit 5f05cca

9 files changed

Lines changed: 147 additions & 5 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
4545
'general',
4646
'billing',
4747
'secrets',
48+
'connected-accounts',
4849
'organization',
4950
'usage',
5051
'access-control',
@@ -166,6 +167,21 @@ describe('WorkspaceSettingsSectionPage', () => {
166167
expect(mockSectionPrefetch).not.toHaveBeenCalled()
167168
})
168169

170+
it('gates direct Connected accounts links before loading the settings panel', async () => {
171+
mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'redirect-general' })
172+
173+
await expect(WorkspaceSettingsSectionPage(pageProps('connected-accounts'))).rejects.toThrow(
174+
'NEXT_REDIRECT:/workspace/workspace-b/settings/general'
175+
)
176+
expect(mockAuthorizeSection).toHaveBeenCalledWith({
177+
workspaceId: 'workspace-b',
178+
userId: 'viewer-a',
179+
section: 'connected-accounts',
180+
})
181+
expect(mockGetHostContext).not.toHaveBeenCalled()
182+
expect(mockGetQueryClient).not.toHaveBeenCalled()
183+
})
184+
169185
it('redirects unavailable visible-catalog sections to General', async () => {
170186
mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'redirect-general' })
171187

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').th
2929
const Secrets = dynamic(() =>
3030
import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets)
3131
)
32+
const OrganizationConnectedAccounts = dynamic(() =>
33+
import('@/ee/credential-groups/components/organization-connected-accounts').then(
34+
(m) => m.OrganizationConnectedAccounts
35+
)
36+
)
3237
const Sandboxes = dynamic(() =>
3338
import('@/app/workspace/[workspaceId]/settings/components/sandboxes/sandboxes').then(
3439
(m) => m.Sandboxes
@@ -160,6 +165,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
160165
{effectiveSection === 'browser' && <Browser />}
161166
{effectiveSection === 'terminal' && <Terminal />}
162167
{effectiveSection === 'secrets' && <Secrets />}
168+
{effectiveSection === 'connected-accounts' && organizationId && (
169+
<OrganizationConnectedAccounts organizationId={organizationId} />
170+
)}
163171
{effectiveSection === 'access-control' && organizationId && (
164172
<AccessControl
165173
organizationId={organizationId}

apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('unified settings navigation', () => {
3333
{ id: 'organization', label: 'Members', section: 'organization' },
3434
{ id: 'usage', label: 'Usage tracking', section: 'organization' },
3535
{ id: 'secrets', label: 'Secrets', section: 'workspace' },
36+
{ id: 'connected-accounts', label: 'Connected accounts', section: 'organization' },
3637
{ id: 'custom-tools', label: 'Custom tools', section: 'workspace' },
3738
{ id: 'mcp', label: 'MCP tools', section: 'workspace' },
3839
{ id: 'apikeys', label: 'Sim API keys', section: 'workspace' },
@@ -84,6 +85,7 @@ describe('unified settings navigation', () => {
8485
expect(idsForSection('organization')).toEqual([
8586
'organization',
8687
'usage',
88+
'connected-accounts',
8789
'access-control',
8890
'audit-logs',
8991
'whitelabeling',
@@ -144,9 +146,9 @@ describe('resolveSettingsSection', () => {
144146
expect(resolveSettingsSection('')).toBeNull()
145147
})
146148

147-
it('does not expose credential group management through workspace settings', () => {
149+
it('resolves organization connected accounts in the unified settings shell', () => {
148150
expect(resolveSettingsSection('credential-groups')).toBeNull()
149-
expect(resolveSettingsSection('connected-accounts')).toBeNull()
151+
expect(resolveSettingsSection('connected-accounts')?.id).toBe('connected-accounts')
150152
})
151153

152154
it('carries the catalog label through as the header title', () => {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,21 @@ function expectWorkspaceLinks() {
186186
}
187187

188188
describe('workspace SettingsSidebar organization rollout', () => {
189+
it('hides Connected accounts when credential groups are disabled', () => {
190+
hostContext.features = { ...hostContext.features!, credentialGroups: false }
191+
renderSidebar()
192+
193+
expect(workspaceLink('connected-accounts')).toBeNull()
194+
expectWorkspaceLinks()
195+
})
196+
197+
it('does not offer organization accounts in a personal workspace', () => {
198+
hostContext.hostOrganizationId = null
199+
renderSidebar()
200+
201+
expect(workspaceLink('connected-accounts')).toBeNull()
202+
})
203+
189204
it.each([false, undefined])(
190205
'keeps organization settings in the workspace for an admin when rollout is %s',
191206
(enabled) => {
@@ -196,6 +211,7 @@ describe('workspace SettingsSidebar organization rollout', () => {
196211
expect(workspaceLink('billing')).toHaveTextContent('Subscription')
197212
expect(workspaceLink('usage')).toHaveTextContent('Usage tracking')
198213
expect(workspaceLink('sso')).toHaveTextContent('Single sign-on')
214+
expect(workspaceLink('connected-accounts')).toHaveTextContent('Connected accounts')
199215
expect(container.querySelector('a[href^="/o/"]')).toBeNull()
200216
expectWorkspaceLinks()
201217
}
@@ -205,6 +221,8 @@ describe('workspace SettingsSidebar organization rollout', () => {
205221
hostContext.features = undefined
206222
renderSidebar()
207223

224+
expect(workspaceLink('connected-accounts')).toBeNull()
225+
208226
expect(workspaceLink('organization')).toHaveTextContent('Members')
209227
expect(workspaceLink('billing')).toHaveTextContent('Subscription')
210228
expect(container.querySelector('a[href^="/o/"]')).toBeNull()
@@ -221,7 +239,7 @@ describe('workspace SettingsSidebar organization rollout', () => {
221239
expect(links).toHaveLength(1)
222240
expect(links[0]).toHaveAttribute('href', '/o/host-org/settings/members')
223241
expect(links[0]).toHaveTextContent('Organization')
224-
for (const section of ['organization', 'billing', 'usage', 'sso']) {
242+
for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) {
225243
expect(workspaceLink(section)).toBeNull()
226244
}
227245
expectWorkspaceLinks()
@@ -233,7 +251,7 @@ describe('workspace SettingsSidebar organization rollout', () => {
233251
renderSidebar()
234252

235253
expect(workspaceLink('organization')).toHaveTextContent('Members')
236-
for (const section of ['billing', 'usage', 'sso']) {
254+
for (const section of ['billing', 'usage', 'sso', 'connected-accounts']) {
237255
expect(workspaceLink(section)).toBeNull()
238256
}
239257
expect(container.querySelector('a[href^="/o/"]')).toBeNull()
@@ -259,7 +277,7 @@ describe('workspace SettingsSidebar organization rollout', () => {
259277
renderSidebar()
260278

261279
expect(container.querySelector('a[href^="/o/"]')).toBeNull()
262-
for (const section of ['organization', 'billing', 'usage', 'sso']) {
280+
for (const section of ['organization', 'billing', 'usage', 'sso', 'connected-accounts']) {
263281
expect(workspaceLink(section)).toBeNull()
264282
}
265283
expectWorkspaceLinks()

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ export function SettingsSidebar({
149149

150150
const navigationItems = useMemo(() => {
151151
return allNavigationItems.filter((item) => {
152+
if (item.id === 'connected-accounts') {
153+
return Boolean(
154+
hostContext.hostOrganizationId &&
155+
isOrgAdminOrOwner &&
156+
hostContext.features?.credentialGroups &&
157+
!hostContext.features?.organizationSearch
158+
)
159+
}
152160
if (
153161
hostContext.hostOrganizationId &&
154162
ORGANIZATION_PLANE_UNIFIED_SECTIONS.has(item.id) &&

apps/sim/components/settings/navigation.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ describe('settings navigation boundaries', () => {
105105
'organization',
106106
'usage',
107107
'secrets',
108+
'connected-accounts',
108109
'custom-tools',
109110
'mcp',
110111
'apikeys',
@@ -297,6 +298,7 @@ describe('settings navigation boundaries', () => {
297298
'access-control',
298299
'audit-logs',
299300
'billing',
301+
'connected-accounts',
300302
'data-drains',
301303
'data-retention',
302304
'organization',
@@ -314,6 +316,7 @@ describe('settings navigation boundaries', () => {
314316
expect(UNIFIED_TO_ORGANIZATION_SECTION).toEqual({
315317
organization: 'members',
316318
billing: 'billing',
319+
'connected-accounts': 'connected-accounts',
317320
'access-control': 'access-control',
318321
'audit-logs': 'audit-logs',
319322
sso: 'sso',

apps/sim/components/settings/navigation.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface SettingsNavigationItem<Section extends string = string> {
9696
}
9797

9898
export type UnifiedSettingsSection =
99+
| 'connected-accounts'
99100
| 'general'
100101
| 'desktop'
101102
| 'browser'
@@ -531,6 +532,13 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[]
531532
{
532533
label: 'Connected accounts',
533534
icon: GridOffset,
535+
unified: {
536+
id: 'connected-accounts',
537+
description: 'Manage accounts shared with your organization’s workflows.',
538+
group: 'organization',
539+
order: 1,
540+
organizationSection: 'connected-accounts',
541+
},
534542
planes: {
535543
account: {
536544
id: 'connected-accounts',

apps/sim/lib/settings/application/workspace-section-access.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const mocks = vi.hoisted(() => ({
3131
isForkingAvailableForWorkspace: vi.fn(),
3232
isOrganizationOnEnterprisePlan: vi.fn(),
3333
isOrganizationSettingsSectionAvailable: vi.fn(),
34+
isScopedCredentialGroupsAvailable: vi.fn(),
35+
isKnowledgeMemberAccessAvailable: vi.fn(),
3436
isPlatformAdmin: vi.fn(),
3537
resolveVerifiedUserAccessControlContext: vi.fn(),
3638
resolveWorkspaceNavigation: vi.fn(),
@@ -43,6 +45,7 @@ vi.mock('@/components/settings/navigation', () => ({
4345
UNIFIED_TO_ORGANIZATION_SECTION: {
4446
organization: 'members',
4547
billing: 'billing',
48+
'connected-accounts': 'connected-accounts',
4649
'access-control': 'access-control',
4750
},
4851
UNIFIED_TO_WORKSPACE_SECTION: {
@@ -60,6 +63,12 @@ vi.mock('@/lib/billing/core/subscription', () => ({
6063
vi.mock('@/lib/core/config/deployment-shape', () => ({
6164
getDeploymentShape: () => mocks.deploymentShape,
6265
}))
66+
vi.mock('@/lib/credential-groups/scoped-availability', () => ({
67+
isScopedCredentialGroupsAvailable: mocks.isScopedCredentialGroupsAvailable,
68+
}))
69+
vi.mock('@/lib/knowledge/access/availability', () => ({
70+
isKnowledgeMemberAccessAvailable: mocks.isKnowledgeMemberAccessAvailable,
71+
}))
6372
vi.mock('@/lib/organizations/settings-access', () => ({
6473
canOpenOrganizationSettingsSection: mocks.canOpenOrganizationSettingsSection,
6574
}))
@@ -114,6 +123,8 @@ describe('authorizeWorkspaceSettingsSection', () => {
114123
mocks.isForkingAvailableForWorkspace.mockResolvedValue(true)
115124
mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(true)
116125
mocks.isOrganizationSettingsSectionAvailable.mockReturnValue(true)
126+
mocks.isScopedCredentialGroupsAvailable.mockResolvedValue(true)
127+
mocks.isKnowledgeMemberAccessAvailable.mockResolvedValue(false)
117128
mocks.isPlatformAdmin.mockResolvedValue(true)
118129
mocks.canOpenOrganizationSettingsSection.mockResolvedValue(true)
119130
mocks.resolveVerifiedUserAccessControlContext.mockResolvedValue({ config: {} })
@@ -238,6 +249,65 @@ describe('authorizeWorkspaceSettingsSection', () => {
238249
expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled()
239250
})
240251

252+
it.each([
253+
{ groups: true, search: false, allowed: true },
254+
{ groups: false, search: false, allowed: false },
255+
{ groups: true, search: true, allowed: false },
256+
{ groups: false, search: true, allowed: false },
257+
])(
258+
'gates Connected accounts with organization groups=$groups and search=$search',
259+
async ({ groups, search, allowed }) => {
260+
mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS)
261+
mocks.isScopedCredentialGroupsAvailable.mockResolvedValue(groups)
262+
mocks.isKnowledgeMemberAccessAvailable.mockResolvedValue(search)
263+
264+
await expect(authorize('connected-accounts')).resolves.toEqual(
265+
allowed ? { allowed: true } : { allowed: false, disposition: 'redirect-general' }
266+
)
267+
expect(mocks.canOpenOrganizationSettingsSection).toHaveBeenCalledWith(
268+
'organization-1',
269+
'viewer-1',
270+
'connected-accounts'
271+
)
272+
expect(mocks.isScopedCredentialGroupsAvailable).toHaveBeenCalledWith({
273+
kind: 'organization',
274+
organizationId: 'organization-1',
275+
})
276+
if (groups) {
277+
expect(mocks.isKnowledgeMemberAccessAvailable).toHaveBeenCalledWith({
278+
organizationId: 'organization-1',
279+
})
280+
}
281+
expect(mocks.isOrganizationOnEnterprisePlan).not.toHaveBeenCalled()
282+
}
283+
)
284+
285+
it('does not infer organization admin access from workspace admin access', async () => {
286+
mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS)
287+
mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false)
288+
289+
await expect(authorize('connected-accounts')).resolves.toEqual({
290+
allowed: false,
291+
disposition: 'redirect-general',
292+
})
293+
expect(mocks.isScopedCredentialGroupsAvailable).not.toHaveBeenCalled()
294+
})
295+
296+
it('requires a host organization for Connected accounts', async () => {
297+
await expect(authorize('connected-accounts')).resolves.toEqual({
298+
allowed: false,
299+
disposition: 'redirect-general',
300+
})
301+
expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled()
302+
})
303+
304+
it('propagates feature lookup failures instead of opening Connected accounts', async () => {
305+
mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS)
306+
mocks.isKnowledgeMemberAccessAvailable.mockRejectedValue(new Error('Feature lookup failed'))
307+
308+
await expect(authorize('connected-accounts')).rejects.toThrow('Feature lookup failed')
309+
})
310+
241311
it('requires current organization access and plan availability for enterprise sections', async () => {
242312
mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS)
243313
mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false)

apps/sim/lib/settings/application/workspace-section-access.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
1212
import { getDeploymentShape } from '@/lib/core/config/deployment-shape'
1313
import { canOpenOrganizationSettingsSection } from '@/lib/organizations/settings-access'
1414
import { isPlatformAdmin } from '@/lib/permissions/super-user'
15+
import { authorizeOrganizationSettingsSection } from '@/lib/settings/application/organization-section-access'
1516
import { isCustomBlocksEligibleForOrganization } from '@/lib/workflows/custom-blocks/operations'
1617
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
1718
import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check'
@@ -86,6 +87,14 @@ async function canOpenOrganizationSection(
8687
return input.section === 'billing' && workspace.billedAccountUserId === input.userId
8788
}
8889

90+
if (organizationSection === 'connected-accounts') {
91+
return authorizeOrganizationSettingsSection({
92+
organizationId: workspace.organizationId,
93+
userId: input.userId,
94+
section: organizationSection,
95+
})
96+
}
97+
8998
const needsEnterprisePlan = organizationSection !== 'members' && organizationSection !== 'billing'
9099
const [canOpenSection, isEnterpriseOrganization] = await Promise.all([
91100
canOpenOrganizationSettingsSection(workspace.organizationId, input.userId, organizationSection),

0 commit comments

Comments
 (0)