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
@@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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. */
Expand Down
25 changes: 22 additions & 3 deletions apps/sim/lib/credential-groups/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 3 additions & 6 deletions apps/sim/lib/knowledge/connectors/member-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@ import {
inviteCredentialGroupEnrollment,
} from '@/lib/credential-groups/enrollments'
import {
type CredentialGroupProvider,
getCredentialGroupProviderFromProviderId,
findCredentialGroupProviderFromProviderId,
getCredentialGroupProviderId,
isCredentialGroupProvider,
isCredentialGroupStandardOAuthProvider,
Expand Down Expand Up @@ -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`
Expand Down
Loading