From fdcbf89354c58f7330a2e2a8327501829dca9889 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 9 Sep 2026 15:52:05 -0700 Subject: [PATCH 1/3] improvement(settings): remove personal connected accounts page --- .../account/settings/[section]/page.test.tsx | 9 +- .../enroll/[token]/page.test.tsx | 10 +- .../credential-groups/enroll/[token]/page.tsx | 7 +- .../integrations/integrations.test.tsx | 6 +- .../integrations/integrations.tsx | 20 +-- .../settings/account-settings-renderer.tsx | 2 - .../components/settings/navigation.test.ts | 1 - apps/sim/components/settings/navigation.ts | 16 +-- .../organization-account-people.test.tsx | 106 +++----------- .../personal-organization-accounts.tsx | 132 ------------------ .../hooks/queries/organization-accounts.ts | 46 +----- 11 files changed, 51 insertions(+), 304 deletions(-) delete mode 100644 apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx diff --git a/apps/sim/app/account/settings/[section]/page.test.tsx b/apps/sim/app/account/settings/[section]/page.test.tsx index 838297805e3..bd2226565d6 100644 --- a/apps/sim/app/account/settings/[section]/page.test.tsx +++ b/apps/sim/app/account/settings/[section]/page.test.tsx @@ -52,7 +52,10 @@ describe('account settings legacy links', () => { ) }) - it('still rejects unknown sections', async () => { - await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND') - }) + it.each(['unknown', 'connected-accounts'])( + 'rejects unavailable sections: %s', + async (section) => { + await expect(AccountSettingsSectionPage(pageProps(section))).rejects.toThrow('NEXT_NOT_FOUND') + } + ) }) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx index ff95caa7120..3b01f425c50 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -148,7 +148,7 @@ describe('focused Search enrollment', () => { expect(mocks.read).toHaveBeenCalledWith({ principal, input: {} }) }) - it('keeps account-settings reconnect focused and returns to account settings', async () => { + it('keeps existing account reconnect links focused and returns to Sim', async () => { await render({ returnTo: 'accounts', optionId: 'site-two' }) expect(oauthLinks().map((link) => link.getAttribute('href'))).toEqual([ '/api/credential-groups/enroll/invitation/oauth/site-two?returnTo=accounts', @@ -156,9 +156,9 @@ describe('focused Search enrollment', () => { expect(document.querySelector('form')).toBeNull() expect( Array.from(document.querySelectorAll('a')) - .find((link) => link.textContent === 'Your connected accounts') + .find((link) => link.textContent === 'Open Sim') ?.getAttribute('href') - ).toBe('/account/settings/connected-accounts') + ).toBe('/home') }) it('lets an account owner deliberately reconnect an active grant before reporting completion', async () => { @@ -189,12 +189,12 @@ describe('focused Search enrollment', () => { }) mocks.read.mockResolvedValue({ enrollment, canSearch }) await render({ returnTo: 'search', optionId: 'site-two' }) - const label = canSearch ? 'Return to Search' : 'Your connected accounts' + const label = canSearch ? 'Return to Search' : 'Open Sim' expect( Array.from(document.querySelectorAll('a')) .find((link) => link.textContent === label) ?.getAttribute('href') - ).toBe(canSearch ? '/o/canonical-org/search' : '/account/settings/connected-accounts') + ).toBe(canSearch ? '/o/canonical-org/search' : '/home') } ) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 57febd8e801..f5504cef26a 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -3,7 +3,6 @@ import { Chip, ChipLink } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' import { redirect } from 'next/navigation' -import { getAccountSettingsHref } from '@/components/settings/navigation' import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' import type { ResourceOwner } from '@/lib/core/resource-scope' @@ -184,10 +183,8 @@ export default async function CredentialGroupEnrollmentPage({ const canReturnToSearch = returnToSearch && ('canSearch' in enrollmentResult ? enrollmentResult.canSearch : !principal.organizationId) - const returnHref = canReturnToSearch - ? searchReturnPath(principal) - : getAccountSettingsHref('connected-accounts') - const returnLabel = canReturnToSearch ? 'Return to Search' : 'Your connected accounts' + const returnHref = canReturnToSearch ? searchReturnPath(principal) : APP_ENTRY_PATH + const returnLabel = canReturnToSearch ? 'Return to Search' : 'Open Sim' if (!enrollment) return diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx index ecdfb02e5ba..7588a765136 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx @@ -166,7 +166,7 @@ describe('organization integrations role and source paths', () => { expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) - it('keeps Slack return actions alongside the standard account and source actions', async () => { + it('keeps Slack return actions alongside source management', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, @@ -177,7 +177,7 @@ describe('organization integrations role and source paths', () => { ) ) - expect(document.body.textContent).toContain('Your accounts') + expect(document.body.textContent).not.toContain('Your accounts') expect(document.body.textContent).toContain('Manage sources') expect(buttons('slack-return')).toHaveLength(1) }) @@ -353,7 +353,7 @@ describe('organization integrations role and source paths', () => { expect( document.querySelector('a[href="/o/organization-a/settings/integrations"]') ).toHaveTextContent('Manage sources') - expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).not.toBeNull() + expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).toBeNull() expect(buttons('Add source')).toHaveLength(0) expect(buttons('Manage')).toHaveLength(0) expect(document.querySelector('[aria-label$="source actions"]')).toBeNull() diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx index 23c0d287e5c..ae8918681b1 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx @@ -2,7 +2,6 @@ import { useMemo } from 'react' import { Chip, ChipLink } from '@sim/emcn' -import { getAccountSettingsHref } from '@/components/settings/navigation' import type { ResourceScope } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' import { @@ -129,15 +128,16 @@ export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegr description='Connect your tools for Sim Search' tabs={TABS} action={ -
- Your accounts - {viewer.isAdmin && ( - Manage sources - )} - {slackOnboarding && ( - - )} -
+ (viewer.isAdmin || slackOnboarding) && ( +
+ {viewer.isAdmin && ( + Manage sources + )} + {slackOnboarding && ( + + )} +
+ ) } >
diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 2112668c818..7cefeba5d47 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -6,7 +6,6 @@ import { usePostHog } from 'posthog-js/react' import type { AccountSettingsSection } from '@/components/settings/navigation' import { captureEvent } from '@/lib/posthog/client' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' -import { PersonalOrganizationAccounts } from '@/ee/credential-groups/components/personal-organization-accounts' const Billing = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/billing/billing').then( @@ -40,7 +39,6 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp captureEvent(posthog, 'settings_tab_viewed', { plane: 'account', section }) }, [posthog, section]) - if (section === 'connected-accounts') return if (section === 'general') return if (section === 'billing') return if (section === 'api-keys') return diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index c495c028a33..37ded7d6390 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -128,7 +128,6 @@ describe('settings navigation boundaries', () => { 'general', 'billing', 'api-keys', - 'connected-accounts', 'admin', 'mothership', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 57ba6cb85e5..4d95c5461af 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -35,13 +35,7 @@ import { organizationRoutes } from '@/lib/navigation/paths' export type SettingsPlane = 'account' | 'selfhost' | 'workspace' -export type AccountSettingsSection = - | 'connected-accounts' - | 'general' - | 'billing' - | 'api-keys' - | 'admin' - | 'mothership' +export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' /** * Settings a self-hoster needs from the managed service: their profile, what @@ -539,14 +533,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] order: 1, organizationSection: 'connected-accounts', }, - planes: { - account: { - id: 'connected-accounts', - group: 'account', - order: 3, - description: 'Manage accounts you have contributed to organizations.', - }, - }, }, { label: 'Custom tools', diff --git a/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx b/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx index 8b1cc2321da..99f2877eb61 100644 --- a/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx +++ b/apps/sim/ee/credential-groups/components/organization-account-people.test.tsx @@ -2,48 +2,24 @@ import { act } from 'react' import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ people: vi.fn(), resend: vi.fn(), revoke: vi.fn(), - disconnect: vi.fn(), reset: vi.fn(), resendState: { isPending: false, error: null as Error | null }, revokeState: { isPending: false, error: null as Error | null }, })) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccountPeople: mocks.people, - usePersonalOrganizationAccounts: () => ({ - data: { - pages: [ - { - accounts: [ - { - credentialId: 'credential-1', - displayName: 'Personal Gmail', - organizationName: 'Example organization', - providerId: 'gmail', - status: 'active', - canReconnect: true, - }, - ], - }, - ], - }, - }), useResendOrganizationAccountInvitation: () => ({ ...mocks.resendState, mutate: mocks.resend }), useRevokeOrganizationAccountEnrollment: () => ({ ...mocks.revokeState, mutate: mocks.revoke, reset: mocks.reset, }), - useReconnectPersonalOrganizationAccount: () => ({}), - useDisconnectPersonalOrganizationAccount: () => ({ - mutate: mocks.disconnect, - reset: mocks.reset, - }), })) vi.mock('@/ee/credential-groups/components/organization-account-invite-modal', () => ({ OrganizationAccountInviteModal: () => null, @@ -51,7 +27,6 @@ vi.mock('@/ee/credential-groups/components/organization-account-invite-modal', ( import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' -import { PersonalOrganizationAccounts } from '@/ee/credential-groups/components/personal-organization-accounts' let root: Root let container: HTMLDivElement @@ -111,11 +86,6 @@ async function selectPersonAction(label: string) { return action } -async function openConfirmation(label: string) { - if (label === 'Revoke') await selectPersonAction(label) - else await act(async () => button(container, label).click()) -} - async function renderPeople(searchConnection?: { optionId: string; providerName: string }) { await act(async () => root.render( @@ -156,58 +126,28 @@ it('keeps the compact People rows and resends from the actions menu', async () = ) }) -const cases = [ - { - label: 'Revoke', - component: , - mutation: mocks.revoke, - target: 'person@example.com', - input: { organizationId: 'organization-1', enrollmentId: 'enrollment-1' }, - }, - { - label: 'Disconnect', - component: , - mutation: mocks.disconnect, - target: 'Personal Gmail', - input: 'credential-1', - }, -] as const - -describe.each(cases)( - '$label organization account access', - ({ label, component, mutation, target, input }) => { - it('requires confirmation, allows cancellation, and never submits from an unfocused Enter', async () => { - await act(async () => - root.render( - - - {component} - - - ) - ) - await openConfirmation(label) - let dialog = document.querySelector('[role="dialog"]') - expect(dialog?.textContent).toContain(target) - expect(mutation).not.toHaveBeenCalled() - await act(async () => - dialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - ) - expect(mutation).not.toHaveBeenCalled() - if (!dialog) throw new Error('Missing confirmation dialog') - await act(async () => button(dialog, 'Cancel').click()) - expect(mutation).not.toHaveBeenCalled() - await openConfirmation(label) - dialog = document.querySelector('[role="dialog"]') - if (!dialog) throw new Error('Missing confirmation dialog') - await act(async () => button(dialog, label).click()) - expect(mutation).toHaveBeenCalledExactlyOnceWith( - input, - expect.objectContaining({ onSuccess: expect.any(Function) }) - ) - }) - } -) +it('requires revoke confirmation, allows cancellation, and never submits from an unfocused Enter', async () => { + await renderPeople() + await selectPersonAction('Revoke') + let dialog = document.querySelector('[role="dialog"]') + expect(dialog?.textContent).toContain('person@example.com') + expect(mocks.revoke).not.toHaveBeenCalled() + await act(async () => + dialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(mocks.revoke).not.toHaveBeenCalled() + if (!dialog) throw new Error('Missing confirmation dialog') + await act(async () => button(dialog, 'Cancel').click()) + expect(mocks.revoke).not.toHaveBeenCalled() + await selectPersonAction('Revoke') + dialog = document.querySelector('[role="dialog"]') + if (!dialog) throw new Error('Missing confirmation dialog') + await act(async () => button(dialog, 'Revoke').click()) + expect(mocks.revoke).toHaveBeenCalledExactlyOnceWith( + { organizationId: 'organization-1', enrollmentId: 'enrollment-1' }, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) +}) it('restores the existing People URL search and requests server-filtered results', async () => { mocks.people.mockReturnValue({ data: { pages: [{ enrollments: [] }] }, hasNextPage: false }) diff --git a/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx b/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx deleted file mode 100644 index 1d19d8885de..00000000000 --- a/apps/sim/ee/credential-groups/components/personal-organization-accounts.tsx +++ /dev/null @@ -1,132 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Chip, ChipConfirmModal, ChipModalError, ChipTag, toast } from '@sim/emcn' -import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { - useDisconnectPersonalOrganizationAccount, - usePersonalOrganizationAccounts, - useReconnectPersonalOrganizationAccount, -} from '@/hooks/queries/organization-accounts' - -export function PersonalOrganizationAccounts() { - const accounts = usePersonalOrganizationAccounts() - const reconnect = useReconnectPersonalOrganizationAccount() - const disconnect = useDisconnectPersonalOrganizationAccount() - const [disconnectingId, setDisconnectingId] = useState(null) - const disconnectingAccount = accounts.data?.pages - .flatMap((page) => page.accounts) - .find((account) => account.credentialId === disconnectingId) - const pending = reconnect.isPending || disconnect.isPending - const error = reconnect.error ?? disconnect.error - return ( - -
-

- Manage accounts you connected to organizations. Disconnecting stops new indexing and - workflows that use the account, and removes Search access that depends on it. -

- {error && ( -

- {error.message} -

- )} - {accounts.error ? ( - void accounts.refetch()} - /> - ) : accounts.isPending ? ( -

Loading your accounts…

- ) : ( - accounts.data?.pages - .flatMap((page) => page.accounts) - .map((account) => ( - - {account.enrollmentStatus === 'revoked' ? 'Access revoked' : account.status} - - } - trailing={ -
- - reconnect.mutate(account.credentialId, { - onSuccess: ({ invitationLink }) => { - window.location.assign(invitationLink) - }, - }) - } - > - Reconnect - - { - disconnect.reset() - setDisconnectingId(account.credentialId) - }} - > - Disconnect - -
- } - /> - )) - )} - {accounts.data?.pages[0]?.accounts.length === 0 && ( -

- You haven’t contributed accounts to an organization yet. Use your invitation link to get - started. -

- )} - {accounts.hasNextPage && ( -
- void accounts.fetchNextPage()} - > - Load more - -
- )} - {disconnectingAccount && ( - { - if (!open && !disconnect.isPending) setDisconnectingId(null) - }} - title={`Disconnect ${disconnectingAccount.displayName}`} - text={`${disconnectingAccount.organizationName} will stop indexing and running workflows with this account. You will lose Search access that depends on this connection.`} - defaultAction='none' - confirm={{ - label: 'Disconnect', - pendingLabel: 'Disconnecting…', - pending: disconnect.isPending, - variant: 'destructive', - onClick: () => - disconnect.mutate(disconnectingAccount.credentialId, { - onSuccess: () => { - setDisconnectingId(null) - toast.success('Account disconnected') - }, - }), - }} - > - {disconnect.error?.message} - - )} -
-
- ) -} diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index fce40bb80ae..ea781965c97 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -14,7 +14,6 @@ import { addOrganizationAccountMcpProviderContract, type ConfigureOrganizationMcpBody, configureOrganizationMcpContract, - disconnectPersonalOrganizationAccountContract, type EnsureOrganizationAccountsBody, ensureOrganizationAccountsContract, getOrganizationAccountsContract, @@ -24,11 +23,9 @@ import { type InviteOrganizationAccountPeopleBody, inviteOrganizationAccountPeopleContract, listOrganizationAccountPeopleContract, - listPersonalOrganizationAccountsContract, type OrganizationAccountPeopleQuery, type RemoveOrganizationAccountMcpProviderParams, type ResendOrganizationAccountInvitationQuery, - reconnectPersonalOrganizationAccountContract, removeOrganizationAccountMcpProviderContract, resendOrganizationAccountInvitationContract, revokeOrganizationAccountEnrollmentContract, @@ -45,7 +42,6 @@ export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 export const organizationAccountsKeys = { all: ['organization-accounts'] as const, - personal: () => [...organizationAccountsKeys.all, 'personal'] as const, workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, workspace: (workspaceId?: string) => [...organizationAccountsKeys.workspaces(), workspaceId ?? ''] as const, @@ -117,7 +113,6 @@ export function useConfigureOrganizationMcp() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), ]), }) } @@ -144,7 +139,6 @@ export function useUpdateOrganizationAccounts() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), queryClient.invalidateQueries({ queryKey: slackSearchKeys.organizationManifests(organizationId), }), @@ -294,12 +288,7 @@ export function useRevokeOrganizationAccountEnrollment() { params: { id: organizationId, enrollmentId }, }), onSuccess: (_, { organizationId }) => - Promise.all([ - queryClient.invalidateQueries({ - queryKey: organizationAccountsKeys.people(organizationId), - }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), - ]), + queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.people(organizationId) }), }) } export function useAddOrganizationAccountMcpProvider() { @@ -341,39 +330,6 @@ export function useRemoveOrganizationAccountMcpProvider() { queryKey: organizationAccountsKeys.detail(organizationId), }), queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.workspaces() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), - ]), - }) -} - -export function usePersonalOrganizationAccounts() { - return useInfiniteQuery({ - queryKey: organizationAccountsKeys.personal(), - staleTime: ORGANIZATION_ACCOUNTS_STALE_TIME, - initialPageParam: undefined as string | undefined, - queryFn: ({ signal, pageParam }) => - requestJson(listPersonalOrganizationAccountsContract, { - query: { cursor: pageParam }, - signal, - }), - getNextPageParam: (page) => page.nextCursor ?? undefined, - }) -} -export function useReconnectPersonalOrganizationAccount() { - return useMutation({ - mutationFn: (credentialId: string) => - requestJson(reconnectPersonalOrganizationAccountContract, { params: { credentialId } }), - }) -} -export function useDisconnectPersonalOrganizationAccount() { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (credentialId: string) => - requestJson(disconnectPersonalOrganizationAccountContract, { params: { credentialId } }), - onSuccess: () => - Promise.all([ - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.personal() }), - queryClient.invalidateQueries({ queryKey: organizationAccountsKeys.details() }), ]), }) } From 7050b1ad2c5d72258e93235c1794826092dfcb3b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 9 Sep 2026 17:34:06 -0700 Subject: [PATCH 2/3] improvement(search): simplify personal integrations and source setup --- .../credential-groups/enrollment-redirect.ts | 19 +- .../api/credential-groups/oauth-callback.ts | 6 +- .../oauth/[provider]/callback/route.test.ts | 16 ++ .../connectors/[connectorId]/enroll/route.ts | 5 +- .../api/knowledge/sim-search/connect/route.ts | 2 +- .../complete/completion-handoff.test.tsx | 49 ++++ .../complete/completion-handoff.tsx | 26 ++ .../app/credential-groups/complete/page.tsx | 31 +- .../organization-page/organization-page.tsx | 45 +-- .../integrations/connect-account-options.tsx | 226 +++++++++++++++ .../disconnect-account-menu.test.tsx | 135 +++++++++ .../integrations/disconnect-account-menu.tsx | 62 ++++ .../integrations/integrations.test.tsx | 204 +++++++++---- .../integrations/integrations.tsx | 267 +++++------------- .../organization-integrations-settings.tsx | 3 - .../organization-integrations-setup.test.tsx | 35 ++- .../organization-integrations-setup.tsx | 21 +- .../organization-search-status.test.ts | 55 ++++ .../organization-search-status.ts | 20 +- .../[connectorType]/provider-detail.test.tsx | 110 ++++++-- .../[connectorType]/provider-detail.tsx | 77 +++-- .../sources/[connectorId]/source-detail.tsx | 2 +- .../components/search-source-row.test.tsx | 4 +- .../search/components/search-source-row.tsx | 13 +- apps/sim/hooks/queries/kb/connectors.ts | 5 +- .../queries/organization-accounts.test.tsx | 69 +++++ .../hooks/queries/organization-accounts.ts | 21 ++ apps/sim/hooks/use-member-enrollment.test.tsx | 117 +++++++- apps/sim/hooks/use-member-enrollment.ts | 108 +++++-- .../lib/api/contracts/knowledge/connectors.ts | 20 +- .../lib/credential-groups/oauth-completion.ts | 26 ++ .../lib/credential-groups/oauth-state.test.ts | 2 + apps/sim/lib/credential-groups/oauth-state.ts | 17 +- apps/sim/lib/credential-groups/oauth.test.ts | 8 +- apps/sim/lib/credential-groups/oauth.ts | 7 +- apps/sim/lib/knowledge/api/route-policies.ts | 2 + .../application/connector-access.test.ts | 67 +++++ .../knowledge/application/connector-access.ts | 61 +++- .../organization-search-overview.test.ts | 46 ++- .../organization-search-overview.ts | 20 +- .../application/search-sources.test.ts | 36 ++- .../knowledge/application/search-sources.ts | 22 +- .../knowledge/application/sim-search.test.ts | 7 +- .../lib/knowledge/application/sim-search.ts | 5 +- .../connectors/viewer-source-accounts.test.ts | 98 +++++++ .../connectors/viewer-source-accounts.ts | 97 +++++++ apps/sim/lib/sim-search/connectors.test.ts | 18 ++ apps/sim/lib/sim-search/connectors.ts | 5 + 48 files changed, 1878 insertions(+), 439 deletions(-) create mode 100644 apps/sim/app/credential-groups/complete/completion-handoff.test.tsx create mode 100644 apps/sim/app/credential-groups/complete/completion-handoff.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx create mode 100644 apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx create mode 100644 apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.test.ts create mode 100644 apps/sim/lib/credential-groups/oauth-completion.ts create mode 100644 apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts create mode 100644 apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index d2768f19ee7..755a68094b2 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' const NO_STORE_REDIRECT_HEADERS = { 'Cache-Control': 'no-store', @@ -20,23 +21,17 @@ export function createCredentialGroupEnrollmentRedirect( }) } -export type CredentialGroupOAuthFailure = - | 'expired' - | 'denied' - | 'account_mismatch' - | 'permissions_required' - | 'configuration_changed' - | 'rate_limited' - | 'unavailable' - | 'failed' - export function createCredentialGroupCompletionRedirect( - oauth?: CredentialGroupOAuthFailure + oauth?: CredentialGroupOAuthFailure, + completionId?: string ): NextResponse { + const query = new URLSearchParams() + if (oauth) query.set('oauth', oauth) + if (completionId) query.set('completionId', completionId) return new NextResponse(null, { status: 303, headers: { - Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`, + Location: `/credential-groups/complete${query.size ? `?${query}` : ''}`, ...NO_STORE_REDIRECT_HEADERS, }, }) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 399eb222982..aa565c8be62 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -5,6 +5,7 @@ import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/cred import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { CredentialGroupInvitationUnavailableError, @@ -12,7 +13,6 @@ import { } from '@/lib/credential-groups/provider-adapter' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { - type CredentialGroupOAuthFailure, createCredentialGroupCompletionRedirect, createCredentialGroupEnrollmentRedirect, } from '@/app/api/credential-groups/enrollment-redirect' @@ -54,7 +54,7 @@ export async function handleCredentialGroupOAuthCallback({ : {} const failureRedirect = (oauth: CredentialGroupOAuthFailure) => attempt.completionRedirect - ? createCredentialGroupCompletionRedirect(oauth) + ? createCredentialGroupCompletionRedirect(oauth, attempt.completionId) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth }) if (limited) { return failureRedirect('rate_limited') @@ -74,7 +74,7 @@ export async function handleCredentialGroupOAuthCallback({ request, }) return attempt.completionRedirect - ? createCredentialGroupCompletionRedirect() + ? createCredentialGroupCompletionRedirect(undefined, attempt.completionId) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, connected: attempt.optionId, diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 7c2b4a9edb9..508dc05267a 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -56,6 +56,22 @@ function request(query: string) { } describe('credential group OAuth callback', () => { + it.each([ + ['code=code-1', undefined], + ['error=access_denied', 'denied'], + ])( + 'correlates direct OAuth completion without returning to enrollment: %s', + async (query, failure) => { + const completionId = '550e8400-e29b-41d4-a716-446655440000' + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true, completionId }) + const response = await GET(request(`state=state-1&${query}`), context) + const location = new URL(response.headers.get('location')!, 'https://sim.test') + expect(response.status).toBe(303) + expect(location.pathname).toBe('/credential-groups/complete') + expect(location.searchParams.get('completionId')).toBe(completionId) + expect(location.searchParams.get('oauth')).toBe(failure ?? null) + } + ) beforeEach(() => { vi.clearAllMocks() mocks.rateLimit.mockResolvedValue(null) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts index da3cc91c176..ddedaec89c6 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts @@ -16,10 +16,11 @@ export const POST = defineInternalJsonRoute({ reason: 'A member connecting their own account by hand; each call only re-issues their own invitation', }), - errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ params }) => ({ + errorPolicy: internalKnowledgeErrorPolicies.connectAccount, + mapInput: ({ params, query }) => ({ connectorId: params.connectorId, knowledgeBaseId: params.id, + oauthCompletionId: query.oauthCompletionId, }), useCase: startKnowledgeConnectorMemberEnrollment, present: ({ url }) => ({ success: true as const, data: { url } }), diff --git a/apps/sim/app/api/knowledge/sim-search/connect/route.ts b/apps/sim/app/api/knowledge/sim-search/connect/route.ts index 9f2319ee6d3..521b6b570ef 100644 --- a/apps/sim/app/api/knowledge/sim-search/connect/route.ts +++ b/apps/sim/app/api/knowledge/sim-search/connect/route.ts @@ -13,7 +13,7 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: knowledgeOperations.simSearchConnect, rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }), - errorPolicy: internalKnowledgeErrorPolicies.connectors, + errorPolicy: internalKnowledgeErrorPolicies.connectAccount, mapInput: ({ body }) => body, useCase: connectSimSearchConnector, present: (result) => ({ success: true as const, data: result }), diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx new file mode 100644 index 00000000000..f139057a076 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx @@ -0,0 +1,49 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('credential group OAuth completion', () => { + it.each([undefined, 'denied', 'configuration_changed'] as const)( + 'publishes %s to only its initiating tab and closes', + (failure) => { + const postMessage = vi.fn() + const closeChannel = vi.fn() + const names: string[] = [] + vi.stubGlobal( + 'BroadcastChannel', + class { + postMessage = postMessage + close = closeChannel + constructor(name: string) { + names.push(name) + } + } + ) + const closeWindow = vi.spyOn(window, 'close').mockImplementation(() => {}) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + const completionId = '550e8400-e29b-41d4-a716-446655440000' + try { + act(() => + root.render( + + ) + ) + expect(names).toEqual([`sim:credential-group-oauth:${completionId}`]) + expect(postMessage).toHaveBeenCalledExactlyOnceWith(failure ?? 'connected') + expect(closeChannel).toHaveBeenCalledOnce() + expect(closeWindow).toHaveBeenCalledOnce() + } finally { + act(() => root.unmount()) + } + } + ) +}) diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.tsx new file mode 100644 index 00000000000..07f8d440126 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/completion-handoff.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useEffect } from 'react' +import { + type CredentialGroupOAuthFailure, + credentialGroupOAuthCompletionChannel, +} from '@/lib/credential-groups/oauth-completion' + +interface CredentialGroupCompletionHandoffProps { + completionId: string + failure?: CredentialGroupOAuthFailure +} + +/** Notifies the originating tab even when provider navigation has removed window.opener. */ +export function CredentialGroupCompletionHandoff({ + completionId, + failure, +}: CredentialGroupCompletionHandoffProps) { + useEffect(() => { + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId)) + channel.postMessage(failure ?? 'connected') + channel.close() + window.close() + }, [completionId, failure]) + return null +} diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx index 86dbcae5ee3..07edac4b434 100644 --- a/apps/sim/app/credential-groups/complete/page.tsx +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -1,36 +1,33 @@ import { ChipLink } from '@sim/emcn' +import { isValidUuid } from '@sim/utils/id' import type { Metadata } from 'next' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { AuthHeader, AuthShell } from '@/app/(auth)/components' +import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' export const metadata: Metadata = { title: 'Accounts connected', robots: { index: false, follow: false }, } -const OAUTH_FAILURE_MESSAGES = { - expired: 'This connection attempt expired. Open Sim and start connecting your account again.', - denied: 'Authorization was canceled. Open Sim to try again.', - account_mismatch: 'Choose the account matching your Sim email address.', - permissions_required: 'All requested permissions are required to connect this account.', - configuration_changed: 'The connection settings changed. Open Sim to try again.', - rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', - unavailable: 'This connection is unavailable. Open Sim to try again.', - failed: 'Account authorization did not complete. Open Sim to try again.', -} as const - export default async function CredentialGroupCompletePage({ searchParams, }: { - searchParams: Promise<{ oauth?: string | string[] }> + searchParams: Promise<{ oauth?: string | string[]; completionId?: string | string[] }> }) { - const { oauth } = await searchParams - const error = - typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth) - ? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES] - : undefined + const { oauth, completionId } = await searchParams + const failure = + oauth === undefined ? undefined : isCredentialGroupOAuthFailure(oauth) ? oauth : 'failed' + const error = failure ? CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure] : undefined return ( + {typeof completionId === 'string' && isValidUuid(completionId) && ( + + )} (null) @@ -71,7 +76,7 @@ export function OrganizationPage({ * viewer opened and has not dismissed. */ const [searchOpened, setSearchOpened] = useState(false) - const searchOpen = searchOpened || search.length > 0 + const searchOpen = searchMode === 'expanded' || searchOpened || search.length > 0 const closeSearch = () => { setSearch('') @@ -99,7 +104,8 @@ export function OrganizationPage({ ref={tabsRef} className={cn( scrollFadeXClass, - 'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' + 'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + searchMode === 'expanded' && !tabs?.length && 'hidden' )} {...scrollFadeAttributes(tabEdges)} > @@ -119,32 +125,39 @@ export function OrganizationPage({ ) })}
-
+
{searchOpen ? ( setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape') closeSearch() }} endAdornment={ - + (searchMode === 'collapsible' || search.length > 0) && ( + + ) } /> ) : ( diff --git a/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx new file mode 100644 index 00000000000..aea98ec0b7b --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/connect-account-options.tsx @@ -0,0 +1,226 @@ +'use client' + +import { useMemo } from 'react' +import { Chip } from '@sim/emcn' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { + connectorDisplayName, + getConnectorAccessAvailability, + SEARCH_CONNECTORS, +} from '@/lib/sim-search/connectors' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' +import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' +import { useSearchIntegrations } from '@/hooks/queries/search-integrations' +import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment' +import { usePermissionConfig } from '@/hooks/use-permission-config' + +interface ConnectAccountOptionsProps { + search?: string + showEmpty?: boolean +} + +/** Approved integrations the viewer can connect to Search with their own account. */ +export function ConnectAccountOptions({ + search = '', + showEmpty = true, +}: ConnectAccountOptionsProps = {}) { + const { organization, searchAccess } = useOrganizationContext() + const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } + const sources = useSearchSources(scope, { search }) + const overview = useSearchSourceOverview(scope) + const integrations = useSearchIntegrations(organization.id) + const availability = usePermissionConfig() + const membershipQueryKeys = useMemo( + () => [ + searchSourceKeys.list({ kind: 'organization', organizationId: organization.id }), + organizationAccountsKeys.detail(organization.id), + ], + [organization.id] + ) + const connectedConnectorIds = useMemo( + () => + new Set( + sources.data + ?.filter((source) => source.viewerMembership === 'connected') + .map((source) => source.connectorId) + ), + [sources.data] + ) + const enrollment = useMemberEnrollment({ + membershipQueryKeys, + connectedConnectorIds, + directOAuth: true, + }) + const visibleSources = + sources.data?.filter( + (source) => + source.connectionRequired && + source.enabled && + source.approved !== false && + source.availability === 'available' && + source.viewerMembership !== null && + source.viewerMembership !== 'needs_reauth' && + CONNECTABLE_MEMBERSHIPS.has(source.viewerMembership) && + searchAccess.memberScoped && + (source.accessMode === 'members' || searchAccess.sourceMirrored) + ) ?? [] + + const approvedTypes = new Set( + integrations.data + ?.filter((integration) => integration.approved) + .map((integration) => integration.connectorType) + ) + const configuredTypes = new Set( + overview.data?.providers.map((provider) => provider.connectorType) + ) + const sourceChoices = SEARCH_CONNECTORS.filter((connector) => { + if ( + connector.type === 'slack' || + !approvedTypes.has(connector.type) || + !connector.meta.name.toLowerCase().includes(search.toLowerCase()) || + (configuredTypes.has(connector.type) && connector.setupFields.length === 0) + ) + return false + return getConnectorAccessAvailability(connector.meta, availability.integrationAvailability, { + memberAccessAvailable: searchAccess.memberScoped, + mirroredAccessAvailable: searchAccess.sourceMirrored, + oauthServiceAvailability: availability.oauthServiceAvailability, + isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + }).members + }) + const integrationRows = [ + ...sourceChoices.map((connector) => ({ + kind: 'provider' as const, + connector, + name: connector.meta.name, + })), + ...visibleSources.map((source) => ({ + kind: 'source' as const, + source, + name: connectorDisplayName(source.connectorType), + })), + ].sort( + (a, b) => a.name.localeCompare(b.name) || (a.kind === b.kind ? 0 : a.kind === 'source' ? -1 : 1) + ) + const failedQuery = + sources.isError && !sources.isFetchNextPageError + ? sources + : overview.isError + ? overview + : integrations.isError + ? integrations + : null + + return ( + <> +
+ {failedQuery ? ( + void failedQuery.refetch()} + variant='inline' + /> + ) : availability.integrationAvailabilityError ? ( + void availability.refetchIntegrationAvailability()} + variant='inline' + /> + ) : sources.isPending || + overview.isPending || + integrations.isPending || + !availability.isIntegrationAvailabilityReady ? ( + Loading sources… + ) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? ( + <> + {integrationRows.map((row) => { + if (row.kind === 'source') { + const { source } = row + return ( + enrollment.connect(source.knowledgeBaseId, source.connectorId)} + /> + ) + } + const { connector } = row + const { type, meta } = connector + const hasSources = configuredTypes.has(type) + return ( + } + title={meta.name} + description={ + hasSources + ? 'Connect a different site or content scope' + : 'Connect your account to search this source' + } + trailing={ + enrollment.connectSearchSource(scope, connector, undefined)} + > + Connect + + } + /> + ) + })} + + + ) : showEmpty ? ( + + {search ? 'No matching integrations.' : 'No integrations are available to connect.'} + + ) : null} + {enrollment.error && ( +

{enrollment.error}

+ )} +
+ {enrollment.setupConnector && ( + + enrollment.connectSource(scope, enrollment.setupConnector!.type, config) + } + /> + )} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx new file mode 100644 index 00000000000..279513d60a4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx @@ -0,0 +1,135 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ disconnect: vi.fn(), mutate: vi.fn(), reset: vi.fn() })) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useDisconnectPersonalOrganizationAccount: mocks.disconnect, +})) +vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ + IntegrationTile: () => null, +})) + +import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' +import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' +import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' + +const accounts = [{ credentialId: 'my-gmail', displayName: 'me@example.test' }] +const source: SearchSourceSummary = { + knowledgeBaseId: 'kb', + connectorId: 'gmail', + connectorType: 'gmail', + sourceDescription: '', + accessMode: 'members', + availability: 'available', + enabled: true, + isSyncing: true, + lastSyncAt: null, + hasSyncError: false, + viewerDocumentCount: 0, + viewerFailedDocumentCount: 0, + viewerEmailVerified: true, + connectionRequired: true, + viewerMembership: 'connected', + viewerAccounts: accounts, +} + +describe('personal integration disconnect', () => { + let root: Root + let container: HTMLDivElement + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.disconnect.mockReturnValue({ + mutate: mocks.mutate, + reset: mocks.reset, + isPending: false, + error: null, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + async function render(overrides: Partial = {}) { + await act(async () => + root.render( + + } + /> + ) + ) + } + async function openDisconnect() { + const trigger = document.querySelector( + '[aria-label="Gmail account actions"]' + )! + await act(async () => + trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + const item = document.querySelector('[role="menuitem"]')! + expect(item.textContent).toBe('Disconnect') + expect(item.hasAttribute('data-disabled')).toBe(false) + await act(async () => item.click()) + } + function confirm() { + return Array.from(document.querySelectorAll('[role="dialog"] button')).find( + (button) => button.textContent === 'Disconnect' + )! + } + + it.each([ + ['indexing', {}], + ['failed', { hasSyncError: true }], + ['paused', { enabled: false }], + ['deactivated', { approved: false }], + ['reconnect', { viewerMembership: 'needs_reauth' }], + ['unavailable', { availability: 'unavailable', viewerMembership: null }], + ] as const)('allows disconnect while %s without requiring admin access', async (_, overrides) => { + await render(overrides) + await openDisconnect() + expect(document.body.textContent).toContain('Sim will stop using me@example.test for Search.') + expect(document.body.textContent).not.toContain('workflows') + expect(mocks.mutate).not.toHaveBeenCalled() + expect(confirm().disabled).toBe(false) + await act(async () => confirm().click()) + expect(mocks.mutate).toHaveBeenCalledExactlyOnceWith( + 'my-gmail', + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + }) + + it('shows a failure in the confirmation and keeps it retryable', async () => { + await render() + await openDisconnect() + mocks.disconnect.mockReturnValue({ + mutate: mocks.mutate, + reset: mocks.reset, + isPending: false, + error: new Error('Could not disconnect. Try again.'), + }) + await render() + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Could not disconnect. Try again.' + ) + expect(confirm().disabled).toBe(false) + }) +}) diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx new file mode 100644 index 00000000000..6d3166ff616 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipModalError } from '@sim/emcn' +import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts' + +interface DisconnectAccountMenuProps { + organizationId: string + integrationName: string + accounts: ViewerSearchSourceAccount[] +} + +/** Disconnect is independent of provider availability, reconnect state, and indexing activity. */ +export function DisconnectAccountMenu({ + organizationId, + integrationName, + accounts, +}: DisconnectAccountMenuProps) { + const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const [selectedId, setSelectedId] = useState(null) + const selected = accounts.find((account) => account.credentialId === selectedId) + if (!accounts.length) return null + + return ( + <> + ({ + label: accounts.length === 1 ? 'Disconnect' : `Disconnect ${account.displayName}`, + destructive: true, + disabled: disconnect.isPending, + onSelect: () => { + disconnect.reset() + setSelectedId(account.credentialId) + }, + }))} + /> + { + if (!open && !disconnect.isPending) setSelectedId(null) + }} + title={`Disconnect ${integrationName}`} + text={`Sim will stop using ${selected?.displayName ?? integrationName} for Search. You can reconnect later.`} + confirm={{ + label: 'Disconnect', + pendingLabel: 'Disconnecting…', + pending: disconnect.isPending, + disabled: disconnect.isPending, + onClick: () => { + if (!selected || disconnect.isPending) return + disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) }) + }, + }} + > + {disconnect.error?.message} + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx index 7588a765136..edf5d144b50 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ connect: vi.fn(), availability: vi.fn(), refetchAvailability: vi.fn(), + enrollment: vi.fn(), })) vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({ @@ -57,20 +58,25 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({ })) vi.mock('@/hooks/use-member-enrollment', () => ({ CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']), - useMemberEnrollment: () => ({ - connect: mocks.connect, - connectSearchSource: mocks.connect, - isAwaiting: () => false, - isPending: false, - error: null, - }), + useMemberEnrollment: (options: unknown) => { + mocks.enrollment(options) + return { + connect: mocks.connect, + connectSearchSource: mocks.connect, + isAwaiting: () => false, + isPending: false, + error: null, + } + }, })) vi.mock('@/hooks/use-oauth-return', () => ({ useDesktopOAuthConnectListener: () => undefined, useOAuthReturnRouter: () => undefined, })) +import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' const scope = { kind: 'organization', organizationId: 'organization-a' } as const const memberSource: SearchSourceSummary = { @@ -115,7 +121,11 @@ describe('organization integrations role and source paths', () => { mocks.integrations.mockReturnValue({ data: [], isPending: false }) mocks.availability.mockReturnValue({ integrationAvailability: new Map(), - oauthServiceAvailability: new Map([['google-email', true]]), + oauthServiceAvailability: new Map([ + ['google-email', true], + ['confluence', true], + ['jira', true], + ]), isIntegrationAvailabilityReady: true, integrationAvailabilityError: null, isIntegrationAvailabilityFetching: false, @@ -146,7 +156,7 @@ describe('organization integrations role and source paths', () => { }) async function render() { - await act(async () => root.render()) + await act(async () => root.render()) } function buttons(label: string) { @@ -157,16 +167,16 @@ describe('organization integrations role and source paths', () => { it('uses the actual organization and only asks members to connect identity-dependent sources', async () => { await render() - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: false }) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '' }) expect(buttons('Add source')).toHaveLength(0) expect(buttons('Manage')).toHaveLength(0) - expect(buttons('Connect account')).toHaveLength(1) - expect(document.body.textContent).toContain('4 searchable documents') - await act(async () => buttons('Connect account')[0].click()) + expect(buttons('Connect')).toHaveLength(1) + expect(document.body.textContent).not.toContain('Engineering') + await act(async () => buttons('Connect')[0].click()) expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) - it('keeps Slack return actions alongside source management', async () => { + it('keeps Slack return actions alongside personal connection controls', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, @@ -178,18 +188,31 @@ describe('organization integrations role and source paths', () => { ) ) expect(document.body.textContent).not.toContain('Your accounts') - expect(document.body.textContent).toContain('Manage sources') + expect(document.body.textContent).not.toContain('Manage sources') expect(buttons('slack-return')).toHaveLength(1) }) - it('debounces server search while applying the selected tab immediately', async () => { + it('always requests personal connections even with an old All tab URL', async () => { vi.useFakeTimers() - await render() - mocks.filters.mockReturnValue({ tab: 'mine', search: ' drive ', setSearch: vi.fn() }) - await render() - expect(mocks.sources).toHaveBeenLastCalledWith(scope, { search: '', mine: true }) + await act(async () => root.render()) + mocks.filters.mockReturnValue({ tab: 'all', search: ' drive ', setSearch: vi.fn() }) + await act(async () => root.render()) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) await act(async () => vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS)) - expect(mocks.sources).toHaveBeenLastCalledWith(scope, { search: 'drive', mine: true }) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'drive', mine: true }) + }) + + it('refreshes organization Accounts after either direct connection flow completes', async () => { + await act(async () => root.render()) + expect(mocks.enrollment.mock.calls.length).toBeGreaterThanOrEqual(2) + for (const [options] of mocks.enrollment.mock.calls) { + expect(options).toMatchObject({ + directOAuth: true, + membershipQueryKeys: expect.arrayContaining([ + organizationAccountsKeys.detail(scope.organizationId), + ]), + }) + } }) it('offers an approved integration before any source is configured', async () => { @@ -204,8 +227,8 @@ describe('organization integrations role and source paths', () => { }) await render() expect(document.body.textContent).toContain('Connect your account to search this source') - expect(buttons('Connect account')).toHaveLength(1) - await act(async () => buttons('Connect account')[0].click()) + expect(buttons('Connect')).toHaveLength(1) + await act(async () => buttons('Connect')[0].click()) expect(mocks.connect).toHaveBeenCalledWith( scope, expect.objectContaining({ type: 'gmail' }), @@ -231,8 +254,8 @@ describe('organization integrations role and source paths', () => { isIntegrationAvailabilityReady: true, }) await render() - expect(buttons('Add source')).toHaveLength(1) - await act(async () => buttons('Add source')[0].click()) + expect(buttons('Connect')).toHaveLength(2) + await act(async () => buttons('Connect')[1].click()) expect(mocks.connect).toHaveBeenCalledWith( scope, expect.objectContaining({ type: 'confluence' }), @@ -261,8 +284,8 @@ describe('organization integrations role and source paths', () => { isPending: false, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('Deactivated by an organization admin') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Gmail') }) it('waits for availability before describing approved sources as needing admin setup', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) @@ -278,7 +301,7 @@ describe('organization integrations role and source paths', () => { await render() expect(document.body.textContent).toContain('Loading sources') expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) }) it('retries availability failures instead of asking an admin to finish setup', async () => { @@ -300,11 +323,11 @@ describe('organization integrations role and source paths', () => { await render() expect(document.body.textContent).toContain('Connection availability failed') expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) await act(async () => buttons('Try again')[0].click()) expect(mocks.refetchAvailability).toHaveBeenCalledOnce() }) - it('asks an admin to configure Slack before members can connect an approved source', async () => { + it('hides Slack until its organization setup is ready', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false }, @@ -315,10 +338,42 @@ describe('organization integrations role and source paths', () => { isPending: false, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('An admin needs to finish source setup') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Slack') + expect(document.body.textContent).toContain('No integrations are available to connect.') + }) + + it('hides an approved provider when its OAuth configuration is missing', async () => { + mocks.sources.mockReturnValue({ data: [], isPending: false }) + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'gmail', approved: true }], + isPending: false, + }) + mocks.availability.mockReturnValue({ + integrationAvailability: new Map(), + oauthServiceAvailability: new Map([['google-email', false]]), + isIntegrationAvailabilityReady: true, + }) + await render() + expect(document.body.textContent).not.toContain('Gmail') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).toContain('No integrations are available to connect.') }) - it('takes admins directly to unfinished Slack indexing setup', async () => { + + it('offers personal Slack connection once source setup is complete', async () => { + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, connectorType: 'slack', accessMode: 'admin' }], + isPending: false, + }) + await render() + expect(document.body.textContent).toContain('Slack') + expect(buttons('Connect')).toHaveLength(1) + expect(document.body.textContent).not.toContain('Finish Slack setup') + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') + }) + it('also hides unfinished Slack setup from admins on this personal surface', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, @@ -333,46 +388,73 @@ describe('organization integrations role and source paths', () => { await render() expect( document.querySelector('a[href="/o/organization-a/settings/integrations/providers/slack"]') - ).toHaveTextContent('Finish Slack setup') + ).toBeNull() expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) }) - it('keeps personal rows consistent for admins and directs management through Sources', async () => { + it('keeps source administration off the personal page for admins', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, searchAccess: { memberScoped: true, sourceMirrored: true }, }) - await render() - expect( - document.querySelector( - 'a[href="/o/organization-a/settings/integrations/sources/member-source"]' - ) - ).toBeNull() - expect( - document.querySelector('a[href="/o/organization-a/settings/integrations"]') - ).toHaveTextContent('Manage sources') + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, viewerMembership: 'connected' }], + isPending: false, + }) + await act(async () => root.render()) + expect(document.body.textContent).not.toContain('Manage sources') expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).toBeNull() - expect(buttons('Add source')).toHaveLength(0) - expect(buttons('Manage')).toHaveLength(0) expect(document.querySelector('[aria-label$="source actions"]')).toBeNull() - expect(buttons('Connect account')).toHaveLength(1) + expect(buttons('Connect')).toHaveLength(0) }) - it('lists only the sources the viewer connected under Mine', async () => { - mocks.filters.mockReturnValue({ tab: 'mine', search: '', setSearch: vi.fn() }) + it('shows ready integrations inline and connects without an intermediate dialog', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) - await render() + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'gmail', approved: true }], + isPending: false, + }) + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + await act(async () => root.render()) expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) - expect(document.body.textContent).toContain('You haven’t connected any sources yet.') - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, viewerMembership: 'connected' }], + expect(document.body.textContent).toContain('Gmail') + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(buttons('Connect account')).toHaveLength(0) + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledWith( + scope, + expect.objectContaining({ type: 'gmail' }), + undefined + ) + }) + + it('filters available providers using the same search as personal connections', async () => { + mocks.sources.mockReturnValue({ data: [], isPending: false }) + mocks.integrations.mockReturnValue({ + data: [ + { connectorType: 'gmail', approved: true }, + { connectorType: 'jira', approved: true }, + ], isPending: false, }) - await render() + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + await act(async () => root.render()) expect(document.body.textContent).toContain('Gmail') - expect(document.body.textContent).not.toContain('Engineering') + expect(document.body.textContent).not.toContain('Jira') + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'gmail' }) + }) + + it('lets the viewer reconnect their own expired account from the main page', async () => { + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, viewerMembership: 'needs_reauth' }], + isPending: false, + }) + await act(async () => root.render()) + expect(document.body.textContent).toContain('Your account needs to be reconnected') + await act(async () => buttons('Reconnect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) it('does not offer connection to an unavailable source or setup to a member with no sources', async () => { @@ -382,15 +464,15 @@ describe('organization integrations role and source paths', () => { searchAccess: { memberScoped: false, sourceMirrored: false }, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('Not available in this organization') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Gmail') mocks.sources.mockReturnValue({ data: [], isPending: false }) mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false }, isPending: false, }) await render() - expect(document.body.textContent).toContain('Ask an organization admin to get started') + expect(document.body.textContent).toContain('No integrations are available to connect.') expect(buttons('Add source')).toHaveLength(0) }) it('keeps sparse source pages navigable without claiming missing sources or duplicating configured providers', async () => { @@ -402,7 +484,7 @@ describe('organization integrations role and source paths', () => { }) await render() expect(buttons('Load more')).toHaveLength(1) - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) expect(document.body.textContent).not.toContain('hasn’t added any sources') await act(async () => buttons('Load more')[0].click()) expect(fetchNextPage).toHaveBeenCalledOnce() @@ -411,7 +493,7 @@ describe('organization integrations role and source paths', () => { it('retains loaded rows on a next-page failure and retries only that page', async () => { const fetchNextPage = vi.fn() mocks.sources.mockReturnValue({ - data: [centralSource], + data: [{ ...memberSource, sourceDescription: 'Engineering' }], isPending: false, isError: true, isFetchNextPageError: true, diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx index ae8918681b1..71bb9109950 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx @@ -1,68 +1,44 @@ 'use client' import { useMemo } from 'react' -import { Chip, ChipLink } from '@sim/emcn' import type { ResourceScope } from '@/lib/core/resource-scope' -import { organizationRoutes } from '@/lib/navigation/paths' -import { - connectorDisplayName, - getConnectorAccessAvailability, - SEARCH_CONNECTORS, - SEARCH_SOURCE_TYPES, -} from '@/lib/sim-search/connectors' +import { connectorDisplayName } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page' import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' +import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' +import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' -import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' -import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors' -import { useSearchIntegrations } from '@/hooks/queries/search-integrations' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { RESOURCE_LIST_STACK } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSearchSources } from '@/hooks/queries/kb/connectors' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { useDebounce } from '@/hooks/use-debounce' import { useMemberEnrollment } from '@/hooks/use-member-enrollment' import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return' -import { usePermissionConfig } from '@/hooks/use-permission-config' - -/** Every source the organization searches, or only the ones the viewer has connected. */ -const TABS = [ - { id: 'all', label: 'All' }, - { id: 'mine', label: 'Mine' }, -] as const interface OrganizationIntegrationsProps { slackOnboarding?: { token: string; userId: string } } -/** - * Personal connections and approved source scopes available to the organization. - * Administrators can open source management without leaving this journey. - */ +/** The viewer's Search connections and ready integrations they can connect personally. */ export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegrationsProps = {}) { useOAuthReturnRouter() useDesktopOAuthConnectListener() - const { organization, searchAccess, viewer } = useOrganizationContext() - const routes = organizationRoutes(organization.id) + const { organization, searchAccess } = useOrganizationContext() const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } - const { tab, search } = useOrganizationPageFilters() + const { search } = useOrganizationPageFilters() const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - const sources = useSearchSources(scope, { search: sourceSearch, mine: tab === 'mine' }) - const overview = useSearchSourceOverview(scope) - const integrations = useSearchIntegrations(organization.id) - const availability = usePermissionConfig() + const sources = useSearchSources(scope, { search: sourceSearch, mine: true }) const membershipQueryKeys = useMemo( - () => [searchSourceKeys.list({ kind: 'organization', organizationId: organization.id })], + () => [ + searchSourceKeys.list({ kind: 'organization', organizationId: organization.id }), + organizationAccountsKeys.detail(organization.id), + ], [organization.id] ) const connectedConnectorIds = useMemo( @@ -74,191 +50,74 @@ export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegr ), [sources.data] ) - const enrollment = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds }) - const query = search.trim().toLowerCase() - const mineOnly = tab === 'mine' - const visibleSources = sources.data ?? [] - - const approvedTypes = new Set( - integrations.data - ?.filter((integration) => integration.approved) - .map((integration) => integration.connectorType) - ) - const configuredTypes = new Set( - overview.data?.providers.map((provider) => provider.connectorType) - ) - const sourceChoices = mineOnly - ? [] - : SEARCH_SOURCE_TYPES.filter( - ([type, meta]) => - approvedTypes.has(type) && - (!configuredTypes.has(type) || - SEARCH_CONNECTORS.some( - (connector) => connector.type === type && connector.setupFields.length > 0 - )) && - meta.name.toLowerCase().includes(query) - ) - const integrationRows = [ - ...sourceChoices.map(([type, meta]) => ({ - kind: 'provider' as const, - type, - meta, - name: meta.name, - })), - ...visibleSources.map((source) => ({ - kind: 'source' as const, - source, - name: connectorDisplayName(source.connectorType), - })), - ].sort( - (a, b) => a.name.localeCompare(b.name) || (a.kind === b.kind ? 0 : a.kind === 'source' ? -1 : 1) - ) - const failedQuery = - sources.isError && !sources.isFetchNextPageError - ? sources - : overview.isError - ? overview - : integrations.isError - ? integrations - : null + const enrollment = useMemberEnrollment({ + membershipQueryKeys, + connectedConnectorIds, + directOAuth: true, + }) return ( - {viewer.isAdmin && ( - Manage sources - )} - {slackOnboarding && ( - - )} -
+ slackOnboarding && ( + ) } >
- {failedQuery ? ( + {sources.isError && !sources.isFetchNextPageError ? ( void failedQuery.refetch()} + error={sources.error} + fallback='Could not load your connections' + isRetrying={sources.isFetching} + onRetry={() => void sources.refetch()} variant='inline' /> - ) : availability.integrationAvailabilityError ? ( - void availability.refetchIntegrationAvailability()} - variant='inline' - /> - ) : sources.isPending || - overview.isPending || - integrations.isPending || - !availability.isIntegrationAvailabilityReady ? ( - Loading sources… - ) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? ( + ) : !sources.isPending && (sources.data?.length || sources.hasNextPage) ? ( <> - {integrationRows.map((row) => { - if (row.kind === 'source') { - const { source } = row - return ( - enrollment.connect(source.knowledgeBaseId, source.connectorId)} - /> - ) - } - const { type, meta } = row - const connector = SEARCH_CONNECTORS.find((item) => item.type === type) - const access = getConnectorAccessAvailability( - meta, - availability.integrationAvailability, - { - memberAccessAvailable: searchAccess.memberScoped, - mirroredAccessAvailable: searchAccess.sourceMirrored, - oauthServiceAvailability: availability.oauthServiceAvailability, - isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + {sources.data?.map((source) => ( + + ) : undefined } - ) - const canConnect = connector && type !== 'slack' && access.members - const hasSources = configuredTypes.has(type) - if (hasSources && !canConnect) return null - return ( - } - title={hasSources ? `Add another ${meta.name} source` : meta.name} - description={ - hasSources - ? 'Connect a different site or content scope' - : canConnect - ? 'Connect your account to search this source' - : type === 'slack' && viewer.isAdmin - ? 'Finish setting up Slack indexing to connect accounts' - : 'An admin needs to finish source setup' - } - trailing={ - canConnect ? ( - enrollment.connectSearchSource(scope, connector, undefined)} - > - {hasSources ? 'Add source' : 'Connect account'} - - ) : type === 'slack' && viewer.isAdmin ? ( - Finish Slack setup - ) : undefined - } - /> - ) - })} + available={ + source.accessMode === 'members' + ? searchAccess.memberScoped + : searchAccess.sourceMirrored && + (!source.connectionRequired || searchAccess.memberScoped) + } + waiting={enrollment.isAwaiting(source.connectorId)} + isPending={enrollment.isPending} + onConnect={() => enrollment.connect(source.knowledgeBaseId, source.connectorId)} + /> + ))} - ) : ( - - {query - ? 'No matching sources.' - : mineOnly - ? 'You haven’t connected any sources yet.' - : viewer.isAdmin - ? 'Your organization hasn’t added any sources yet. Open Manage sources to get started.' - : 'Your organization hasn’t added any sources yet. Ask an organization admin to get started.'} - - )} + ) : null} {enrollment.error && (

{enrollment.error}

)}
- {enrollment.setupConnector && ( - - enrollment.connectSource(scope, enrollment.setupConnector!.type, config) - } - /> - )} + ) } 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 4634cb0e4af..f0508c816a1 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 @@ -35,9 +35,6 @@ export function OrganizationIntegrationsSettings() { { value: 'people', label: 'People' }, ]} /> - {tab === 'providers' && ( - Allowed in Sim Search - )}
{tab === 'providers' && } {tab === 'people' && ( diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx index c38d53d5c93..2d303cea62f 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.test.tsx @@ -56,6 +56,7 @@ const providers: OrganizationSearchProviderSummary[] = [ approved: true, sourceCount: 0, status: 'waiting_for_connections', + issue: null, isSyncing: false, }, { @@ -63,6 +64,7 @@ const providers: OrganizationSearchProviderSummary[] = [ approved: false, sourceCount: 2, status: 'paused', + issue: null, isSyncing: false, }, ] @@ -127,9 +129,33 @@ async function click(label: string) { } describe('organization integration management entry', () => { + it('offers Drive account management before anyone has connected', async () => { + mocks.overview.mockReturnValue({ + data: { + providers: [ + { + connectorType: 'google_drive', + approved: true, + sourceCount: 0, + status: 'waiting_for_connections', + issue: null, + isSyncing: false, + }, + ], + }, + isPending: false, + }) + await render() + expect(document.querySelector('a[aria-label="Manage Google Drive"]')).toHaveAttribute( + 'href', + '/o/org-one/settings/integrations/providers/google_drive' + ) + expect(document.querySelector('a[aria-label="Set up Google Drive"]')).toBeNull() + expect(container.textContent).toContain('Waiting for connections') + }) it('shows the stable catalog with switches and separate setup and management links', async () => { await render() - expect(document.querySelector('a[aria-label="Set up Gmail"]')).toHaveAttribute( + expect(document.querySelector('a[aria-label="Manage Gmail"]')).toHaveAttribute( 'href', '/o/org-one/settings/integrations/providers/gmail' ) @@ -137,8 +163,9 @@ describe('organization integration management entry', () => { 'href', '/o/org-one/settings/integrations/providers/google_drive' ) - expect(container.textContent).toContain('Needs setup') - expect(container.textContent).toContain('2 sources') + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).not.toContain('Needs setup') + expect(container.textContent).toContain('Disabled') expect(container.textContent).toContain('Confluence') expect(container.textContent).not.toContain('Add integration') expect(document.querySelector('[aria-label="Allow Gmail in Sim Search"]')).toHaveAttribute( @@ -307,7 +334,7 @@ describe('organization integration management entry', () => { isPending: false, }) await render() - expect(container.textContent).toContain('Needs attention') + expect(container.textContent).toContain('Sync failed') expect(document.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx index a918636313c..406f1bfd564 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx @@ -4,8 +4,13 @@ import { useState } from 'react' import { ChipConfirmModal, ChipLink, ChipModalError, Switch, toast } from '@sim/emcn' import { SettingsPanel } from '@/components/settings/settings-panel' import { organizationRoutes } from '@/lib/navigation/paths' -import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' +import { + canConnectWithDefaults, + getConnectorAccessAvailability, + SEARCH_SOURCE_TYPES, +} from '@/lib/sim-search/connectors' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' @@ -97,12 +102,8 @@ export function OrganizationIntegrationsSetup() { ) const available = access.admin || access.members const hasSources = sourceCount > 0 - let description = hasSources - ? `${sourceCount} ${sourceCount === 1 ? 'source' : 'sources'}` - : approved - ? 'Needs setup' - : undefined - if (approved && provider?.status === 'needs_attention') description = 'Needs attention' + const manage = hasSources || canConnectWithDefaults(meta) + let description = provider ? organizationSearchStatusLabel(provider) : undefined if (!hasSources && availability.isIntegrationAvailabilityReady && !available) description = 'Unavailable in this deployment' return ( @@ -117,10 +118,10 @@ export function OrganizationIntegrationsSetup() { {(hasSources || (approved && available)) && ( - {hasSources ? 'Manage' : 'Set up'} + {manage ? 'Manage' : 'Set up'} )} { + it('describes the next step instead of calling all empty integrations unconfigured', () => { + expect(organizationSearchStatusLabel(provider)).toBe('Waiting for connections') + expect(organizationSearchStatusLabel({ ...provider, status: 'needs_setup' })).toBe( + 'Source not configured' + ) + expect( + organizationSearchStatusLabel({ ...provider, status: 'needs_setup', sourceCount: 1 }) + ).toBe('Waiting for first sync') + expect(organizationSearchStatusLabel({ ...provider, status: 'active', sourceCount: 1 })).toBe( + 'Enabled' + ) + }) + it.each([ + ['sync_failed', 'Sync failed'], + ['account_sync_incomplete', 'Some accounts are not up to date'], + ['document_indexing_failed', 'Some documents failed to index'], + ] as const)('describes %s and keeps concurrent recovery visible', (issue, label) => { + expect(organizationSearchStatusLabel({ ...provider, status: 'needs_attention', issue })).toBe( + label + ) + expect( + organizationSearchStatusLabel({ + ...provider, + status: 'needs_attention', + issue, + isSyncing: true, + }) + ).toBe(`Indexing · ${label}`) + }) + it('shows deactivation ahead of a retained failure', () => { + expect( + organizationSearchStatusLabel({ + ...provider, + approved: false, + status: 'needs_attention', + issue: 'sync_failed', + }) + ).toBe('Disabled') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts index 85a329f0467..547f0339812 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts @@ -1,15 +1,25 @@ import type { OrganizationSearchProviderSummary } from '@/lib/api/contracts/knowledge/connectors' const STATUS_LABELS: Record = { - needs_setup: 'Needs setup', - waiting_for_connections: 'Waiting for account connections', + needs_setup: 'Source not configured', + waiting_for_connections: 'Waiting for connections', indexing: 'Indexing', - needs_attention: 'Needs attention', + needs_attention: 'Sync failed', paused: 'Paused', - active: 'Syncing enabled', + active: 'Enabled', } export function organizationSearchStatusLabel(provider: OrganizationSearchProviderSummary): string { - if (!provider.approved) return 'Deactivated' + if (!provider.approved) return 'Disabled' + if (provider.status === 'needs_setup' && provider.sourceCount > 0) return 'Waiting for first sync' + if (provider.status === 'needs_attention') { + const error = + provider.issue === 'account_sync_incomplete' + ? 'Some accounts are not up to date' + : provider.issue === 'document_indexing_failed' + ? 'Some documents failed to index' + : 'Sync failed' + return provider.isSyncing ? `Indexing · ${error}` : error + } return STATUS_LABELS[provider.status] } diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx index 3873cc1d046..d4c214aeb1b 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.test.tsx @@ -39,6 +39,8 @@ vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ })) vi.mock('@/lib/sim-search/connectors', () => ({ canConnectPersonally: () => mocks.personal, + canConnectWithDefaults: (meta: { name: string }) => + ['Gmail', 'Google Calendar', 'Google Drive'].includes(meta.name), getConnectorAccessAvailability: () => mocks.access, })) vi.mock('@/lib/oauth', () => ({ @@ -53,6 +55,10 @@ vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: { google_drive: { name: 'Google Drive', auth: { mode: 'oauth', provider: 'google-drive' } }, gmail: { name: 'Gmail', auth: { mode: 'oauth', provider: 'google-email' } }, + google_calendar: { + name: 'Google Calendar', + auth: { mode: 'oauth', provider: 'google-calendar' }, + }, slack: { name: 'Slack', auth: { mode: 'oauth', provider: 'slack' } }, gitlab: { name: 'GitLab', auth: { mode: 'apiKey' } }, }, @@ -202,6 +208,56 @@ describe('organization provider management', () => { }) } + it.each(['gmail', 'google_calendar', 'google_drive'])( + 'lets %s wait for connections without requiring source setup', + async (connectorType) => { + mocks.overview.mockReturnValue({ + data: { + providers: [ + { + connectorType, + approved: true, + status: 'waiting_for_connections', + sourceCount: 0, + issue: null, + isSyncing: false, + }, + ], + }, + }) + mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) + mocks.sources.mockReturnValue({ data: [], isPending: false }) + await render(connectorType) + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).toContain( + 'Members connect their accounts from Integrations. Indexing starts automatically.' + ) + expect(container.textContent).not.toContain('Add source') + expect(container.textContent).not.toContain('Add sync configuration') + expect(container.querySelector('a[href="/o/org-one/integrations"]')).toHaveTextContent( + 'Open Integrations' + ) + expect(mocks.sources).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ) + await click('Advanced') + expect(container.textContent).toContain( + 'A sync configuration controls what gets indexed and how often.' + ) + expect(container.textContent).toContain('Add sync configuration') + await click('Add sync configuration') + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenLastCalledWith( + expect.objectContaining({ searchParams: expect.any(URLSearchParams) }) + ) + expect(mocks.updateUrl.mock.calls.at(-1)![0].searchParams.get('addConnector')).toBe( + connectorType + ) + }) + } + ) + it.each(['active', 'disabled'])( 'removes only Slack account setup after confirmation, including a %s option', async (status) => { @@ -269,7 +325,7 @@ describe('organization provider management', () => { }) it('uses named source links even when the admin has not reconnected their own account', async () => { - await render() + await render('google_drive', '?view=sources') expect(mocks.sources).toHaveBeenCalledWith( { kind: 'organization', organizationId: 'org-one' }, { connectorType: 'google_drive', search: '', enabled: true } @@ -301,7 +357,7 @@ describe('organization provider management', () => { it('does not claim a provider is empty before paginated source discovery finishes', async () => { const fetchNextPage = vi.fn() mocks.sources.mockReturnValue({ data: [], isPending: false, hasNextPage: true, fetchNextPage }) - await render() + await render('google_drive', '?view=sources') expect(container.textContent).not.toContain('No sources yet') await click('Load more') expect(fetchNextPage).toHaveBeenCalledOnce() @@ -317,14 +373,14 @@ describe('organization provider management', () => { hasNextPage: true, fetchNextPage, }) - await render() + await render('google_drive', '?view=sources') expect(container.textContent).toContain('Engineering handbook') expect(container.textContent).toContain('More sources unavailable') await click('Try again') expect(fetchNextPage).toHaveBeenCalledOnce() }) - it.each(['', '?view=accounts'])( + it.each(['', '?view=accounts', '?view=sources'])( 'renders overview loading without presenting missing configuration at %s', async (params) => { mocks.overview.mockReturnValue({ isPending: true }) @@ -334,7 +390,7 @@ describe('organization provider management', () => { expect(mocks.people).not.toHaveBeenCalled() expect( container.querySelector( - `input[placeholder="${params ? 'Search people...' : 'Search sources...'}"]` + `input[placeholder="${params === '?view=sources' ? 'Search sync configurations...' : 'Search people...'}"]` ) ).toBeEnabled() } @@ -388,8 +444,14 @@ describe('organization provider management', () => { it.each([ ['loading', 'Loading accounts…'], ['error', 'Accounts unavailable'], - ['missing group', 'Add a source to set up account connections.'], - ['missing provider option', 'Add a source to set up account connections.'], + [ + 'missing group', + 'Members connect their accounts from Integrations. Indexing starts automatically.', + ], + [ + 'missing provider option', + 'Members connect their accounts from Integrations. Indexing starts automatically.', + ], ])('preserves Accounts search while %s', async (state, message) => { const refetch = vi.fn() mocks.accounts.mockReturnValue( @@ -410,7 +472,7 @@ describe('organization provider management', () => { expect(container.textContent).toContain(message) expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() - expect(container.querySelector('input[placeholder="Search sources..."]')).toBeNull() + expect(container.querySelector('input[placeholder="Search sync configurations..."]')).toBeNull() expect(mocks.people).not.toHaveBeenCalled() if (state === 'error') { await click('Try again') @@ -419,19 +481,19 @@ describe('organization provider management', () => { const sourcesTab = Array.from( container.querySelectorAll('[role="radio"]') - ).find((item) => item.textContent === 'Sources') + ).find((item) => item.textContent === 'Advanced') expect(sourcesTab).toBeDefined() await act(async () => sourcesTab!.click()) - expect(container.querySelector('input[placeholder="Search sources..."]')).toHaveValue( - 'handbook' - ) + expect( + container.querySelector('input[placeholder="Search sync configurations..."]') + ).toHaveValue('handbook') await click('Accounts') expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') }) it('preserves source navigation and retries connection availability failures', async () => { mocks.availabilityError = new Error('Connection availability could not be loaded') - await render() + await render('google_drive', '?view=sources') expect(container.textContent).toContain('Connection availability could not be loaded') expect(container.querySelector('a[aria-label="Open Engineering handbook"]')).toHaveAttribute( 'href', @@ -491,10 +553,14 @@ describe('organization provider management', () => { data: { providers: [{ ...provider, connectorType: type }] }, }) await render(type) - await click('Add source') - const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) - expect(query.get('addConnector')).toBe(type) - expect(query.get('source-access')).toBe(memberParam ? 'members' : null) + await click('Advanced') + await click('Add sync configuration') + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenCalled() + const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) + expect(query.get('addConnector')).toBe(type) + expect(query.get('source-access')).toBe(memberParam ? 'members' : null) + }) } ) @@ -506,10 +572,12 @@ describe('organization provider management', () => { mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) await render('slack') await click('Set up Slack app') - await vi.waitFor(() => expect(mocks.updateUrl).toHaveBeenCalled()) - const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) - expect(query.get('connectedAccounts')).toBe('slack') - expect(query.has('addConnector')).toBe(false) + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenCalled() + const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) + expect(query.get('connectedAccounts')).toBe('slack') + expect(query.has('addConnector')).toBe(false) + }) }) it.each([ diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx index 8080d989f0a..d1002eb31b2 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { ChipConfirmModal, ChipModalError, ChipSwitch } from '@sim/emcn' +import { ChipConfirmModal, ChipLink, ChipModalError, ChipSwitch } from '@sim/emcn' import { ArrowLeft, Plus } from '@sim/emcn/icons' import { format } from 'date-fns' import { useRouter } from 'next/navigation' @@ -11,7 +11,11 @@ import { SettingsPanel } from '@/components/settings/settings-panel' import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' import { organizationRoutes } from '@/lib/navigation/paths' import { getServiceConfigByProviderId, getServiceConfigByServiceId } from '@/lib/oauth' -import { canConnectPersonally, getConnectorAccessAvailability } from '@/lib/sim-search/connectors' +import { + canConnectPersonally, + canConnectWithDefaults, + getConnectorAccessAvailability, +} from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' @@ -52,9 +56,11 @@ interface OrganizationProviderDetailProps { export function OrganizationProviderDetail({ connectorType }: OrganizationProviderDetailProps) { const { organization, viewer, searchAccess } = useOrganizationContext() const router = useRouter() + const meta = CONNECTOR_META_REGISTRY[connectorType] + const automaticSetup = Boolean(meta && canConnectWithDefaults(meta) && searchAccess.memberScoped) const [view, setView] = useQueryState( organizationProviderTabParam.key, - organizationProviderTabParam.parser + organizationProviderTabParam.parser.withDefault(automaticSetup ? 'accounts' : 'sources') ) const [search, setSearch] = useSettingsSearch() const [peopleSearch, setPeopleSearch] = useOrganizationAccountPeopleSearch() @@ -62,7 +68,6 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const [deactivating, setDeactivating] = useState(false) const [removingSlackAccounts, setRemovingSlackAccounts] = useState(false) const scope = { kind: 'organization', organizationId: organization.id } as const - const meta = CONNECTOR_META_REGISTRY[connectorType] const personal = Boolean(meta && canConnectPersonally(meta) && searchAccess.memberScoped) const showAccounts = view === 'accounts' && personal const overview = useOrganizationSearchOverview(organization.id, { enabled: viewer.isAdmin }) @@ -100,7 +105,11 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid if (!viewer.isAdmin || !meta) return null const searchField = showAccounts ? { value: peopleSearch, onChange: setPeopleSearch, placeholder: 'Search people...' } - : { value: search, onChange: setSearch, placeholder: 'Search sources...' } + : { + value: search, + onChange: setSearch, + placeholder: automaticSetup ? 'Search sync configurations...' : 'Search sources...', + } const panel = { back, title: meta.name, @@ -156,10 +165,14 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid approval.mutate({ organizationId: organization.id, connectorType, approved: true }) const actions: SettingsAction[] = approved ? [ - ...(access.admin || access.members + ...((access.admin || access.members) && (!automaticSetup || !showAccounts) ? [ { - text: needsSlackSetup ? 'Set up Slack app' : 'Add source', + text: needsSlackSetup + ? 'Set up Slack app' + : automaticSetup + ? 'Add sync configuration' + : 'Add source', icon: Plus, variant: 'primary' as const, disabled: @@ -208,6 +221,12 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const renderSources = () => ( + {automaticSetup && ( + + A sync configuration controls what gets indexed and how often. Connecting the first + account creates the default configuration automatically. + + )} {approval.error && ( {approval.error.message} @@ -253,12 +272,16 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid : !source.enabled ? 'Paused' : source.hasSyncError - ? 'Needs attention' - : source.isSyncing - ? 'Indexing' - : source.lastSyncAt - ? `Last synced ${format(new Date(source.lastSyncAt), 'MMM d, h:mm a')}` - : 'Waiting for the first sync' + ? source.isSyncing + ? 'Indexing · Previous sync failed' + : 'Sync failed' + : source.viewerFailedDocumentCount > 0 + ? `${source.viewerFailedDocumentCount} ${source.viewerFailedDocumentCount === 1 ? 'document' : 'documents'} failed to index` + : source.isSyncing + ? 'Indexing' + : source.lastSyncAt + ? `Last synced ${format(new Date(source.lastSyncAt), 'MMM d, h:mm a')}` + : 'Waiting for the first sync' } href={organizationRoutes(organization.id).searchSource(source.connectorId)} clickLabel={`Open ${source.sourceDescription || meta.name}`} @@ -271,7 +294,9 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid ? 'No matching sources' : !approved ? 'Activate this integration to set up sources.' - : 'No sources yet.'} + : automaticSetup + ? 'No accounts connected yet. A sync configuration will be created when someone connects.' + : 'No sources yet.'} )} @@ -288,10 +313,17 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid aria-label={`${meta.name} settings`} value={view} onChange={(value) => void setView(value)} - options={[ - { value: 'sources', label: 'Sources' }, - { value: 'accounts', label: 'Accounts' }, - ]} + options={ + automaticSetup + ? [ + { value: 'accounts', label: 'Accounts' }, + { value: 'sources', label: 'Advanced' }, + ] + : [ + { value: 'sources', label: 'Sources' }, + { value: 'accounts', label: 'Accounts' }, + ] + } /> )} @@ -336,9 +368,16 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid {approved ? needsSlackSetup ? 'Set up the Slack app to connect accounts.' - : 'Add a source to set up account connections.' + : automaticSetup + ? 'Members connect their accounts from Integrations. Indexing starts automatically.' + : 'Add a source to set up account connections.' : 'Activate this integration to set up account connections.'} + {automaticSetup && approved && ( + + Open Integrations + + )} ) ) : ( diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx index 7670e68e7e6..648ca14f1f6 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx @@ -192,7 +192,7 @@ function SourceDetailContent({ : effectiveStatus === 'disabled' ? 'Sync disabled' : effectiveStatus === 'error' - ? 'Sync needs attention' + ? 'Sync failed' : undefined const description = [title === meta?.name ? undefined : meta?.name, status].filter(Boolean).join(' · ') || undefined diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx index 235965ee47f..bb4e0c18188 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.test.tsx @@ -155,9 +155,9 @@ describe('Search source viewer actions', () => { it.each([ { change: { hasSyncError: true, viewerDocumentCount: 4 }, - status: 'Sync needs attention · 4 searchable documents', + status: 'Sync failed · 4 searchable documents', }, - { change: { hasSyncError: true }, status: 'Sync needs admin attention' }, + { change: { hasSyncError: true }, status: 'Sync failed' }, { change: { viewerFailedDocumentCount: 1 }, status: "1 document couldn't be indexed", diff --git a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx index edb387dc829..220ff3edd4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/search/components/search-source-row.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ReactNode } from 'react' import { Chip, ChipLink } from '@sim/emcn' import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' import { type ResourceScope, resourceScopeFromOwner } from '@/lib/core/resource-scope' @@ -19,9 +20,11 @@ interface SearchSourceRowProps { waiting: boolean isPending: boolean onConnect: () => void + connectLabel?: string manageHref?: string /** Opens management for the source; only a surface that offers management passes it. */ onManage?: () => void + accountActions?: ReactNode } /** Source health and the viewer's connection are separate; only the viewer's next action is primary. */ @@ -34,8 +37,10 @@ export function SearchSourceRow({ waiting, isPending, onConnect, + connectLabel = 'Connect account', manageHref, onManage, + accountActions, }: SearchSourceRowProps) { const scope = explicitScope ?? resourceScopeFromOwner({ workspaceId }) const meta = CONNECTOR_META_REGISTRY[source.connectorType] @@ -70,10 +75,7 @@ export function SearchSourceRow({ ? 'Your account needs to be reconnected' : 'Connect your account to search this source' else if (source.hasSyncError) - status = - source.viewerDocumentCount > 0 - ? `Sync needs attention · ${count}` - : 'Sync needs admin attention' + status = source.viewerDocumentCount > 0 ? `Sync failed · ${count}` : 'Sync failed' else if (source.viewerFailedDocumentCount > 0) status = `${source.viewerFailedDocumentCount} document${source.viewerFailedDocumentCount === 1 ? '' : 's'} couldn't be indexed${source.viewerDocumentCount > 0 ? ` · ${count}` : ''}` else if (source.isSyncing) @@ -108,7 +110,7 @@ export function SearchSourceRow({ ? 'Open again' : membership === 'needs_reauth' ? 'Reconnect' - : 'Connect account'} + : connectLabel} )} {canAdmin && @@ -122,6 +124,7 @@ export function SearchSourceRow({ ) : ( Manage ))} + {accountActions} ) } diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index 3a79d0be8e6..675d6e3e4f1 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -44,6 +44,7 @@ import { readSearchIndexContract, readSearchSourceOverviewContract, readSearchSourceProgressContract, + type SearchConnectionOAuthQuery, type SearchSourcePage, type SearchSourceProgress, } from '@/lib/api/contracts/knowledge/connectors' @@ -418,7 +419,7 @@ async function updateConnectorAccess({ return result.data } -interface StartConnectorMemberEnrollmentParams { +interface StartConnectorMemberEnrollmentParams extends SearchConnectionOAuthQuery { knowledgeBaseId: string connectorId: string } @@ -426,9 +427,11 @@ interface StartConnectorMemberEnrollmentParams { async function startConnectorMemberEnrollment({ knowledgeBaseId, connectorId, + oauthCompletionId, }: StartConnectorMemberEnrollmentParams): Promise { const response = await requestJson(startKnowledgeConnectorMemberEnrollmentContract, { params: { id: knowledgeBaseId, connectorId }, + query: { oauthCompletionId }, }) return response.data } diff --git a/apps/sim/hooks/queries/organization-accounts.test.tsx b/apps/sim/hooks/queries/organization-accounts.test.tsx index d26b2bb931d..1a45f1fd069 100644 --- a/apps/sim/hooks/queries/organization-accounts.test.tsx +++ b/apps/sim/hooks/queries/organization-accounts.test.tsx @@ -10,17 +10,86 @@ vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.request })) import { ApiClientError } from '@/lib/api/client/errors' import { + disconnectPersonalOrganizationAccountContract, listOrganizationAccountPeopleContract, updateOrganizationAccountsContract, } from '@/lib/api/contracts/organization-accounts' import { organizationAccountsKeys, + useDisconnectPersonalOrganizationAccount, useOrganizationAccountPeople, useUpdateOrganizationAccounts, } from '@/hooks/queries/organization-accounts' import { slackSearchKeys } from '@/hooks/queries/slack-search' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' +describe('personal account disconnect', () => { + it.each([true, false])( + 'refreshes this organization only after success=%s, including after unmount', + async (success) => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.request.mockReset() + const response = Promise.withResolvers<{ success: true }>() + mocks.request.mockReturnValue(response.promise) + const client = new QueryClient() + const root = createRoot(document.createElement('div')) + let mutation: ReturnType + function Probe() { + mutation = useDisconnectPersonalOrganizationAccount('org-1') + return null + } + const own = searchSourceKeys.pages( + { kind: 'organization', organizationId: 'org-1' }, + { mine: true, search: '' } + ) + const catalog = searchSourceKeys.pages( + { kind: 'organization', organizationId: 'org-1' }, + { mine: false, search: '' } + ) + const people = organizationAccountsKeys.people('org-1') + const other = searchSourceKeys.list({ kind: 'organization', organizationId: 'org-2' }) + for (const key of [own, catalog, people, other]) client.setQueryData(key, { existing: true }) + try { + await act(async () => + root.render( + + + + ) + ) + let pending: Promise + await act(async () => { + pending = mutation.mutateAsync('own-credential') + }) + await act(async () => + root.render({null}) + ) + await act(async () => { + if (success) { + response.resolve({ success: true }) + await pending + } else { + const rejection = expect(pending).rejects.toThrow('Try again') + response.reject(new Error('Try again')) + await rejection + } + }) + expect(mocks.request).toHaveBeenCalledExactlyOnceWith( + disconnectPersonalOrganizationAccountContract, + { params: { credentialId: 'own-credential' } } + ) + for (const key of [own, catalog, people]) + expect(client.getQueryState(key)?.isInvalidated).toBe(success) + expect(client.getQueryState(other)?.isInvalidated).toBe(false) + } finally { + await act(async () => root.unmount()) + client.clear() + vi.unstubAllGlobals() + } + } + ) +}) + describe('organization account setup updates', () => { it.each([true, false])( 'refreshes only this organization’s setup after the caller unmounts on success=%s', diff --git a/apps/sim/hooks/queries/organization-accounts.ts b/apps/sim/hooks/queries/organization-accounts.ts index ea781965c97..d6c08965dd8 100644 --- a/apps/sim/hooks/queries/organization-accounts.ts +++ b/apps/sim/hooks/queries/organization-accounts.ts @@ -14,6 +14,7 @@ import { addOrganizationAccountMcpProviderContract, type ConfigureOrganizationMcpBody, configureOrganizationMcpContract, + disconnectPersonalOrganizationAccountContract, type EnsureOrganizationAccountsBody, ensureOrganizationAccountsContract, getOrganizationAccountsContract, @@ -40,6 +41,26 @@ import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' export const ORGANIZATION_ACCOUNTS_STALE_TIME = 30_000 +/** Disconnects an owned grant; indexing and source setup do not gate this operation. */ +export function useDisconnectPersonalOrganizationAccount(organizationId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (credentialId: string) => + requestJson(disconnectPersonalOrganizationAccountContract, { + params: { credentialId }, + }), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ + queryKey: searchSourceKeys.list({ kind: 'organization', organizationId }), + }), + queryClient.invalidateQueries({ + queryKey: organizationAccountsKeys.detail(organizationId), + }), + ]), + }) +} + export const organizationAccountsKeys = { all: ['organization-accounts'] as const, workspaces: () => [...organizationAccountsKeys.all, 'workspace'] as const, diff --git a/apps/sim/hooks/use-member-enrollment.test.tsx b/apps/sim/hooks/use-member-enrollment.test.tsx index f82c4e036a0..9fe93f8ef8f 100644 --- a/apps/sim/hooks/use-member-enrollment.test.tsx +++ b/apps/sim/hooks/use-member-enrollment.test.tsx @@ -9,6 +9,11 @@ const mocks = vi.hoisted(() => ({ enrollmentMutate: vi.fn(), sourceConnectionMutate: vi.fn(), invalidateQueries: vi.fn(), + channels: [] as Array<{ + name: string + onmessage: ((event: MessageEvent) => void) | null + close: ReturnType + }>, })) vi.mock('@tanstack/react-query', () => ({ @@ -40,17 +45,27 @@ let root: Root | null = null let container: HTMLDivElement | null = null let enrollmentTab: { location: { href: string }; closed: boolean; close: () => void } -function Harness({ connected }: { connected: ReadonlySet }) { - latest = useMemberEnrollment({ membershipQueryKeys: [], connectedConnectorIds: connected }) +function Harness({ + connected, + directOAuth, +}: { + connected: ReadonlySet + directOAuth?: boolean +}) { + latest = useMemberEnrollment({ + membershipQueryKeys: [], + connectedConnectorIds: connected, + directOAuth, + }) return null } -function mount(connected: ReadonlySet = new Set()) { +function mount(connected: ReadonlySet = new Set(), directOAuth = false) { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - act(() => root?.render()) + act(() => root?.render()) } function enrollment(): Enrollment { @@ -61,6 +76,17 @@ function enrollment(): Enrollment { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() + mocks.channels.length = 0 + vi.stubGlobal( + 'BroadcastChannel', + class { + onmessage: ((event: MessageEvent) => void) | null = null + close = vi.fn() + constructor(public name: string) { + mocks.channels.push(this) + } + } + ) enrollmentTab = { location: { href: '' }, closed: false, @@ -77,9 +103,92 @@ afterEach(() => { latest = null vi.useRealTimers() vi.restoreAllMocks() + vi.unstubAllGlobals() }) describe('useMemberEnrollment', () => { + it('opens provider OAuth and waits for its own completion even if the account was already connected', () => { + mount(new Set(['connector-1']), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + const [input, handlers] = mocks.enrollmentMutate.mock.calls[0] + expect(input.oauthCompletionId).toMatch(/^[a-f\d-]{36}$/) + expect(mocks.channels[0].name).toBe(`sim:credential-group-oauth:${input.oauthCompletionId}`) + act(() => handlers.onSuccess({ url: 'https://provider.test/authorize' })) + expect(enrollmentTab.location.href).toBe('https://provider.test/authorize') + act(() => vi.advanceTimersByTime(4_000)) + expect(enrollment().isAwaiting('connector-1')).toBe(true) + act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'connected' }))) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().error).toBeNull() + expect(mocks.invalidateQueries).toHaveBeenCalled() + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + }) + + it('keeps overlapping provider authorizations separate and reports a rejected one on the original page', () => { + mount(new Set(), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ url: 'https://provider.test/one' }) + ) + act(() => enrollment().connect('kb-1', 'connector-2')) + act(() => + mocks.enrollmentMutate.mock.calls[1][1].onSuccess({ url: 'https://provider.test/two' }) + ) + expect(mocks.channels[0].name).not.toBe(mocks.channels[1].name) + act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'denied' }))) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().isAwaiting('connector-2')).toBe(true) + expect(enrollment().error).toContain('Authorization was canceled') + act(() => mocks.channels[1].onmessage?.(new MessageEvent('message', { data: 'unrecognized' }))) + expect(enrollment().isAwaiting('connector-2')).toBe(true) + }) + + it('keeps listening for OAuth completion when provider isolation reports a closed window', () => { + mount(new Set(), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ url: 'https://provider.test/authorize' }) + ) + enrollmentTab.closed = true + act(() => vi.advanceTimersByTime(4_000)) + expect(enrollment().isAwaiting('connector-1')).toBe(true) + expect(enrollment().error).toBeNull() + act(() => mocks.channels[0].onmessage?.(new MessageEvent('message', { data: 'connected' }))) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().error).toBeNull() + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + }) + + it('stops waiting with an actionable error when direct OAuth expires', () => { + mount(new Set(), true) + act(() => enrollment().connect('kb-1', 'connector-1')) + act(() => + mocks.enrollmentMutate.mock.calls[0][1].onSuccess({ + url: 'https://provider.test/authorize', + }) + ) + act(() => vi.advanceTimersByTime(10 * 60_000)) + expect(enrollment().isAwaiting('connector-1')).toBe(false) + expect(enrollment().error).toContain('expired') + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + }) + + it('passes direct authorization correlation through first-source setup and cleans it up on failure', () => { + mount(new Set(), true) + act(() => + enrollment().connectSource({ kind: 'organization', organizationId: 'org-1' }, 'gmail') + ) + const [input, handlers] = mocks.sourceConnectionMutate.mock.calls[0] + expect(input).toMatchObject({ + organizationId: 'org-1', + connectorType: 'gmail', + oauthCompletionId: expect.any(String), + }) + act(() => handlers.onError(new Error('Unavailable'))) + expect(mocks.channels[0].close).toHaveBeenCalledOnce() + expect(enrollmentTab.close).toHaveBeenCalledOnce() + }) + it.each(['blocked', 'failed', 'closed', 'success'] as const)( 'retains source setup until enrollment navigation succeeds: %s', (outcome) => { diff --git a/apps/sim/hooks/use-member-enrollment.ts b/apps/sim/hooks/use-member-enrollment.ts index e9e41586254..4d1b2b14ac1 100644 --- a/apps/sim/hooks/use-member-enrollment.ts +++ b/apps/sim/hooks/use-member-enrollment.ts @@ -1,9 +1,15 @@ 'use client' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { type QueryKey, useQueryClient } from '@tanstack/react-query' import { type ResourceScope, resourceScopeFields } from '@/lib/core/resource-scope' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + credentialGroupOAuthCompletionChannel, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' import type { MemberSyncStatus } from '@/lib/knowledge/types' import type { SearchConnector } from '@/lib/sim-search/connectors' import { @@ -93,6 +99,7 @@ interface AwaitingEnrollment { * can be told it is awaited before its membership row exists to look it up by. */ connectorType: string | null + oauthCompletionId?: string } interface UseMemberEnrollmentProps { @@ -100,6 +107,8 @@ interface UseMemberEnrollmentProps { membershipQueryKeys: readonly QueryKey[] /** Connector ids the viewer is now connected to; awaiting stops for them. */ connectedConnectorIds: ReadonlySet + /** Main Integrations skips the invitation page; invitation-based surfaces keep their flow. */ + directOAuth?: boolean } /** @@ -116,8 +125,12 @@ interface UseMemberEnrollmentProps { export function useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds, + directOAuth = false, }: UseMemberEnrollmentProps) { const connectedRef = useRef(connectedConnectorIds) + const oauthPopups = useRef( + new Map }>() + ) const queryClient = useQueryClient() const enrollment = useStartConnectorMemberEnrollment() const sourceConnection = useConnectSimSearchConnector() @@ -125,6 +138,39 @@ export function useMemberEnrollment({ () => new Map() ) const [popupBlocked, setPopupBlocked] = useState(false) + const [oauthError, setOAuthError] = useState(null) + + const refreshMemberships = useCallback(() => { + for (const queryKey of membershipQueryKeys) { + void queryClient.invalidateQueries({ queryKey }) + } + void queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) + }, [membershipQueryKeys, queryClient]) + + const finishOAuth = (completionId: string, error: string | null) => { + const popup = oauthPopups.current.get(completionId) + if (!popup) return + clearTimeout(popup.timer) + popup.channel.close() + oauthPopups.current.delete(completionId) + setAwaitingSince( + (current) => + new Map([...current].filter(([, entry]) => entry.oauthCompletionId !== completionId)) + ) + setOAuthError(error) + refreshMemberships() + } + + useEffect(() => { + const popups = oauthPopups.current + return () => { + for (const popup of popups.values()) { + clearTimeout(popup.timer) + popup.channel.close() + } + popups.clear() + } + }, []) useEffect(() => { connectedRef.current = connectedConnectorIds @@ -134,6 +180,8 @@ export function useMemberEnrollment({ * Polls while any connection is awaited, and once more after the last one * connects: that tick drops the connected ids, so a token that later needs * reauthorization is not mistaken for a connection still being awaited. + * Direct OAuth waits for its completion message: provider window isolation + * can report a closed handle while authorization is still in progress. */ const awaiting = awaitingSince.size > 0 useEffect(() => { @@ -143,25 +191,23 @@ export function useMemberEnrollment({ setAwaitingSince((current) => { const next = new Map( [...current].filter( - ([id, { since, tab }]) => - !tab.closed && - !connectedRef.current.has(id) && + ([id, { since, tab, oauthCompletionId }]) => + (Boolean(oauthCompletionId) || !tab.closed) && + (Boolean(oauthCompletionId) || !connectedRef.current.has(id)) && now - since < AWAITING_CONNECTION_TIMEOUT_MS ) ) return next.size === current.size ? current : next }) - for (const queryKey of membershipQueryKeys) { - void queryClient.invalidateQueries({ queryKey }) - } - void queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() }) + refreshMemberships() }, AWAITING_CONNECTION_POLL_MS) return () => clearInterval(timer) - }, [awaiting, membershipQueryKeys, queryClient]) + }, [awaiting, refreshMemberships]) /** Opens the tab inside the click, then sends it wherever `start` mints. */ const openEnrollment = ( start: (handlers: { + oauthCompletionId?: string onSuccess: (url: string, connectorId: string, connectorType?: string) => boolean onError: () => void }) => void @@ -173,27 +219,50 @@ export function useMemberEnrollment({ } tab.opener = null setPopupBlocked(false) + setOAuthError(null) + const oauthCompletionId = directOAuth ? generateId() : undefined + if (oauthCompletionId) { + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(oauthCompletionId)) + channel.onmessage = ({ data }: MessageEvent) => { + if (data === 'connected') finishOAuth(oauthCompletionId, null) + else if (isCredentialGroupOAuthFailure(data)) + finishOAuth(oauthCompletionId, CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[data]) + } + const timer = setTimeout(() => { + finishOAuth(oauthCompletionId, CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES.expired) + }, AWAITING_CONNECTION_TIMEOUT_MS) + oauthPopups.current.set(oauthCompletionId, { channel, timer }) + } start({ + ...(oauthCompletionId ? { oauthCompletionId } : {}), onSuccess: (url, connectorId, connectorType) => { - if (tab.closed) return false + if (tab.closed || (oauthCompletionId && !oauthPopups.current.has(oauthCompletionId))) { + if (oauthCompletionId) + finishOAuth(oauthCompletionId, CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES.denied) + return false + } tab.location.href = url setAwaitingSince((current) => new Map(current).set(connectorId, { since: Date.now(), tab, connectorType: connectorType ?? null, + ...(oauthCompletionId ? { oauthCompletionId } : {}), }) ) return true }, - onError: () => tab.close(), + onError: () => { + if (oauthCompletionId) finishOAuth(oauthCompletionId, null) + tab.close() + }, }) } const connect = (knowledgeBaseId: string, connectorId: string) => - openEnrollment(({ onSuccess, onError }) => { + openEnrollment(({ onSuccess, onError, oauthCompletionId }) => { enrollment.mutate( - { knowledgeBaseId, connectorId }, + { knowledgeBaseId, connectorId, ...(oauthCompletionId ? { oauthCompletionId } : {}) }, { onSuccess: ({ url }) => onSuccess(url, connectorId), onError: (err) => { @@ -214,12 +283,13 @@ export function useMemberEnrollment({ connectorType: string, sourceConfig?: Record ) => - openEnrollment(({ onSuccess, onError }) => { + openEnrollment(({ onSuccess, onError, oauthCompletionId }) => { sourceConnection.mutate( { ...(typeof owner === 'string' ? { workspaceId: owner } : resourceScopeFields(owner)), connectorType, sourceConfig, + ...(oauthCompletionId ? { oauthCompletionId } : {}), }, { onSuccess: ({ url, connectorId }) => { @@ -257,7 +327,9 @@ export function useMemberEnrollment({ } const isAwaiting = (connectorId: string) => - awaitingSince.has(connectorId) && !connectedConnectorIds.has(connectorId) + awaitingSince.has(connectorId) && + (Boolean(awaitingSince.get(connectorId)?.oauthCompletionId) || + !connectedConnectorIds.has(connectorId)) /** * Whether a Sim Search source is awaited by the connect that created its @@ -266,7 +338,9 @@ export function useMemberEnrollment({ */ const isAwaitingSource = (connectorType: string) => [...awaitingSince].some( - ([id, awaiting]) => awaiting.connectorType === connectorType && !connectedConnectorIds.has(id) + ([id, awaiting]) => + awaiting.connectorType === connectorType && + (Boolean(awaiting.oauthCompletionId) || !connectedConnectorIds.has(id)) ) /** The surface reports the latest attempt, whichever path made it. */ @@ -281,6 +355,6 @@ export function useMemberEnrollment({ isAwaiting, isAwaitingSource, isPending: enrollment.isPending || sourceConnection.isPending, - error: popupBlocked ? POPUP_BLOCKED_MESSAGE : (latest.error?.message ?? null), + error: popupBlocked ? POPUP_BLOCKED_MESSAGE : (oauthError ?? latest.error?.message ?? null), } } diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index ded0a02984b..438765f3c7b 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -19,6 +19,7 @@ import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_SEARCH_LENGTH, MAX_SEARCH_SOURCE_PROGRESS_ITEMS, MAX_SEARCH_SOURCE_PROVIDER_TYPES, + SEARCH_SOURCE_CANDIDATE_PAGE_SIZE, SEARCH_SOURCE_PAGE_SIZE, } from '@/lib/knowledge/constants' import { MEMBER_SYNC_STATUSES } from '@/lib/knowledge/types' @@ -295,17 +296,23 @@ export const updateKnowledgeConnectorAccessContract = defineRouteContract({ }) export const startKnowledgeConnectorMemberEnrollmentDataSchema = z.object({ - /** The viewer's enrollment link; opening it connects their account. */ + /** The viewer's invitation link or direct provider authorization URL. */ url: z.string().url(), }) export type StartKnowledgeConnectorMemberEnrollmentData = z.output< typeof startKnowledgeConnectorMemberEnrollmentDataSchema > +export const searchConnectionOAuthQuerySchema = z.object({ + oauthCompletionId: z.string().uuid().optional(), +}) +export type SearchConnectionOAuthQuery = z.input + export const startKnowledgeConnectorMemberEnrollmentContract = defineRouteContract({ method: 'POST', path: '/api/knowledge/[id]/connectors/[connectorId]/enroll', params: knowledgeConnectorParamsSchema, + query: searchConnectionOAuthQuerySchema, response: { mode: 'json', schema: successResponseSchema(startKnowledgeConnectorMemberEnrollmentDataSchema), @@ -342,6 +349,14 @@ const searchSourceSummaryFields = { viewerDocumentCount: z.number().int().nonnegative(), viewerFailedDocumentCount: z.number().int().nonnegative().default(0), viewerEmailVerified: z.boolean(), + viewerAccounts: z + .array( + z.object({ + credentialId: z.string().min(1).max(128), + displayName: z.string(), + }) + ) + .max(SEARCH_SOURCE_CANDIDATE_PAGE_SIZE), } export const searchSourceSummarySchema = z.discriminatedUnion('connectionRequired', [ @@ -357,6 +372,7 @@ export const searchSourceSummarySchema = z.discriminatedUnion('connectionRequire }), ]) export type SearchSourceSummary = z.output +export type ViewerSearchSourceAccount = SearchSourceSummary['viewerAccounts'][number] export const searchSourceCursorSchema = z.object({ createdAt: z.string().datetime(), @@ -422,6 +438,7 @@ export const organizationSearchProviderSummarySchema = z.object({ approved: z.boolean(), sourceCount: z.number().int().nonnegative(), status: organizationSearchProviderStatusSchema, + issue: z.enum(['sync_failed', 'account_sync_incomplete', 'document_indexing_failed']).nullable(), isSyncing: z.boolean(), }) export type OrganizationSearchProviderSummary = z.output< @@ -477,6 +494,7 @@ export const connectSimSearchConnectorBodySchema = resourceOwnerSchema.safeExten connectorId: knowledgeConnectorParamsSchema.shape.connectorId.max(255).optional(), /** Settings identify a compatible source, or assert the configuration of a selected source. */ sourceConfig: z.record(z.string(), z.string().max(500)).optional(), + oauthCompletionId: searchConnectionOAuthQuerySchema.shape.oauthCompletionId, }) export type ConnectSimSearchConnectorBody = z.input diff --git a/apps/sim/lib/credential-groups/oauth-completion.ts b/apps/sim/lib/credential-groups/oauth-completion.ts new file mode 100644 index 00000000000..31225358b61 --- /dev/null +++ b/apps/sim/lib/credential-groups/oauth-completion.ts @@ -0,0 +1,26 @@ +import { isValidUuid } from '@sim/utils/id' + +export const CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES = { + expired: 'This connection attempt expired. Try connecting your account again.', + denied: 'Authorization was canceled. Try connecting your account again.', + account_mismatch: 'Choose the account matching your Sim email address.', + permissions_required: 'All requested permissions are required to connect this account.', + configuration_changed: 'The connection settings changed. Try connecting your account again.', + rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', + unavailable: 'This connection is unavailable. Try connecting your account again.', + failed: 'Account authorization did not complete. Try connecting your account again.', +} as const + +export type CredentialGroupOAuthFailure = keyof typeof CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES + +/** Correlation only: completion notifications trigger authoritative query refreshes, never grants. */ +export function credentialGroupOAuthCompletionChannel(completionId: string): string { + if (!isValidUuid(completionId)) throw new Error('Invalid OAuth completion ID') + return `sim:credential-group-oauth:${completionId}` +} + +export function isCredentialGroupOAuthFailure( + value: unknown +): value is CredentialGroupOAuthFailure { + return typeof value === 'string' && Object.hasOwn(CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, value) +} diff --git a/apps/sim/lib/credential-groups/oauth-state.test.ts b/apps/sim/lib/credential-groups/oauth-state.test.ts index 31b7ab7ae03..799f7157e65 100644 --- a/apps/sim/lib/credential-groups/oauth-state.test.ts +++ b/apps/sim/lib/credential-groups/oauth-state.test.ts @@ -71,6 +71,7 @@ describe('credential group OAuth state', () => { codeVerifier: 'code-verifier', invitationToken: 'invitation-token', completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', }) const stored = [...values.values()][0] @@ -91,6 +92,7 @@ describe('credential group OAuth state', () => { codeVerifier: 'code-verifier', invitationToken: 'invitation-token', completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', }) expect(credentialGroupOAuthNonceMatches(created.nonce, consumed?.nonceHash ?? '')).toBe(true) await expect(consumeCredentialGroupOAuthAttempt(created.state)).resolves.toBeNull() diff --git a/apps/sim/lib/credential-groups/oauth-state.ts b/apps/sim/lib/credential-groups/oauth-state.ts index 53bd32c0c47..38f726ecfc6 100644 --- a/apps/sim/lib/credential-groups/oauth-state.ts +++ b/apps/sim/lib/credential-groups/oauth-state.ts @@ -1,6 +1,6 @@ import { safeCompare } from '@sim/security/compare' import { sha256Hex } from '@sim/security/hash' -import { generateId } from '@sim/utils/id' +import { generateId, isValidUuid } from '@sim/utils/id' import { getRedisClient } from '@/lib/core/config/redis' import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' @@ -38,6 +38,7 @@ interface StoredCredentialGroupOAuthAttempt { requiredScopes: string[] redirectUri: string completionRedirect?: boolean + completionId?: string returnTo?: 'search' | 'accounts' nonceHash: string encryptedCodeVerifier?: string @@ -61,6 +62,7 @@ export interface CredentialGroupOAuthAttempt { requiredScopes: string[] redirectUri: string completionRedirect?: boolean + completionId?: string returnTo?: 'search' | 'accounts' codeVerifier?: string invitationToken: string @@ -81,6 +83,7 @@ interface CreateCredentialGroupOAuthAttemptParams { requiredScopes: string[] redirectUri: string completionRedirect?: boolean + completionId?: string returnTo?: 'search' | 'accounts' codeVerifier?: string invitationToken: string @@ -129,6 +132,10 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt typeof candidate.redirectUri === 'string' && (candidate.completionRedirect === undefined || typeof candidate.completionRedirect === 'boolean') && + (candidate.completionId === undefined || + (candidate.completionRedirect === true && + typeof candidate.completionId === 'string' && + isValidUuid(candidate.completionId))) && (candidate.returnTo === undefined || candidate.returnTo === 'search' || candidate.returnTo === 'accounts') && @@ -144,6 +151,12 @@ function isStoredAttempt(value: unknown): value is StoredCredentialGroupOAuthAtt export async function createCredentialGroupOAuthAttempt( params: CreateCredentialGroupOAuthAttemptParams ): Promise<{ state: string; nonce: string }> { + if ( + params.completionId !== undefined && + (!params.completionRedirect || !isValidUuid(params.completionId)) + ) { + throw new Error('OAuth completion requires a valid correlation ID and completion redirect') + } const redis = requireRedis() const state = `${OAUTH_ATTEMPT_STATE_PREFIX}${generateId()}` const nonce = generateId() @@ -165,6 +178,7 @@ export async function createCredentialGroupOAuthAttempt( requiredScopes: params.requiredScopes, redirectUri: params.redirectUri, ...(params.completionRedirect ? { completionRedirect: true } : {}), + ...(params.completionId ? { completionId: params.completionId } : {}), ...(params.returnTo ? { returnTo: params.returnTo } : {}), nonceHash: sha256Hex(nonce), ...(encryptedCodeVerifier ? { encryptedCodeVerifier: encryptedCodeVerifier.encrypted } : {}), @@ -220,6 +234,7 @@ export async function consumeCredentialGroupOAuthAttempt( requiredScopes: parsed.requiredScopes, redirectUri: parsed.redirectUri, ...(parsed.completionRedirect ? { completionRedirect: true } : {}), + ...(parsed.completionId ? { completionId: parsed.completionId } : {}), ...(parsed.returnTo ? { returnTo: parsed.returnTo } : {}), ...(codeVerifier ? { codeVerifier: codeVerifier.decrypted } : {}), invitationToken: invitationToken.decrypted, diff --git a/apps/sim/lib/credential-groups/oauth.test.ts b/apps/sim/lib/credential-groups/oauth.test.ts index e748e546aa2..b9879eba19f 100644 --- a/apps/sim/lib/credential-groups/oauth.test.ts +++ b/apps/sim/lib/credential-groups/oauth.test.ts @@ -116,7 +116,11 @@ describe('credential group OAuth persistence', () => { }) createAttempt.mockResolvedValue({ state: 'state', nonce: 'nonce' }) await expect( - startCredentialGroupOAuth(CONTEXT, 'invitation', { returnTo: 'search' }) + startCredentialGroupOAuth(CONTEXT, 'invitation', { + returnTo: 'search', + completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', + }) ).resolves.toBe('https://provider.test/authorize') expect(createAttempt).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ @@ -128,6 +132,8 @@ describe('credential group OAuth persistence', () => { scopeVersion: POLICY.scopeVersion, requiredScopes: POLICY.requiredScopes, returnTo: 'search', + completionRedirect: true, + completionId: '550e8400-e29b-41d4-a716-446655440000', invitationToken: 'invitation', }) ) diff --git a/apps/sim/lib/credential-groups/oauth.ts b/apps/sim/lib/credential-groups/oauth.ts index 5bf8431d5d4..a6c2a0858b1 100644 --- a/apps/sim/lib/credential-groups/oauth.ts +++ b/apps/sim/lib/credential-groups/oauth.ts @@ -110,7 +110,11 @@ const logger = createLogger('CredentialGroupOAuth') export async function startCredentialGroupOAuth( context: CredentialGroupOAuthContext, invitationToken: string, - options: { completionRedirect?: boolean; returnTo?: 'search' | 'accounts' } = {} + options: { + completionRedirect?: boolean + completionId?: string + returnTo?: 'search' | 'accounts' + } = {} ): Promise { if (!context.credentialOwnerId) throw new CredentialGroupInvitationUnavailableError() const adapter = getOptionAdapter(context) @@ -130,6 +134,7 @@ export async function startCredentialGroupOAuth( redirectUri: prepared.redirectUri, codeVerifier: prepared.codeVerifier, completionRedirect: options.completionRedirect, + completionId: options.completionId, returnTo: options.returnTo, invitationToken, }) diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index a0f47466ceb..5f046e65909 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -10,6 +10,7 @@ import { v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies' import { KNOWLEDGE_DELEGATION_AUDIENCE } from '@/lib/knowledge/application/authorization' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors' @@ -111,6 +112,7 @@ export const internalKnowledgeErrorPolicies = { internalKnowledgeErrorPolicy('Failed to process knowledge tag request') ), connectors: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')), + connectAccount: concealKnowledgeBase(internalPersonalCredentialConnectionErrorPolicy), uploads: concealKnowledgeBase(internalKnowledgeUploadErrorPolicy), } as const diff --git a/apps/sim/lib/knowledge/application/connector-access.test.ts b/apps/sim/lib/knowledge/application/connector-access.test.ts index b0189ecae5e..0e0358850c4 100644 --- a/apps/sim/lib/knowledge/application/connector-access.test.ts +++ b/apps/sim/lib/knowledge/application/connector-access.test.ts @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({ provision: vi.fn(), memberAccess: vi.fn(), sourceAccess: vi.fn(), + oauthContext: vi.fn(), + startOAuth: vi.fn(), })) vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() })) @@ -65,8 +67,13 @@ vi.mock('@/lib/knowledge/connectors/member-access', () => ({ vi.mock('@/lib/credential-groups/self-enrollment', () => ({ createViewerCredentialGroupEnrollment: async (...args: unknown[]) => ({ invitationLink: await mocks.enrollment(...args), + enrollment: { id: 'enrollment', email: 'person@example.test' }, }), })) +vi.mock('@/lib/credential-groups/enrollments', () => ({ + getCredentialGroupOAuthContextForEnrollment: mocks.oauthContext, +})) +vi.mock('@/lib/credential-groups/oauth', () => ({ startCredentialGroupOAuth: mocks.startOAuth })) vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ sourceIdentityBinding: mocks.identityBinding, @@ -122,9 +129,69 @@ beforeEach(() => { mocks.identityBinding.mockReturnValue(null) mocks.memberAccess.mockResolvedValue(undefined) mocks.sourceAccess.mockResolvedValue(undefined) + mocks.oauthContext.mockResolvedValue({ credentialOwnerId: 'admin', option: { id: 'option' } }) + mocks.startOAuth.mockResolvedValue('https://provider.example.test/authorize') }) describe('source member enrollment', () => { + it.each(['admin', 'members'])( + 'starts provider OAuth directly for a Search %s source', + async (accessMode) => { + const completionId = '550e8400-e29b-41d4-a716-446655440000' + mocks.context.mockResolvedValue({ + workspaceId: 'workspace', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + knowledgeBaseId: 'kb', + connectorId: 'source', + knowledgeBase: { workspaceId: 'workspace', id: 'kb', name: 'Search', isSearchIndex: true }, + }) + mocks.connector.mockResolvedValue({ + ...row, + accessMode, + credentialGroupId: 'group', + credentialGroupOptionId: 'option', + }) + mocks.meta.mockReturnValue({ name: 'Confluence', search: true, requiresMemberIdentity: true }) + mocks.identityBinding.mockReturnValue({ + credentialGroupId: 'group', + credentialGroupOptionId: 'option', + }) + await expect( + startKnowledgeConnectorMemberEnrollment.execute({ + principal, + input: { ...input, oauthCompletionId: completionId }, + }) + ).resolves.toEqual({ url: 'https://provider.example.test/authorize' }) + expect(mocks.oauthContext).toHaveBeenCalledWith( + { + workspaceId: 'workspace', + credentialGroupId: 'group', + enrollmentId: 'enrollment', + email: 'person@example.test', + userId: 'admin', + }, + 'option' + ) + expect(mocks.startOAuth).toHaveBeenCalledWith( + { credentialOwnerId: 'admin', option: { id: 'option' } }, + 'enroll', + { completionRedirect: true, returnTo: 'search', completionId } + ) + } + ) + + it('rejects direct OAuth for a non-Search source before creating an enrollment', async () => { + await expect( + startKnowledgeConnectorMemberEnrollment.execute({ + principal, + input: { ...input, oauthCompletionId: '550e8400-e29b-41d4-a716-446655440000' }, + }) + ).rejects.toThrow('requires a Search source') + expect(mocks.enrollment).not.toHaveBeenCalled() + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + it.each(['admin', 'members'])( 'focuses a Search %s source on its exact validated account option', async (accessMode) => { diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index ed5daa0312e..aded446945e 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -10,6 +10,8 @@ import { } from '@/lib/core/resource-scope' import { generateRequestId } from '@/lib/core/utils/request' import { loadScopedAccountsCredentialListContext } from '@/lib/credential-groups/credentials' +import { getCredentialGroupOAuthContextForEnrollment } from '@/lib/credential-groups/enrollments' +import { startCredentialGroupOAuth } from '@/lib/credential-groups/oauth' import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment' import { requireKnowledgeMemberAccessAvailable, @@ -55,6 +57,8 @@ export interface StartKnowledgeConnectorMemberEnrollmentInput { connectorId: string assertedWorkspaceId?: string assertedOrganizationId?: string + /** Opens provider OAuth directly and correlates its completion with the initiating tab. */ + oauthCompletionId?: string } /** @@ -71,7 +75,7 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge principal: Principal input: StartKnowledgeConnectorMemberEnrollmentInput }) => resolveActiveKnowledgeConnectorContext(input, principal), - async execute({ principal, context }) { + async execute({ principal, context, input }) { const owner = resourceScopeFields(resourceScopeFromOwner(context.knowledgeBase)) const userId = resolvePrincipalSubjectUserId(principal) if (!userId) throw new OrchestrationError('forbidden', 'Sign in to connect your account') @@ -85,7 +89,42 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge if (!connectorMeta || (context.knowledgeBase.isSearchIndex && !connectorMeta.search)) { throw new OrchestrationError('validation', 'This connector is unavailable for Search') } - const enrollmentUrl = (invitationLink: string, optionId: string) => { + if (input.oauthCompletionId && !context.knowledgeBase.isSearchIndex) { + throw new OrchestrationError( + 'validation', + 'Direct account connection requires a Search source' + ) + } + const enrollmentUrl = async (credentialGroupId: string, optionId: string) => { + const { enrollment, invitationLink } = await createViewerCredentialGroupEnrollment({ + userId, + ...owner, + credentialGroupId, + }) + if (input.oauthCompletionId) { + const token = new URL(invitationLink).pathname.split('/').at(-1) + if (!token) throw new Error('Account enrollment did not return an invitation token') + const oauth = await getCredentialGroupOAuthContextForEnrollment( + { + ...owner, + credentialGroupId, + enrollmentId: enrollment.id, + email: enrollment.email, + userId, + }, + optionId + ) + if (!oauth) + throw new OrchestrationError( + 'forbidden', + 'This account connection is no longer available' + ) + return startCredentialGroupOAuth(oauth, token, { + completionRedirect: true, + returnTo: 'search', + completionId: input.oauthCompletionId, + }) + } if (!context.knowledgeBase.isSearchIndex) return invitationLink const url = new URL(invitationLink) url.searchParams.set('optionId', optionId) @@ -102,12 +141,9 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge `Ask an admin to configure ${connectorMeta.name} sign-in in Connected accounts` ) } - const { invitationLink: url } = await createViewerCredentialGroupEnrollment({ - userId, - ...owner, - credentialGroupId: binding.credentialGroupId, - }) - return { url: enrollmentUrl(url, binding.credentialGroupOptionId) } + return { + url: await enrollmentUrl(binding.credentialGroupId, binding.credentialGroupOptionId), + } } if ( connector.accessMode !== 'members' || @@ -140,12 +176,9 @@ export const startKnowledgeConnectorMemberEnrollment = defineAuthorizedKnowledge sourceConfig: connector.sourceConfig, }) if (!validation.ok) throw new OrchestrationError('validation', validation.message) - const { invitationLink: url } = await createViewerCredentialGroupEnrollment({ - userId, - ...owner, - credentialGroupId: connector.credentialGroupId, - }) - return { url: enrollmentUrl(url, connector.credentialGroupOptionId) } + return { + url: await enrollmentUrl(connector.credentialGroupId, connector.credentialGroupOptionId), + } }, }) diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts index bba1e96d990..043fe0f5f8d 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.test.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.test.ts @@ -17,10 +17,12 @@ vi.mock('@/lib/knowledge/access/availability', () => ({ resolveKnowledgeAccessAvailability: mocks.availability, })) vi.mock('@/lib/sim-search/connectors', () => ({ + canConnectWithDefaults: (meta: { id: string }) => ['google_drive', 'gmail'].includes(meta.id), SEARCH_SOURCE_TYPES: [ - ['google_drive', { mirrorsSourceAcls: true }], - ['gmail', { permissionScopedListing: {} }], - ['github', { permissionScopedListing: {} }], + ['google_drive', { id: 'google_drive', mirrorsSourceAcls: true, permissionScopedListing: {} }], + ['gmail', { id: 'gmail', permissionScopedListing: {} }], + ['github', { id: 'github', permissionScopedListing: {} }], + ['gitlab', { id: 'gitlab', mirrorsSourceAcls: true }], ], })) @@ -35,6 +37,8 @@ const health = { sourceCount: 4, pausedCount: 0, hasError: false, + hasAccountError: false, + hasDocumentError: false, hasIndexing: false, hasWaiting: false, hasUnstarted: false, @@ -49,6 +53,35 @@ beforeEach(() => { }) describe('organization Search administration overview', () => { + it.each([ + { connectorType: 'google_drive', memberScoped: true, status: 'waiting_for_connections' }, + { connectorType: 'google_drive', memberScoped: false, status: 'needs_setup' }, + { connectorType: 'gitlab', memberScoped: true, status: 'needs_setup' }, + ])( + 'reports $connectorType setup with member access $memberScoped', + async ({ connectorType, memberScoped, status }) => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(organizationSearchIntegration, [{ connectorType, approved: true }]) + mocks.availability.mockResolvedValue({ memberScoped, sourceMirrored: true }) + const result = await readOrganizationSearchOverview.execute({ principal, input }) + expect(result.providers).toEqual([ + { connectorType, approved: true, sourceCount: 0, status, issue: null, isSyncing: false }, + ]) + } + ) + it.each([ + { hasAccountError: true, hasDocumentError: false, issue: 'account_sync_incomplete' }, + { hasAccountError: false, hasDocumentError: true, issue: 'document_indexing_failed' }, + { hasAccountError: false, hasDocumentError: false, issue: 'sync_failed' }, + ])('identifies $issue without exposing error details', async ({ issue, ...errors }) => { + queueTableRows(member, [{ role: 'admin' }]) + queueTableRows(knowledgeConnector, [ + { ...health, ...errors, hasError: true, rawError: 'private provider response' }, + ]) + const result = await readOrganizationSearchOverview.execute({ principal, input }) + expect(result.providers[0]).toMatchObject({ status: 'needs_attention', issue }) + expect(JSON.stringify(result)).not.toContain('private provider response') + }) it('keeps recovery observable while a previous error remains visible', async () => { queueTableRows(member, [{ role: 'admin' }]) queueTableRows(knowledgeConnector, [{ ...health, hasError: true, hasIndexing: true }]) @@ -59,6 +92,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: true, status: 'needs_attention', + issue: 'sync_failed', isSyncing: true, }, ]) @@ -84,6 +118,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: true, status: 'active', + issue: null, isSyncing: false, }, { @@ -91,6 +126,7 @@ describe('organization Search administration overview', () => { sourceCount: 0, approved: true, status: 'waiting_for_connections', + issue: null, isSyncing: false, }, { @@ -98,6 +134,7 @@ describe('organization Search administration overview', () => { sourceCount: 0, approved: false, status: 'paused', + issue: null, isSyncing: false, }, ], @@ -149,6 +186,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: false, status: 'paused', + issue: null, isSyncing: false, }, ]) @@ -181,6 +219,7 @@ describe('organization Search administration overview', () => { sourceCount: 4, approved: true, status: 'paused', + issue: null, isSyncing: false, }, { @@ -188,6 +227,7 @@ describe('organization Search administration overview', () => { sourceCount: 0, approved: true, status: 'paused', + issue: null, isSyncing: false, }, ]) diff --git a/apps/sim/lib/knowledge/application/organization-search-overview.ts b/apps/sim/lib/knowledge/application/organization-search-overview.ts index 6031da63c09..70025fc809b 100644 --- a/apps/sim/lib/knowledge/application/organization-search-overview.ts +++ b/apps/sim/lib/knowledge/application/organization-search-overview.ts @@ -16,7 +16,7 @@ import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/au import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { MAX_SEARCH_SOURCE_PROVIDER_TYPES } from '@/lib/knowledge/constants' -import { SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' +import { canConnectWithDefaults, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' interface OrganizationSearchOverviewInput { organizationId: string @@ -26,6 +26,8 @@ interface ProviderHealth { sourceCount: number pausedCount: number hasError: boolean + hasAccountError: boolean + hasDocumentError: boolean hasIndexing: boolean hasWaiting: boolean hasUnstarted: boolean @@ -35,12 +37,12 @@ interface ProviderHealth { function organizationSearchProviderStatus( health: ProviderHealth | undefined, approved: boolean, - mirrorsSourceAcls: boolean, + automaticSetup: boolean, available: boolean ) { if (!approved || !available) return 'paused' as const if (!health?.sourceCount) - return mirrorsSourceAcls ? ('needs_setup' as const) : ('waiting_for_connections' as const) + return automaticSetup ? ('waiting_for_connections' as const) : ('needs_setup' as const) if (health.pausedCount === health.sourceCount) return 'paused' as const if (health.hasError) return 'needs_attention' as const if (health.hasIndexing) return 'indexing' as const @@ -194,6 +196,8 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ OR ${hasMemberError} OR ${latestMemberRunHasError} )) ))`, + hasAccountError: sql`bool_or(NOT ${paused} AND ${knowledgeConnector.accessMode} = 'members' AND ${hasMemberError})`, + hasDocumentError: sql`bool_or(NOT ${paused} AND ${hasDocumentsInState(['failed'])})`, hasIndexing: sql`bool_or(NOT ${paused} AND (${knowledgeConnector.accessMode} <> 'members' OR ${hasActiveMembers} OR ${knowledgeConnector.credentialId} IS NOT NULL) AND ( @@ -247,7 +251,7 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ const status = organizationSearchProviderStatus( state, approved, - meta.mirrorsSourceAcls === true, + canConnectWithDefaults(meta) && availability.memberScoped, Boolean( (meta.permissionScopedListing && availability.memberScoped) || (meta.mirrorsSourceAcls && @@ -261,6 +265,14 @@ export const readOrganizationSearchOverview = defineAuthorizedKnowledgeUseCase({ approved, sourceCount: state?.sourceCount ?? 0, status, + issue: + status === 'needs_attention' + ? state?.hasAccountError + ? ('account_sync_incomplete' as const) + : state?.hasDocumentError + ? ('document_indexing_failed' as const) + : ('sync_failed' as const) + : null, isSyncing: status !== 'paused' && Boolean(state?.hasIndexing), }, ] diff --git a/apps/sim/lib/knowledge/application/search-sources.test.ts b/apps/sim/lib/knowledge/application/search-sources.test.ts index 7780bf13f9b..d806ccb77b5 100644 --- a/apps/sim/lib/knowledge/application/search-sources.test.ts +++ b/apps/sim/lib/knowledge/application/search-sources.test.ts @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ permission: vi.fn(), availability: vi.fn(), memberships: vi.fn(), + accounts: vi.fn(), access: vi.fn(), predicate: vi.fn(), })) @@ -36,6 +37,9 @@ vi.mock('@/lib/knowledge/access/availability', () => ({ vi.mock('@/lib/knowledge/connectors/member-provisioning', () => ({ resolveViewerConnectorMemberships: mocks.memberships, })) +vi.mock('@/lib/knowledge/connectors/viewer-source-accounts', () => ({ + resolveViewerSourceAccounts: mocks.accounts, +})) vi.mock('@/lib/knowledge/access/scope', () => ({ createKnowledgeAccessProvider: mocks.access, })) @@ -114,6 +118,7 @@ beforeEach(() => { mocks.permission.mockResolvedValue('read') mocks.availability.mockResolvedValue({ sourceMirrored: true, memberScoped: true }) mocks.memberships.mockResolvedValue(new Map()) + mocks.accounts.mockResolvedValue(new Map()) mocks.access.mockReturnValue({ get: async () => access, getForConnectors: async () => access, @@ -145,6 +150,7 @@ describe('Search source summaries', () => { viewerDocumentCount: 4, viewerFailedDocumentCount: 0, viewerEmailVerified: true, + viewerAccounts: [], connectionRequired: false, viewerMembership: null, }, @@ -376,6 +382,30 @@ describe('Search source summaries', () => { }) describe('organization Search source summaries', () => { + it.each(['syncing', 'error', 'paused', 'disabled'])( + 'keeps own %s accounts removable even when setup is unavailable', + async (status) => { + mocks.context.mockResolvedValue({ organizationId: 'org-1' }) + queueTableRows(member, [{ role: 'member' }]) + seed([{ ...source('own', 'google_drive', 'members'), status }, source('someone-else')], false) + mocks.availability.mockResolvedValue({ sourceMirrored: false, memberScoped: false }) + const account = { credentialId: 'own-account', displayName: 'My Drive' } + mocks.accounts.mockResolvedValue(new Map([['own', [account]]])) + const result = await listSearchSources.execute({ + principal, + input: { organizationId: 'org-1', mine: true }, + }) + expect(result.sources).toHaveLength(1) + expect(result.sources[0]).toMatchObject({ + connectorId: 'own', + availability: 'unavailable', + viewerAccounts: [account], + }) + expect(mocks.accounts).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org-1', userId: 'reader' }) + ) + } + ) it.each(['member', 'admin'])( 'returns only the current %s viewer ACL counts without a workspace membership', async (role) => { @@ -587,12 +617,16 @@ describe('bounded Search source pagination', () => { seed(candidates) mocks.memberships.mockResolvedValue( new Map([ + ['source-093', 'invited'], + ['source-094', 'not_enrolled'], + ['source-095', 'revoked'], + ['source-096', 'unverified_email'], ['source-097', 'connected'], ['source-098', 'needs_reauth'], ]) ) const result = await listSearchSources.execute({ principal, input: { ...input, mine: true } }) - expect(result.sources.map((row) => row.connectorId)).toEqual(['source-097']) + expect(result.sources.map((row) => row.connectorId)).toEqual(['source-097', 'source-098']) expect(result.nextCursor).toBeNull() }) diff --git a/apps/sim/lib/knowledge/application/search-sources.ts b/apps/sim/lib/knowledge/application/search-sources.ts index a639708d49d..b41e7a80270 100644 --- a/apps/sim/lib/knowledge/application/search-sources.ts +++ b/apps/sim/lib/knowledge/application/search-sources.ts @@ -16,6 +16,7 @@ import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/au import { resolveKnowledgeOwnerContext } from '@/lib/knowledge/application/contexts' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { resolveViewerConnectorMemberships } from '@/lib/knowledge/connectors/member-provisioning' +import { resolveViewerSourceAccounts } from '@/lib/knowledge/connectors/viewer-source-accounts' import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE, SEARCH_SOURCE_PAGE_SIZE, @@ -106,7 +107,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ if (candidates.length === 0) return { sources: [], nextCursor: null } const scanned = candidates.slice(0, SEARCH_SOURCE_CANDIDATE_PAGE_SIZE) - const [availability, memberships, viewers, approvals] = await Promise.all([ + const [availability, memberships, viewers, approvals, accounts] = await Promise.all([ resolveKnowledgeAccessAvailability(context), resolveViewerConnectorMemberships({ userId: principal.userId, @@ -120,10 +121,24 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ .where(eq(user.id, principal.userId)) .limit(1), context.organizationId ? listOrganizationSearchApprovals(context.organizationId) : null, + context.organizationId + ? resolveViewerSourceAccounts({ + organizationId: context.organizationId, + userId: principal.userId, + connectors: scanned, + }) + : new Map(), ]) - /** Filtering uses the same safe display labels and verified membership as the source rows. */ + /** Owned grants stay manageable even when the source can no longer authorize Search. */ const matches = scanned.filter((row) => { - if (input.mine && memberships.get(row.id) !== 'connected') return false + const membership = memberships.get(row.id) + if ( + input.mine && + (context.organizationId + ? !accounts.has(row.id) + : membership !== 'connected' && membership !== 'needs_reauth') + ) + return false const meta = getConnectorMeta(row.connectorType) const label = meta ? `${meta.name ?? row.connectorType} ${describeSearchSource(meta, row.sourceConfig)}` @@ -224,6 +239,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({ viewerDocumentCount: available ? (state?.count ?? 0) : 0, viewerFailedDocumentCount: available ? (state?.failedCount ?? 0) : 0, viewerEmailVerified: viewers[0]?.emailVerified === true, + viewerAccounts: accounts.get(row.id) ?? [], } as const return [ { diff --git a/apps/sim/lib/knowledge/application/sim-search.test.ts b/apps/sim/lib/knowledge/application/sim-search.test.ts index 2f919062d40..a0e521ba71b 100644 --- a/apps/sim/lib/knowledge/application/sim-search.test.ts +++ b/apps/sim/lib/knowledge/application/sim-search.test.ts @@ -509,7 +509,11 @@ describe('organization Search setup', () => { await expect( connectSimSearchConnector.execute({ principal, - input: { ...owner, connectorType: 'google_drive' }, + input: { + ...owner, + connectorType: 'google_drive', + oauthCompletionId: '550e8400-e29b-41d4-a716-446655440000', + }, }) ).resolves.toMatchObject(existingConnector) expect(mocks.enroll).toHaveBeenCalledWith( @@ -518,6 +522,7 @@ describe('organization Search setup', () => { input: expect.objectContaining({ assertedOrganizationId: 'org-1', connectorId: 'connector-drive', + oauthCompletionId: '550e8400-e29b-41d4-a716-446655440000', }), }) ) diff --git a/apps/sim/lib/knowledge/application/sim-search.ts b/apps/sim/lib/knowledge/application/sim-search.ts index b2b2f8a9ade..a8b1c94c5d8 100644 --- a/apps/sim/lib/knowledge/application/sim-search.ts +++ b/apps/sim/lib/knowledge/application/sim-search.ts @@ -64,12 +64,14 @@ export interface ConnectSimSearchConnectorInput extends ResourceOwner { connectorId?: string /** Source settings identify a compatible configuration when creating or reusing a source. */ sourceConfig?: Record + /** Correlates a direct provider authorization with the initiating Integrations tab. */ + oauthCompletionId?: string } export interface ConnectSimSearchConnectorResult { knowledgeBaseId: string connectorId: string - /** The enrollment link that connects the caller's own account. */ + /** The invitation link or provider authorization URL for the caller's own account. */ url: string } @@ -367,6 +369,7 @@ export const connectSimSearchConnector = defineAuthorizedKnowledgeUseCase({ connectorId: target.connectorId, assertedWorkspaceId: workspaceId, assertedOrganizationId: context.organizationId, + oauthCompletionId: input.oauthCompletionId, }, request, }) diff --git a/apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts new file mode 100644 index 00000000000..195511f6601 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.test.ts @@ -0,0 +1,98 @@ +/** @vitest-environment node */ +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { eq, inArray, isNull } from 'drizzle-orm' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/connectors/registry', () => ({ + getConnectorMeta: (id: string) => ({ + requiresMemberIdentity: id === 'slack', + auth: { mode: 'oauth', provider: id }, + }), +})) + +import { resolveViewerSourceAccounts } from '@/lib/knowledge/connectors/viewer-source-accounts' +import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE } from '@/lib/knowledge/constants' + +const source = { + id: 'gmail-source', + connectorType: 'gmail', + accessMode: 'members', + credentialGroupId: 'group-1', + credentialGroupOptionId: 'gmail-option', +} +const input = { organizationId: 'org-1', userId: 'viewer', connectors: [source] } +const account = { + credentialId: 'mine', + displayName: 'My Gmail', + groupId: 'group-1', + optionId: 'gmail-option', + providerId: 'gmail', +} + +describe('personal source account projection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('binds the current contributor and organization on both credentials and groups', async () => { + queueTableRows(credential, [account]) + const result = await resolveViewerSourceAccounts(input) + expect(eq).toHaveBeenCalledWith(credentialGroupEnrollment.userId, 'viewer') + expect(eq).toHaveBeenCalledWith(credential.organizationId, 'org-1') + expect(eq).toHaveBeenCalledWith(credentialGroup.organizationId, 'org-1') + expect(isNull).toHaveBeenCalledWith(credential.workspaceId) + expect(isNull).toHaveBeenCalledWith(credentialGroup.workspaceId) + expect(isNull).toHaveBeenCalledWith(credential.revokedAt) + expect(inArray).toHaveBeenCalledWith(credential.managedOauthStatus, ['active', 'needs_reauth']) + expect(result.get(source.id)).toEqual([{ credentialId: 'mine', displayName: 'My Gmail' }]) + expect(dbChainMockFns.select).toHaveBeenCalledWith({ + credentialId: credential.id, + displayName: credential.displayName, + groupId: credentialGroup.id, + optionId: credential.credentialGroupOptionId, + providerId: credential.providerId, + }) + }) + + it('does not attach an account from another source option or group', async () => { + queueTableRows(credential, [ + { ...account, groupId: 'other' }, + { ...account, optionId: 'other' }, + ]) + expect(await resolveViewerSourceAccounts(input)).toEqual(new Map()) + }) + + it('maps Slack personal identity accounts without offering its administrative bot credential', async () => { + queueTableRows(credential, [ + { ...account, providerId: 'slack', credentialId: 'slack-personal' }, + ]) + const result = await resolveViewerSourceAccounts({ + ...input, + connectors: [ + { + ...source, + id: 'slack-source', + connectorType: 'slack', + accessMode: 'admin', + credentialGroupId: null, + credentialGroupOptionId: null, + }, + ], + }) + expect(eq).toHaveBeenCalledWith(credential.type, 'managed_oauth') + expect(eq).toHaveBeenCalledWith(credential.providerId, 'slack') + expect(result.get('slack-source')).toEqual([ + { credentialId: 'slack-personal', displayName: 'My Gmail' }, + ]) + }) + + it('fails instead of silently truncating too many accounts', async () => { + queueTableRows( + credential, + Array.from({ length: SEARCH_SOURCE_CANDIDATE_PAGE_SIZE + 1 }, () => account) + ) + await expect(resolveViewerSourceAccounts(input)).rejects.toThrow('Too many personal accounts') + }) +}) diff --git a/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts new file mode 100644 index 00000000000..84a501a0c75 --- /dev/null +++ b/apps/sim/lib/knowledge/connectors/viewer-source-accounts.ts @@ -0,0 +1,97 @@ +import { db } from '@sim/db' +import { credential, credentialGroup, credentialGroupEnrollment } from '@sim/db/schema' +import { and, eq, inArray, isNull, or } from 'drizzle-orm' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' +import { SEARCH_SOURCE_CANDIDATE_PAGE_SIZE } from '@/lib/knowledge/constants' +import { getConnectorMeta } from '@/connectors/registry' + +interface ViewerSourceAccount { + credentialId: string + displayName: string +} + +interface SourceAccountBinding { + id: string + connectorType: string + accessMode: string + credentialGroupId: string | null + credentialGroupOptionId: string | null +} + +/** + * Own account controls remain available when provider setup, enrollment, or sync is disabled. + * Called inside the authorized source read; selects no token material or other contributors. + */ +export async function resolveViewerSourceAccounts(input: { + organizationId: string + userId: string + connectors: ReadonlyArray +}): Promise> { + const bindings = input.connectors.map((source) => { + const meta = getConnectorMeta(source.connectorType) + const providerId = + source.accessMode === 'admin' && meta?.requiresMemberIdentity && meta.auth.mode === 'oauth' + ? meta.auth.provider + : null + return { source, providerId } + }) + const matches = bindings.flatMap(({ source, providerId }) => { + if ( + source.accessMode === 'members' && + source.credentialGroupId && + source.credentialGroupOptionId + ) + return [ + and( + eq(credentialGroup.id, source.credentialGroupId), + eq(credential.credentialGroupOptionId, source.credentialGroupOptionId) + ), + ] + return providerId ? [eq(credential.providerId, providerId)] : [] + }) + const result = new Map() + if (!matches.length) return result + const scope = { kind: 'organization', organizationId: input.organizationId } as const + const accounts = await db + .select({ + credentialId: credential.id, + displayName: credential.displayName, + groupId: credentialGroup.id, + optionId: credential.credentialGroupOptionId, + providerId: credential.providerId, + }) + .from(credential) + .innerJoin( + credentialGroupEnrollment, + eq(credentialGroupEnrollment.id, credential.credentialGroupEnrollmentId) + ) + .innerJoin(credentialGroup, eq(credentialGroup.id, credentialGroupEnrollment.credentialGroupId)) + .where( + and( + resourceScopeCondition(credential, scope), + resourceScopeCondition(credentialGroup, scope), + eq(credentialGroupEnrollment.userId, input.userId), + eq(credential.type, 'managed_oauth'), + inArray(credential.managedOauthStatus, ['active', 'needs_reauth']), + isNull(credential.revokedAt), + or(...matches) + ) + ) + .limit(SEARCH_SOURCE_CANDIDATE_PAGE_SIZE + 1) + if (accounts.length > SEARCH_SOURCE_CANDIDATE_PAGE_SIZE) + throw new Error('Too many personal accounts for the source page') + for (const { source, providerId } of bindings) { + const own = accounts.filter((account) => + source.accessMode === 'members' + ? account.groupId === source.credentialGroupId && + account.optionId === source.credentialGroupOptionId + : providerId !== null && account.providerId === providerId + ) + if (own.length) + result.set( + source.id, + own.map(({ credentialId, displayName }) => ({ credentialId, displayName })) + ) + } + return result +} diff --git a/apps/sim/lib/sim-search/connectors.test.ts b/apps/sim/lib/sim-search/connectors.test.ts index 07bbd390c59..254484c8cfe 100644 --- a/apps/sim/lib/sim-search/connectors.test.ts +++ b/apps/sim/lib/sim-search/connectors.test.ts @@ -117,13 +117,16 @@ vi.mock('@/lib/credential-groups/providers', () => ({ import { canConnectPersonally, + canConnectWithDefaults, getConnectorAccessAvailability, isSearchConnectorAvailable, missingSetupFields, personalSetupFields, SEARCH_CONNECTORS, } from '@/lib/sim-search/connectors' +import { googleDriveConnectorMeta } from '@/connectors/google-drive/meta' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { slackConnectorMeta } from '@/connectors/slack/meta' import type { ConnectorMeta } from '@/connectors/types' describe('SEARCH_CONNECTORS', () => { @@ -161,6 +164,21 @@ describe('canConnectPersonally', () => { }) describe('personalSetupFields', () => { + it('does not require central indexing setup for a personal Drive connection', () => { + const defaults = CONNECTOR_META_REGISTRY.google_drive + expect(canConnectWithDefaults(defaults)).toBe(true) + expect(canConnectWithDefaults(googleDriveConnectorMeta)).toBe(true) + expect(canConnectWithDefaults(slackConnectorMeta)).toBe(false) + expect( + canConnectWithDefaults({ + ...defaults, + permissionScopedListing: undefined, + mirrorsSourceAcls: true, + }) + ).toBe(false) + expect(canConnectWithDefaults(CONNECTOR_META_REGISTRY.jira)).toBe(false) + expect(canConnectWithDefaults(CONNECTOR_META_REGISTRY.unreviewed)).toBe(false) + }) it('asks for required config beyond the listing caps, never a selector', () => { const drive = SEARCH_CONNECTORS.find((connector) => connector.type === 'google_drive')! const jira = SEARCH_CONNECTORS.find((connector) => connector.type === 'jira')! diff --git a/apps/sim/lib/sim-search/connectors.ts b/apps/sim/lib/sim-search/connectors.ts index 991f1d52cd3..d5ce4c1c348 100644 --- a/apps/sim/lib/sim-search/connectors.ts +++ b/apps/sim/lib/sim-search/connectors.ts @@ -115,6 +115,11 @@ export function personalSetupFields(meta: ConnectorMeta): ConnectorConfigField[] ) } +/** Personal sources use defaults even when they also support central indexing. Slack needs a custom app first. */ +export function canConnectWithDefaults(meta: ConnectorMeta): boolean { + return canConnectPersonally(meta) && meta.id !== 'slack' && personalSetupFields(meta).length === 0 +} + /** The setup fields a source config leaves empty. */ export function missingSetupFields( meta: ConnectorMeta, From e9947ef1030eb880c78083c5cd0508c0af68bbca Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 9 Sep 2026 17:47:05 -0700 Subject: [PATCH 3/3] fix(search): update source route test fixtures --- .../app/api/knowledge/sim-search/sources/route.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts index 43251e1748c..44ee57d8279 100644 --- a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts +++ b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts @@ -1,6 +1,10 @@ /** @vitest-environment node */ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OrganizationSearchProviderSummary, + SearchSourceSummary, +} from '@/lib/api/contracts/knowledge/connectors' const mocks = vi.hoisted(() => ({ execute: vi.fn(), @@ -58,9 +62,10 @@ const source = { viewerDocumentCount: 0, viewerFailedDocumentCount: 0, viewerEmailVerified: true, + viewerAccounts: [], connectionRequired: false, viewerMembership: null, -} +} satisfies SearchSourceSummary beforeEach(() => { vi.clearAllMocks() @@ -266,8 +271,9 @@ describe('organization administration overview boundary', () => { sourceCount: 1, approved: true, status: 'waiting_for_connections', + issue: null, isSyncing: false, - } + } satisfies OrganizationSearchProviderSummary mocks.adminOverview.mockResolvedValue({ providers: [{ ...provider, privateAccount: 'private' }], documentNames: ['private'],