From 39438f83c020923777aba9ccc5db5c9109a5fbf9 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 17:53:15 -0700 Subject: [PATCH 1/2] fix(search): expose provider configuration updates in Sources --- .../self-hosting/integrations-oauth.mdx | 2 +- apps/docs/content/docs/search/github.mdx | 2 +- ...rganization-integrations-settings.test.tsx | 87 ++++++++++++++++++- .../organization-integrations-settings.tsx | 33 +++++-- .../organization-account-providers.tsx | 20 +---- .../organization-account-options.ts | 20 +++++ apps/sim/lib/credential-groups/service.ts | 4 +- 7 files changed, 140 insertions(+), 28 deletions(-) create mode 100644 apps/sim/lib/credential-groups/organization-account-options.ts diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index acc1fa6c884..3ca4d246c6f 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -203,7 +203,7 @@ Keep **Expire user authorization tokens** enabled so Sim receives the refresh to Complete the installation through [the GitHub Search source setup](/search/github#add-a-repository). -If you replace a deployment's GitHub App, an organization admin must first open **Settings → Connected accounts → Providers → Update configurations**. This applies the deployment's current App configuration to the existing providers while preserving their saved identities. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration. +If you replace a deployment's GitHub App, an organization admin must first open **Settings → Sources → Update configurations**. When Search is disabled, this action is under **Settings → Connected accounts → Providers**. This applies the deployment's current App configuration to the existing providers while preserving their saved identities. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration. diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index 023068d0962..c4e661e1c7f 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -96,7 +96,7 @@ This is an installation plus personal authorization flow. GitHub Search does not | Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | | Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. | | Account authorization did not complete | Start the connection again from Sim. If it repeats, contact your organization admin or Sim support. For self-hosted Sim, check the [App callback and credentials](/platform/self-hosting/integrations-oauth#github-search). | -| Update GitHub in Connected accounts before connecting this source | An organization admin must select **Settings → Connected accounts → Providers → Update configurations**, then reconnect GitHub. | +| Update GitHub using Update configurations in organization settings before connecting this source | An organization admin must select **Settings → Sources → Update configurations**, then reconnect GitHub. | | Indexed files no longer appear | Confirm your own repository access, App repository selection, and connection status. Installation-indexed content is also withheld when GitHub cannot verify current access; retry once GitHub is available. | | Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. | | Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. | diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx index 9ea2460bd81..b128245018e 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ people: vi.fn(), invite: vi.fn(), refetch: vi.fn(), + update: vi.fn(), + updatePending: false, })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, @@ -21,6 +23,7 @@ vi.mock( ) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccounts: mocks.accounts, + useUpdateOrganizationAccounts: () => ({ mutate: mocks.update, isPending: mocks.updatePending }), useOrganizationAccountPeople: mocks.people, useInviteOrganizationAccountPeople: () => ({ mutateAsync: mocks.invite, reset: vi.fn() }), useResendOrganizationAccountInvitation: () => ({}), @@ -37,10 +40,12 @@ describe('organization integration invitations', () => { beforeEach(() => { vi.clearAllMocks() vi.spyOn(toast, 'success').mockReturnValue('toast-id') + vi.spyOn(toast, 'error').mockReturnValue('toast-id') + mocks.updatePending = false vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } }) mocks.accounts.mockReturnValue({ - data: { credentialGroup: { id: 'group-a' } }, + data: { credentialGroup: { id: 'group-a', options: [] } }, error: null, refetch: mocks.refetch, }) @@ -93,7 +98,7 @@ describe('organization integration invitations', () => { it('keeps provider setup as the default and sends manual invitations from People to this org', async () => { await render() expect(container.textContent).toContain('Provider setup') - expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + expect(mocks.accounts).toHaveBeenLastCalledWith('org-a') expect(mocks.people).not.toHaveBeenCalled() await click('People') @@ -119,6 +124,79 @@ describe('organization integration invitations', () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) + it('refreshes saved provider identities from Sources and reports the outcome', async () => { + mocks.accounts.mockReturnValue({ + data: { + credentialGroup: { + id: 'group-a', + options: [ + { + id: 'github-option', + provider: 'github-repositories', + label: 'Engineering', + required: true, + }, + { + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: false, + slackBotCredentialId: 'slack-bot', + requiredScopes: ['search:read'], + }, + ], + }, + }, + error: null, + }) + mocks.update.mockImplementationOnce((_input, { onSuccess }) => onSuccess()) + await render() + await click('Update configurations') + expect(mocks.update).toHaveBeenCalledWith( + { + organizationId: 'org-a', + groupId: 'group-a', + update: { + options: [ + { + id: 'github-option', + provider: 'github-repositories', + label: 'Engineering', + required: true, + }, + { + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: false, + slackBotCredentialId: 'slack-bot', + requiredScopes: ['search:read'], + }, + ], + }, + }, + expect.any(Object) + ) + expect(toast.success).toHaveBeenCalledWith('Provider configurations updated') + + mocks.update.mockImplementationOnce((_input, { onError }) => + onError(new Error('Update denied')) + ) + await click('Update configurations') + expect(toast.error).toHaveBeenCalledWith('Update denied') + + mocks.updatePending = true + await render() + expect(findButton('Update configurations')).toBeDisabled() + await click('Update configurations') + expect(mocks.update).toHaveBeenCalledTimes(2) + }) + + it('does not offer a configuration update without saved providers', async () => { + await render() + expect(container.textContent).not.toContain('Update configurations') + }) + it('opens People directly from the saved URL', async () => { await render('?tab=people') expect(container.textContent).toContain('Request connections') @@ -137,7 +215,10 @@ describe('organization integration invitations', () => { await click('Request connections') expect(document.querySelector('[role="dialog"]')).toBeNull() - mocks.accounts.mockReturnValue({ data: { credentialGroup: { id: 'group-a' } }, error: null }) + mocks.accounts.mockReturnValue({ + data: { credentialGroup: { id: 'group-a', options: [] } }, + error: null, + }) await render('?tab=people') expect(container.textContent).not.toContain('Loading connected accounts') expect(findButton('Request connections')).not.toBeDisabled() diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx index f0508c816a1..82055465391 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx @@ -1,7 +1,8 @@ 'use client' -import { Chip, ChipSwitch } from '@sim/emcn' +import { Chip, ChipSwitch, toast } from '@sim/emcn' import { useQueryState } from 'nuqs' +import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup' import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' @@ -10,7 +11,10 @@ import { SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' -import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' +import { + useOrganizationAccounts, + useUpdateOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' export function OrganizationIntegrationsSettings() { const { organization, viewer } = useOrganizationContext() @@ -18,9 +22,23 @@ export function OrganizationIntegrationsSettings() { organizationIntegrationsTabParam.key, organizationIntegrationsTabParam.parser ) - const accounts = useOrganizationAccounts( - viewer.isAdmin && tab === 'people' ? organization.id : undefined - ) + const accounts = useOrganizationAccounts(viewer.isAdmin ? organization.id : undefined) + const update = useUpdateOrganizationAccounts() + const group = accounts.data?.credentialGroup + const updateConfigurations = () => { + if (!group || update.isPending) return + update.mutate( + { + organizationId: organization.id, + groupId: group.id, + update: { options: getOrganizationAccountUpdateOptions(group) }, + }, + { + onSuccess: () => toast.success('Provider configurations updated'), + onError: (error) => toast.error(error.message), + } + ) + } if (!viewer.isAdmin) return null return ( @@ -35,6 +53,11 @@ export function OrganizationIntegrationsSettings() { { value: 'people', label: 'People' }, ]} /> + {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( + + Update configurations + + )} {tab === 'providers' && } {tab === 'people' && ( diff --git a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx index fb6cf96561a..60ecdd1c5dc 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-providers.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-providers.tsx @@ -12,12 +12,10 @@ import { } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' -import type { - OrganizationAccountsSettings, - UpdateOrganizationAccountsBody, -} from '@/lib/api/contracts/organization-accounts' +import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' import { MANAGED_MCP_CONNECTORS } from '@/lib/credential-groups/managed-mcp-connectors' +import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' import { type CredentialGroupProvider, getCredentialGroupProviderService, @@ -60,19 +58,7 @@ export function OrganizationAccountProviders({ const addMcp = useAddOrganizationAccountMcpProvider() const removeMcp = useRemoveOrganizationAccountMcpProvider() const pending = update.isPending || addMcp.isPending || removeMcp.isPending - const options: NonNullable = group.options.map( - (option) => { - const common = { id: option.id, label: option.label, required: option.required } - return option.provider === 'slack' - ? { - ...common, - provider: 'slack', - slackBotCredentialId: option.slackBotCredentialId, - requiredScopes: option.requiredScopes, - } - : { ...common, provider: option.provider } - } - ) + const options = getOrganizationAccountUpdateOptions(group) const updateConfigurations = () => { if (pending) return update.mutate( diff --git a/apps/sim/lib/credential-groups/organization-account-options.ts b/apps/sim/lib/credential-groups/organization-account-options.ts new file mode 100644 index 00000000000..4a9c0ccc4b6 --- /dev/null +++ b/apps/sim/lib/credential-groups/organization-account-options.ts @@ -0,0 +1,20 @@ +import type { + OrganizationAccountsSettings, + UpdateOrganizationAccountsBody, +} from '@/lib/api/contracts/organization-accounts' + +export function getOrganizationAccountUpdateOptions( + group: NonNullable +): NonNullable { + return group.options.map((option) => { + const common = { id: option.id, label: option.label, required: option.required } + return option.provider === 'slack' + ? { + ...common, + provider: 'slack', + slackBotCredentialId: option.slackBotCredentialId, + requiredScopes: option.requiredScopes, + } + : { ...common, provider: option.provider } + }) +} diff --git a/apps/sim/lib/credential-groups/service.ts b/apps/sim/lib/credential-groups/service.ts index d0d766f4e57..78715ba0884 100644 --- a/apps/sim/lib/credential-groups/service.ts +++ b/apps/sim/lib/credential-groups/service.ts @@ -306,7 +306,9 @@ export async function ensureWorkspaceAccountsGroup( ) { throw new OrchestrationError( 'validation', - `Update ${preparedOption.label} in Connected accounts before connecting this source` + scope.kind === 'organization' + ? `Update ${preparedOption.label} using Update configurations in organization settings before connecting this source` + : `Update ${preparedOption.label} in Connected accounts before connecting this source` ) } return existing From 45df56e39d0ecf021a566c9ab67acc7eaf5742d5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 9 Sep 2026 17:57:59 -0700 Subject: [PATCH 2/2] chore(search): document provider refresh option mapping --- apps/sim/lib/credential-groups/organization-account-options.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/credential-groups/organization-account-options.ts b/apps/sim/lib/credential-groups/organization-account-options.ts index 4a9c0ccc4b6..8716bdbc7d8 100644 --- a/apps/sim/lib/credential-groups/organization-account-options.ts +++ b/apps/sim/lib/credential-groups/organization-account-options.ts @@ -3,6 +3,7 @@ import type { UpdateOrganizationAccountsBody, } from '@/lib/api/contracts/organization-accounts' +/** Preserves option identities and custom Slack scopes while the server refreshes managed OAuth policies. */ export function getOrganizationAccountUpdateOptions( group: NonNullable ): NonNullable {