diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.test.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.test.ts new file mode 100644 index 00000000000..6a7c8cd4701 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.test.ts @@ -0,0 +1,41 @@ +/** + * `supported` is what decides whether the Access field renders at all, and it is + * exactly `connectorMemberGroupProvider(...) !== null`. A connector that declares + * `permissionScopedListing` crawls once per member, so resolving it to `null` + * hides per-member access from the one kind of connector that has it. + * + * @vitest-environment node + */ +import { assert, describe, expect, it, vi } from 'vitest' + +vi.mock('@/hooks/queries/credential-groups', () => ({ useCredentialGroups: vi.fn() })) + +import { canConnectPersonally } from '@/lib/sim-search/connectors' +import { connectorMemberGroupProvider } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options' +import { getAllConnectorMeta } from '@/connectors/registry' + +const permissionScopedOAuthConnectors = Object.entries(getAllConnectorMeta()).filter(([, meta]) => + canConnectPersonally(meta) +) + +describe('connectorMemberGroupProvider', () => { + /** A registry-driven `it.each([])` runs zero cases, so the suite must not be empty. */ + it('has permission-scoped OAuth connectors to check', () => { + expect(permissionScopedOAuthConnectors.length).toBeGreaterThan(0) + }) + + it.each(permissionScopedOAuthConnectors)( + 'resolves a credential-group provider for %s', + (_id, meta) => { + expect(connectorMemberGroupProvider(meta)).not.toBeNull() + } + ) + + it('returns null for a connector that does not crawl per member', () => { + const plain = Object.values(getAllConnectorMeta()).find( + (meta) => meta.auth.mode === 'oauth' && !canConnectPersonally(meta) + ) + assert(plain) + expect(connectorMemberGroupProvider(plain)).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts index 6806f4c8af4..b3a37ee395e 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-member-group-options.ts @@ -3,9 +3,9 @@ import { useMemo } from 'react' import type { ComboboxOption } from '@sim/emcn' import { - type CredentialGroupStandardOAuthProvider, + type CredentialGroupProvider, + findCredentialGroupProviderFromProviderId, getCredentialGroupProviderId, - getCredentialGroupStandardOAuthProviderFromProviderId, isCredentialGroupProvider, } from '@/lib/credential-groups/providers' import type { ConnectorMeta } from '@/connectors/types' @@ -31,15 +31,11 @@ export function decodeConnectorMemberGroupOption( } /** The credential-group provider that collects accounts for this connector, if any. */ -function connectorMemberGroupProvider( +export function connectorMemberGroupProvider( connectorConfig: ConnectorMeta -): CredentialGroupStandardOAuthProvider | null { +): CredentialGroupProvider | null { if (connectorConfig.auth.mode !== 'oauth' || !connectorConfig.permissionScopedListing) return null - try { - return getCredentialGroupStandardOAuthProviderFromProviderId(connectorConfig.auth.provider) - } catch { - return null - } + return findCredentialGroupProviderFromProviderId(connectorConfig.auth.provider) } /** The config fields a per-member connector hides: its listing caps, which the server clears. */ diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts index 09638d94ef2..5464fb4af15 100644 --- a/apps/sim/lib/credential-groups/providers.ts +++ b/apps/sim/lib/credential-groups/providers.ts @@ -262,12 +262,31 @@ export function getCredentialGroupProviderId(provider: CredentialGroupProvider): return getCredentialGroupProviderService(provider).providerId } +/** + * The credential group provider collecting accounts for an OAuth provider id, + * or `null` when none does. + * + * Every provider counts here, not only the standard OAuth ones: Slack is + * collected through a custom bot app, so resolving against + * {@link CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS} misses it. Callers that + * treat a miss as an ordinary answer take this rather than catching the throw + * from {@link getCredentialGroupProviderFromProviderId}, so the choice of which + * provider set counts is made in one place instead of at each call site. + */ +export function findCredentialGroupProviderFromProviderId( + providerId: string +): CredentialGroupProvider | null { + return ( + CREDENTIAL_GROUP_PROVIDER_IDS.find( + (candidate) => getCredentialGroupProviderId(candidate) === providerId + ) ?? null + ) +} + export function getCredentialGroupProviderFromProviderId( providerId: string ): CredentialGroupProvider { - const provider = CREDENTIAL_GROUP_PROVIDER_IDS.find( - (candidate) => getCredentialGroupProviderId(candidate) === providerId - ) + const provider = findCredentialGroupProviderFromProviderId(providerId) if (!provider) throw new Error(`Unsupported managed credential provider: ${providerId}`) return provider } diff --git a/apps/sim/lib/knowledge/connectors/member-provisioning.ts b/apps/sim/lib/knowledge/connectors/member-provisioning.ts index 3b9fa1ec37e..9f12819e76f 100644 --- a/apps/sim/lib/knowledge/connectors/member-provisioning.ts +++ b/apps/sim/lib/knowledge/connectors/member-provisioning.ts @@ -17,8 +17,7 @@ import { inviteCredentialGroupEnrollment, } from '@/lib/credential-groups/enrollments' import { - type CredentialGroupProvider, - getCredentialGroupProviderFromProviderId, + findCredentialGroupProviderFromProviderId, getCredentialGroupProviderId, isCredentialGroupProvider, isCredentialGroupStandardOAuthProvider, @@ -115,10 +114,8 @@ export async function provisionKnowledgeConnectorMembersBinding(input: { throw new OrchestrationError('validation', 'Only an OAuth connector can sync per member') } const providerId = connectorMeta.auth.provider - let provider: CredentialGroupProvider - try { - provider = getCredentialGroupProviderFromProviderId(providerId) - } catch { + const provider = findCredentialGroupProviderFromProviderId(providerId) + if (!provider) { throw new OrchestrationError( 'validation', `${connectorMeta.name} accounts cannot be collected through a Credential Group yet`