diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts index af895bf5a0d..447c7219922 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts @@ -25,6 +25,8 @@ export const PATCH = defineInternalJsonRoute({ knowledgeBaseId: params.id, accessMode: body.accessMode, credentialId: body.credentialId, + sourceConfig: body.sourceConfig, + syncIntervalMinutes: body.syncIntervalMinutes, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), source: 'ui' as const, diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx index 267977e0ead..8b191c6ae24 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx @@ -130,6 +130,13 @@ vi.mock('@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope' }), })) vi.mock('@/hooks/queries/kb/connectors', () => ({ + isConnectorSyncingOrPending: (row: { + status: string + accessMode?: string + memberSyncStatus?: string + }) => + ['pending', 'syncing'].includes(row.status) || + ['pending', 'running'].includes(row.memberSyncStatus ?? ''), useSearchIndex: ( scope: { workspaceId?: string; organizationId?: string }, options: { enabled: boolean } @@ -1367,10 +1374,10 @@ describe('administrator source prerequisites in real connector dialogs', () => { (node) => node.textContent?.trim() === replacement.name )! await act(async () => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))) - expect(button('Save')).toBeDisabled() - expect(button('Change service account')).toBeEnabled() + expect(button('Save')).toBeEnabled() + expect(document.body.textContent).not.toContain('Change service account') - await click(button('Change service account')) + await click(button('Save')) expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith( { diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx index c97e4c8a3c4..31435adee4a 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx @@ -38,7 +38,8 @@ vi.mock('@/hooks/use-oauth-return', () => ({ useOAuthReturnForKBConnectors: vi.f vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchIndex: mocks.index, useConnectorDetail: mocks.detail, - isConnectorSyncingOrPending: () => false, + isConnectorSyncingOrPending: (row: ConnectorData) => + row.status === 'syncing' || row.status === 'pending', })) vi.mock('@/hooks/queries/search-integrations', () => ({ useSearchIntegrations: mocks.integrations, @@ -187,6 +188,19 @@ describe('organization source detail navigation', () => { expect(button, `Missing ${text}`).toBeTruthy() await act(async () => button!.click()) } + + it('passes live sync status to the form without replacing its settings baseline', async () => { + await render('?view=settings') + const baseline = mocks.form.mock.lastCall![0].connector + mocks.dirty = true + mocks.detail.mockReturnValue({ data: { ...connector, status: 'syncing' } }) + await render('?view=settings') + expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: true }) + + mocks.detail.mockReturnValue({ data: { ...connector, status: 'active' } }) + await render('?view=settings') + expect(mocks.form.mock.lastCall![0]).toMatchObject({ connector: baseline, syncing: false }) + }) it.each(['documents', 'settings', 'history'])( 'replaces the removed connection with Sources from the %s view', async (view) => { 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 ae654870d01..8c75f40fefc 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 @@ -425,6 +425,7 @@ function SourceSettingsForm({ }: SourceSettingsFormProps) { const form = useConnectorSettingsForm({ connector: baseline, + syncing: isConnectorSyncingOrPending(connector), scope, knowledgeBaseId: connector.knowledgeBaseId, isSearchIndex: true, @@ -444,6 +445,7 @@ function SourceSettingsForm({ dirty: form.dirty, saving: form.saving, saveDisabled: !form.canSave, + saveTooltip: form.saveBlockedReason, onSave: form.save, onDiscard, })} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx index 4ecbc389925..61664330801 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.test.tsx @@ -98,7 +98,8 @@ describe('connection method selection', () => { isAvailabilityReady: false, }) expect(container.textContent).not.toContain('This connection method is not available') - expect(container.querySelector('[aria-label="Sync using: Service account"]')).toBeDisabled() + expect(container.textContent).toContain('Service account') + expect(container.querySelector('[role="combobox"]')).toBeNull() }) it('shows a real unavailable method after availability finishes loading', async () => { @@ -119,15 +120,10 @@ describe('connection method selection', () => { { mode: 'admin', label: 'Service account' }, ] as const)('shows a locked $mode method without allowing changes', async ({ mode, label }) => { await render({ value: { accessMode: mode }, lockAccessMode: true }) - const dropdown = container.querySelector( - `[aria-label="Sync using: ${label}"]` - ) - expect(dropdown).toBeDisabled() - expect(dropdown).toHaveTextContent(label) - expect(container.textContent).toContain('Add a new connection to change the sync method.') + expect(container.textContent).toContain(label) + expect(container.textContent).not.toContain('Add a new connection') expect(container.querySelector('[role="radiogroup"]')).toBeNull() - await act(async () => dropdown!.click()) - expect(document.querySelector('[role="menu"]')).toBeNull() + expect(container.querySelector('button')).toBeNull() expect(onChange).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx index 7e85e99cc36..12f32dab613 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx @@ -5,7 +5,6 @@ import { ChipButtonGroup, ChipButtonGroupItem, ChipCombobox, - ChipDropdown, ChipLink, ChipModalField, type ComboboxOption, @@ -159,23 +158,13 @@ export function ConnectorAccessField({ hint={ canAdmin && isAvailabilityReady && !currentMode?.allowed ? `This connection method is not available in this ${scope.kind}.` - : lockAccessMode - ? 'Add a new connection to change the sync method.' - : value.accessMode === 'workspace' - ? 'Everyone in this workspace can search these documents.' - : undefined + : value.accessMode === 'workspace' + ? 'Everyone in this workspace can search these documents.' + : undefined } >
- {slackSetupOnly ? null : lockAccessMode ? ( - ({ value: mode, label }))} - disabled - className='w-fit' - /> - ) : showModeSelector ? ( + {slackSetupOnly ? null : !lockAccessMode && showModeSelector ? ( { it.each([true, false])( 'locks the sync method only for Search settings (%s)', async (isSearchIndex) => { - await render(confluenceConnectorMeta, { isSearchIndex }) + await render(confluenceConnectorMeta, { isSearchIndex, needsWorkspaceCredential: false }) expect(mocks.accessField).toHaveBeenLastCalledWith( expect.objectContaining({ lockAccessMode: isSearchIndex }) ) @@ -501,6 +501,35 @@ describe('connector settings service-account choices', () => { ) }) + it('browses spaces with the draft replacement account without a separate save action', async () => { + mocks.renderConfigFields = true + mocks.credentials = [ + { + id: 'replacement', + name: 'Updated account', + provider: 'confluence', + type: 'service_account', + }, + ] + await render(confluenceConnectorMeta, { + credentialId: 'previous', + workspaceCredentialId: 'replacement', + accessModeChanged: false, + sourceConfig: { domain: 'https://example.atlassian.net', spaceKey: ['ENG'] }, + isFieldVisible: (field) => field.id === 'spaceSelector', + }) + + expect(mocks.selectorOptions).toHaveBeenLastCalledWith( + 'confluence.spaces', + expect.objectContaining({ + context: expect.objectContaining({ oauthCredential: 'replacement' }), + }) + ) + expect(mocks.accessField).not.toHaveBeenCalled() + expect(container.textContent).not.toContain('Change service account') + expect(container.textContent).not.toContain('Cancel') + }) + it.each([ { meta: confluenceConnectorMeta, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx index d880808a513..4cb850c6ce9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/connector-settings-fields.tsx @@ -91,6 +91,7 @@ export interface ConnectorSettingsFieldsProps { hasMaxAccess: boolean isSaving: boolean error: string | null + saveBlockedReason?: string access: ConnectorAccessSelection onAccessChange: (access: ConnectorAccessSelection) => void canAdmin: boolean @@ -133,6 +134,7 @@ export function ConnectorSettingsFields({ hasMaxAccess, isSaving, error, + saveBlockedReason, access, onAccessChange, canAdmin, @@ -204,7 +206,9 @@ export function ConnectorSettingsFields({ }) useCredentialRefreshTriggers(refetchCredentials, providerId ?? '', scope) const [browseCredentialId, setBrowseCredentialId] = useState(null) - const selectorCredentialId = syncsPerMember ? browseCredentialId : credentialId + const selectorCredentialId = syncsPerMember + ? browseCredentialId + : (workspaceCredentialId ?? credentialId) const selectorCredential = rawCredentials.find((item) => item.id === selectorCredentialId) const installations = rawCredentials.filter( (credential) => credential.provider === GITHUB_INSTALLATION_PROVIDER_ID @@ -228,6 +232,7 @@ export function ConnectorSettingsFields({ [rawCredentials, connectorConfig, access.accessMode] ) + const hideFixedAccessMode = isSearchIndex && needsWorkspaceCredential && canAdmin && allowAdmin const hiddenCapFieldIds = derivedAclCapFieldIds(connectorConfig, access.accessMode) const isOptionalSetupField = (field: ConnectorConfigField) => Boolean(gitlabPermissions && connectorConfig) && @@ -330,70 +335,67 @@ export function ConnectorSettingsFields({ disabled={isSaving || !canAdmin} /> )} - {connectorConfig && showAccessField && !isGitHubInstallationSource && ( - -
- - {isSwitchingAccess ? 'Re-enabling…' : 'Re-enable per-member sync'} - -
-

- Members and their documents are kept; the next sync restores their access. -

-
- ) : accessDirty ? ( -
-
- - {isSwitchingAccess - ? 'Switching…' - : isContentCredentialChange - ? isSearchIndex - ? requiresServiceAccount - ? 'Change service account' - : 'Change account' - : 'Change indexing account' - : 'Apply connection method'} - - - {accessSetupHint ? 'Edit settings' : 'Cancel'} - + {connectorConfig && + showAccessField && + !isGitHubInstallationSource && + !hideFixedAccessMode && ( + +
+ + {isSwitchingAccess ? 'Re-enabling…' : 'Re-enable per-member sync'} + +
+

+ Members and their documents are kept; the next sync restores their access. +

-

- {accessSetupHint ?? - (isContentCredentialChange - ? syncsPerMember + ) : accessDirty && (!isContentCredentialChange || syncsPerMember) ? ( +

+
+ + {isSwitchingAccess + ? 'Switching…' + : isContentCredentialChange + ? 'Change indexing account' + : 'Apply connection method'} + + + {accessSetupHint ? 'Edit settings' : 'Cancel'} + +
+

+ {accessSetupHint ?? + (isContentCredentialChange ? 'The next sync uses this account. Members keep their connected accounts and source permissions.' - : 'The next sync uses this account and refreshes source permissions.' - : SWITCH_NOTICE[access.accessMode])} -

-
- ) : undefined - } - /> - )} + : SWITCH_NOTICE[access.accessMode])} +

+
+ ) : undefined + } + /> + )} {connectorConfig && needsWorkspaceCredential && canAdmin && ( )} + {saveBlockedReason && ( +

+ {saveBlockedReason} +

+ )} {error} ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx index dea50c56b85..ca249a9a810 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.test.tsx @@ -14,6 +14,13 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/hooks/queries/kb/connectors', () => ({ + isConnectorSyncingOrPending: (row: { + status: string + accessMode?: string + memberSyncStatus?: string + }) => + ['pending', 'syncing'].includes(row.status) || + ['pending', 'running'].includes(row.memberSyncStatus ?? ''), useUpdateConnector: () => ({ mutate: mocks.update, isPending: mocks.settingsPending }), useUpdateConnectorAccess: () => ({ mutate: mocks.applyAccess, isPending: mocks.accessPending }), })) @@ -32,7 +39,10 @@ vi.mock('@/hooks/use-permission-config', () => ({ ['slack', { oauthAvailable: true, state: 'ready' }], ['slack_v2', { oauthAvailable: true, state: 'ready' }], ]), - oauthServiceAvailability: new Map([['github-repositories', true]]), + oauthServiceAvailability: new Map([ + ['github-repositories', true], + ['confluence', true], + ]), isIntegrationAvailabilityReady: true, isIntegrationAvailabilityFetching: false, integrationAvailabilityError: null, @@ -222,6 +232,61 @@ describe('shared connector settings form', () => { expect(mocks.applyAccess).not.toHaveBeenCalled() }) + it('saves an account replacement and source edits together and retains the draft on rejection', () => { + const row = connector({ + connectorType: 'confluence', + accessMode: 'admin', + credentialId: 'old-account', + sourceConfig: { domain: 'example.atlassian.net', spaceKey: ['ENG'] }, + }) + render(row, 'replacement') + act(() => form.fieldsProps.onWorkspaceCredentialChange('new-account')) + act(() => form.fieldsProps.onFieldChange('labelFilter', 'published')) + expect(form.canSave).toBe(true) + act(() => form.save()) + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.applyAccess).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + access: expect.objectContaining({ + accessMode: 'admin', + credentialId: 'new-account', + sourceConfig: expect.objectContaining({ labelFilter: 'published' }), + }), + }), + expect.any(Object) + ) + act(() => + mocks.applyAccess.mock.calls[0][1].onError(new Error('Account cannot access this space')) + ) + expect(form.fieldsProps.workspaceCredentialId).toBe('new-account') + expect(form.fieldsProps.sourceConfig.labelFilter).toBe('published') + expect(form.canSave).toBe(true) + expect(onSaved).not.toHaveBeenCalled() + }) + + it.each(['pending', 'syncing'] as const)( + 'keeps the account draft while %s and enables Save when idle', + (status) => { + const row = connector({ + connectorType: 'confluence', + accessMode: 'admin', + credentialId: 'old-account', + status, + sourceConfig: { domain: 'example.atlassian.net', spaceKey: ['ENG'] }, + }) + render(row, 'syncing') + act(() => form.fieldsProps.onWorkspaceCredentialChange('new-account')) + expect(form.canSave).toBe(false) + expect(form.saveBlockedReason).toBe('Wait for the current sync to finish before saving.') + act(() => form.save()) + expect(mocks.applyAccess).not.toHaveBeenCalled() + render({ ...row, status: 'active' }, 'syncing') + expect(form.fieldsProps.workspaceCredentialId).toBe('new-account') + expect(form.canSave).toBe(true) + expect(form.saveBlockedReason).toBeUndefined() + } + ) + it('keeps general knowledge-base listing caps editable and includes their changes on save', () => { const sourceConfig = { label: ['INBOX'], diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts index c764d2b0500..53dec40f0a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/use-connector-settings-form.ts @@ -24,6 +24,7 @@ import { useGitLabPermissionForm } from '@/connectors/gitlab/permission-config/u import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { type ConnectorData, + isConnectorSyncingOrPending, useUpdateConnector, useUpdateConnectorAccess, } from '@/hooks/queries/kb/connectors' @@ -122,6 +123,7 @@ interface UseConnectorSettingsFormOptions { isSearchIndex?: boolean connector: ConnectorData onSaved: (connector: ConnectorData) => void + syncing?: boolean } /** Editable connector settings shared by the source page and knowledge-base modal. */ @@ -131,6 +133,7 @@ export function useConnectorSettingsForm({ isSearchIndex = false, connector, onSaved, + syncing = isConnectorSyncingOrPending(connector), }: UseConnectorSettingsFormOptions) { const connectorConfig = CONNECTOR_META_REGISTRY[connector.connectorType] ?? null @@ -263,6 +266,8 @@ export function useConnectorSettingsForm({ workspaceCredentialId !== connector.credentialId) || (access.accessMode === 'members' && contentCredentialId !== (connector.accessMode === 'members' ? connector.credentialId : null)) + const accountChanged = + accessDirty && !accessModeChanged && isContentEngineAccessMode(access.accessMode) /** Exposes credential selection for mode changes and administrator credential recovery. */ const needsWorkspaceCredential = connectorConfig?.auth.mode === 'oauth' && @@ -272,7 +277,8 @@ export function useConnectorSettingsForm({ const missingAdminField = accessDirty && access.accessMode === 'admin' ? connectorConfig?.configFields.find((field) => { - const value = connector.sourceConfig[field.id] + const config = accessModeChanged ? connector.sourceConfig : resolveSourceConfig() + const value = config[field.canonicalParamId ?? field.id] return ( !field.required && isConnectorFieldRequired(field, connectorConfig, 'admin') && @@ -328,7 +334,10 @@ export function useConnectorSettingsForm({ if ( !searchSettingsAllowed || !settingsComplete || - accessDirty || + (accessDirty && !accountChanged) || + (accountChanged && !accessComplete) || + syncing || + isSaving || (showGitLabPermissions && !permissionsComplete) ) return @@ -365,6 +374,26 @@ export function useConnectorSettingsForm({ updates.sourceConfig = next } + if (accountChanged) { + updateAccess( + { + knowledgeBaseId, + connectorId: connector.id, + access: { + accessMode: access.accessMode, + credentialId: workspaceCredentialId ?? connector.credentialId, + sourceConfig: updates.sourceConfig, + syncIntervalMinutes: updates.syncIntervalMinutes, + }, + }, + { + onSuccess: onSaved, + onError: (err) => setError(err.message), + } + ) + return + } + if (Object.keys(updates).length === 0) { onSaved(connector) return @@ -386,6 +415,12 @@ export function useConnectorSettingsForm({ }, [ access.accessMode, accessDirty, + accountChanged, + accessComplete, + syncing, + isSaving, + updateAccess, + workspaceCredentialId, canonicalModes, connector, connectorConfig, @@ -453,6 +488,11 @@ export function useConnectorSettingsForm({ setContentCredentialId(connector.accessMode === 'members' ? connector.credentialId : null) }, [connector]) + const saveBlockedReason = + syncing && (hasChanges || accountChanged) + ? 'Wait for the current sync to finish before saving.' + : undefined + const fieldsProps: ConnectorSettingsFieldsProps = { gitlabPermissions: showGitLabPermissions ? gitlabPermissions : undefined, availability: { @@ -479,6 +519,7 @@ export function useConnectorSettingsForm({ hasMaxAccess, isSaving, error: error ?? searchSetupError, + saveBlockedReason, access, onAccessChange: setAccess, canAdmin, @@ -508,9 +549,11 @@ export function useConnectorSettingsForm({ docsUrl, dirty: hasChanges || accessDirty, saving: isSaving, + saveBlockedReason, canSave: - hasChanges && - !accessDirty && + (hasChanges || accountChanged) && + (!accessDirty || (accountChanged && accessComplete)) && + !syncing && !isSaving && searchSettingsAllowed && Boolean(settingsComplete) && diff --git a/apps/sim/lib/api/contracts/knowledge/connectors.ts b/apps/sim/lib/api/contracts/knowledge/connectors.ts index 1580228c949..89ed3dee598 100644 --- a/apps/sim/lib/api/contracts/knowledge/connectors.ts +++ b/apps/sim/lib/api/contracts/knowledge/connectors.ts @@ -58,6 +58,8 @@ export const updateConnectorAccessBodySchema = z.object({ accessMode: connectorRequestedAccessModeSchema, /** Null removes dedicated content ingestion; omission preserves it in members mode. */ credentialId: z.string().min(1).nullable().optional(), + sourceConfig: z.record(z.string(), z.unknown()).optional(), + syncIntervalMinutes: z.number().int().min(0).optional(), }) export type UpdateConnectorAccessBody = z.input diff --git a/apps/sim/lib/knowledge/application/connector-access.test.ts b/apps/sim/lib/knowledge/application/connector-access.test.ts index d80c90158a6..d593662c52e 100644 --- a/apps/sim/lib/knowledge/application/connector-access.test.ts +++ b/apps/sim/lib/knowledge/application/connector-access.test.ts @@ -647,3 +647,79 @@ describe('connector access application boundary', () => { ) }) }) + +describe('account and settings save', () => { + it('validates the replacement with the edited configuration before passing a single mutation', async () => { + const sourceConfig = { domain: 'example.atlassian.net', spaceKey: ['ENG'] } + mocks.connector.mockResolvedValue({ + ...row, + accessMode: 'admin', + connectorType: 'confluence', + credentialId: 'old', + updatedAt: new Date('2026-09-01'), + }) + mocks.meta.mockReturnValue({ + name: 'Confluence', + auth: { mode: 'oauth', provider: 'confluence' }, + mirrorsSourceAcls: true, + }) + await updateKnowledgeConnectorAccess.execute({ + principal, + input: { + ...input, + accessMode: 'admin', + credentialId: 'new', + sourceConfig, + syncIntervalMinutes: 1440, + }, + }) + expect(mocks.validate).toHaveBeenCalledWith( + expect.objectContaining({ + connector: expect.objectContaining({ credentialId: 'new' }), + sourceConfig, + }) + ) + expect(mocks.update).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + target: { accessMode: 'admin', credentialId: 'new' }, + sourceConfig, + syncIntervalMinutes: 1440, + expectedUpdatedAt: new Date('2026-09-01'), + }) + ) + }) + + it('leaves both account and settings unchanged when provider validation rejects the replacement', async () => { + mocks.connector.mockResolvedValue({ ...row, accessMode: 'admin' }) + mocks.validate.mockResolvedValue({ + errorCode: 'validation', + message: 'Cannot access this space', + }) + await expect( + updateKnowledgeConnectorAccess.execute({ + principal, + input: { + ...input, + accessMode: 'admin', + sourceConfig: { host: 'new.example.test' }, + }, + }) + ).rejects.toThrow('Cannot access this space') + expect(mocks.update).not.toHaveBeenCalled() + }) + + it('rejects combined settings when switching access modes before resolving credentials', async () => { + await expect( + updateKnowledgeConnectorAccess.execute({ + principal, + input: { + ...input, + accessMode: 'admin', + sourceConfig: { host: 'new.example.test' }, + }, + }) + ).rejects.toThrow('Save source settings separately') + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.token).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/connector-access.ts b/apps/sim/lib/knowledge/application/connector-access.ts index 518d5123eeb..06e3e1dd90e 100644 --- a/apps/sim/lib/knowledge/application/connector-access.ts +++ b/apps/sim/lib/knowledge/application/connector-access.ts @@ -194,6 +194,8 @@ export interface UpdateKnowledgeConnectorAccessInput { accessMode: ConnectorAccessMode /** Workspace mode: the credential the connector syncs as from now on. */ credentialId?: string | null + sourceConfig?: Record + syncIntervalMinutes?: number source?: KnowledgeOperationSource resolveBillingAttribution?(workspaceId: string): Promise } @@ -243,6 +245,15 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ ) } const previousConfig = connector.sourceConfig as Record + if ( + (input.sourceConfig !== undefined || input.syncIntervalMinutes !== undefined) && + (input.accessMode === 'members' || input.accessMode !== connector.accessMode) + ) { + throw new OrchestrationError( + 'validation', + 'Save source settings separately when changing the connection method.' + ) + } const sourceConfig = await prepareGitHubInstallationSource({ principal, requestId, @@ -256,7 +267,7 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ isSearchIndex: context.knowledgeBase.isSearchIndex === true, accessMode: input.accessMode, actingUserId, - sourceConfig: previousConfig, + sourceConfig: input.sourceConfig ?? previousConfig, previousConfig, }) @@ -351,6 +362,9 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ knowledgeBase: { id: context.knowledgeBaseId, name: context.knowledgeBase.name, ...owner }, connectorId: context.connectorId, target, + sourceConfig: input.sourceConfig === undefined ? undefined : sourceConfig, + syncIntervalMinutes: input.syncIntervalMinutes, + expectedUpdatedAt: connector.updatedAt, resolveBillingAttribution: () => (owner.workspaceId ? input.resolveBillingAttribution?.(owner.workspaceId) : undefined) ?? resolveKnowledgeBillingAttribution(principal, context), @@ -369,13 +383,18 @@ export const updateKnowledgeConnectorAccess = defineAuthorizedKnowledgeUseCase({ resourceType: AuditResourceType.CONNECTOR, resourceId: result.connector.id, resourceName: result.connector.connectorType, - description: `Switched connector access to ${input.accessMode} mode for knowledge base "${context.knowledgeBase.name}"`, + description: `Updated connector connection for knowledge base "${context.knowledgeBase.name}"`, metadata: { source: input.source, knowledgeBaseId: context.knowledgeBaseId, knowledgeBaseName: context.knowledgeBase.name, connectorType: result.connector.connectorType, - updatedFields: ['accessMode'], + updatedFields: [ + 'accessMode', + ...(input.credentialId !== undefined ? ['credentialId'] : []), + ...(input.sourceConfig !== undefined ? ['sourceConfig'] : []), + ...(input.syncIntervalMinutes !== undefined ? ['syncIntervalMinutes'] : []), + ], accessMode: input.accessMode, }, } diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.test.ts b/apps/sim/lib/knowledge/orchestration/connector-access.test.ts index 8e8f3e82c40..e00b29d643d 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.test.ts @@ -23,6 +23,12 @@ const mocks = vi.hoisted(() => ({ rewriteAcls: vi.fn(), })) +vi.mock('@/connectors/registry.server', () => ({ + CONNECTOR_REGISTRY: { + google_drive: { configFields: [], permissionScopedListing: { capFieldIds: [] } }, + }, +})) + vi.mock('@/lib/knowledge/connectors/member-observations', () => ({ rewriteConnectorAcls: mocks.rewriteAcls, })) @@ -99,6 +105,7 @@ const WORKSPACE_CONNECTOR = { status: 'active', syncLockToken: null, memberSyncLockToken: null, + updatedAt: new Date('2026-09-01T00:00:00Z'), } const MEMBERS_CONNECTOR = { @@ -486,7 +493,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { }) it('changes a workspace credential without the lease, drops the watermark, and queues a full sync', async () => { - queueTableRows(schemaMock.knowledgeConnector, [ + dbChainMockFns.limit.mockResolvedValue([ { ...WORKSPACE_CONNECTOR, lastSyncAt: new Date('2026-08-01T00:00:00Z') }, ]) dbChainMockFns.returning.mockResolvedValueOnce([ @@ -525,6 +532,68 @@ describe('performUpdateKnowledgeConnectorAccess', () => { expect(mocks.revoke).not.toHaveBeenCalled() }) + it('commits the replacement account and source settings in one write', async () => { + const sourceConfig = { folderId: ['f-2'] } + dbChainMockFns.limit.mockResolvedValue([WORKSPACE_CONNECTOR]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...WORKSPACE_CONNECTOR, credentialId: 'cred-2', sourceConfig, syncIntervalMinutes: 1440 }, + ]) + const outcome = await performUpdateKnowledgeConnectorAccess({ + knowledgeBase: KB, + connectorId: 'c-1', + target: { accessMode: 'workspace', credentialId: 'cred-2' }, + sourceConfig, + syncIntervalMinutes: 1440, + resolveBillingAttribution, + ...ACTOR, + }) + expect(outcome).toMatchObject({ success: true, changed: true }) + expect(dbChainMockFns.update).toHaveBeenCalledOnce() + expect(setCallWith('credentialId')).toMatchObject({ + credentialId: 'cred-2', + sourceConfig, + syncIntervalMinutes: 1440, + lastSyncAt: null, + listingCheckpoint: null, + directoryCheckpoint: null, + }) + expect(mocks.dispatchSync).toHaveBeenCalledOnce() + }) + + it('rejects the complete save if a sync starts during validation', async () => { + queueTableRows(schemaMock.knowledgeConnector, [WORKSPACE_CONNECTOR]) + queueTableRows(schemaMock.knowledgeConnector, [{ ...WORKSPACE_CONNECTOR, status: 'syncing' }]) + const outcome = await performUpdateKnowledgeConnectorAccess({ + knowledgeBase: KB, + connectorId: 'c-1', + target: { accessMode: 'workspace', credentialId: 'cred-2' }, + sourceConfig: { folderId: ['f-2'] }, + resolveBillingAttribution, + ...ACTOR, + }) + expect(outcome).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.dispatchSync).not.toHaveBeenCalled() + }) + + it('keeps a committed account replacement successful when sync dispatch fails', async () => { + dbChainMockFns.limit.mockResolvedValue([WORKSPACE_CONNECTOR]) + dbChainMockFns.returning.mockResolvedValueOnce([ + { ...WORKSPACE_CONNECTOR, credentialId: 'cred-2' }, + ]) + mocks.dispatchSync.mockRejectedValueOnce(new Error('queue unavailable')) + + const outcome = await switchTo({ accessMode: 'workspace', credentialId: 'cred-2' }) + + expect(outcome).toMatchObject({ + success: true, + changed: true, + connector: { credentialId: 'cred-2' }, + }) + expect(setCallWith('credentialId')).toMatchObject({ nextSyncAt: expect.any(Date) }) + expect(mocks.dispatchSync).toHaveBeenCalledOnce() + }) + it('refuses a credential change while a sync owns the connector', async () => { queueTableRows(schemaMock.knowledgeConnector, [WORKSPACE_CONNECTOR]) queueTableRows(schemaMock.knowledgeConnector, [ @@ -543,7 +612,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { }) it('changes the credential of a paused connector without queuing a sync', async () => { - queueTableRows(schemaMock.knowledgeConnector, [{ ...WORKSPACE_CONNECTOR, status: 'paused' }]) + dbChainMockFns.limit.mockResolvedValue([{ ...WORKSPACE_CONNECTOR, status: 'paused' }]) dbChainMockFns.returning.mockResolvedValueOnce([ { ...WORKSPACE_CONNECTOR, status: 'paused', credentialId: 'cred-2' }, ]) @@ -576,7 +645,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { listingCheckpoint: { pageToken: 'old-listing' }, directoryCheckpoint: { pageToken: 'old-directory' }, } - queueTableRows(schemaMock.knowledgeConnector, [disabled]) + dbChainMockFns.limit.mockResolvedValue([disabled]) dbChainMockFns.returning.mockResolvedValueOnce([ { ...disabled, credentialId: 'cred-2', lastSyncAt: null }, ]) @@ -603,16 +672,6 @@ describe('performUpdateKnowledgeConnectorAccess', () => { updatedAt: expect.any(Date), }) const condition = dbChainMockFns.where.mock.calls.at(-1)?.[0] - expect( - hasMockCondition( - condition, - (node: MockCondition) => - node.type === 'inArray' && - node.column === schemaMock.knowledgeConnector.status && - Array.isArray(node.values) && - node.values.includes('disabled') - ) - ).toBe(true) for (const [column, value] of [ [schemaMock.knowledgeConnector.id, 'c-1'], [schemaMock.knowledgeConnector.knowledgeBaseId, 'kb-1'], @@ -662,7 +721,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => { error: 'Sync already in progress', errorCode: 'conflict', }) - expect(dbChainMockFns.update).toHaveBeenCalledOnce() + expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mocks.dispatchSync).not.toHaveBeenCalled() expect(mocks.dispatchMemberSync).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/knowledge/orchestration/connector-access.ts b/apps/sim/lib/knowledge/orchestration/connector-access.ts index bf4aab69bfc..94ff695cfc7 100644 --- a/apps/sim/lib/knowledge/orchestration/connector-access.ts +++ b/apps/sim/lib/knowledge/orchestration/connector-access.ts @@ -30,6 +30,7 @@ import { getKnowledgeConnector, type KnowledgeConnectorRow, lockCredentialGroupOption, + performUpdateKnowledgeConnector, } from '@/lib/knowledge/orchestration/connectors' import { classifyKnowledgeFailure, @@ -193,6 +194,9 @@ export interface PerformUpdateKnowledgeConnectorAccessParams extends KnowledgeOp knowledgeBase: { id: string; name: string; workspaceId?: string; organizationId?: string } connectorId: string target: ConnectorAccessTarget + sourceConfig?: Record + syncIntervalMinutes?: number + expectedUpdatedAt?: Date resolveBillingAttribution: () => Promise } @@ -238,7 +242,18 @@ export async function performUpdateKnowledgeConnectorAccess( ? target.binding.credentialGroupOptionId === existing.credentialGroupOptionId && (target.credentialId ?? null) === existing.credentialId : target.credentialId === existing.credentialId) - if (unchanged) { + const settingsChanged = + params.sourceConfig !== undefined || params.syncIntervalMinutes !== undefined + if ( + settingsChanged && + (target.accessMode === 'members' || target.accessMode !== existing.accessMode) + ) { + return fail( + 'Save source settings separately when changing the connection method.', + 'validation' + ) + } + if (unchanged && !settingsChanged) { /** * Re-applying the current binding on a connector whose member sync was * disabled is how it is re-enabled: the next run reconciles members from @@ -275,51 +290,19 @@ export async function performUpdateKnowledgeConnectorAccess( return { success: true, connector, changed: false } } - /** - * Staying in the same credential-backed mode with a different credential - * moves no document's visibility, so the lease is not taken. It does change - * what the source shows: the new credential may see a different corpus, and - * only a full listing reconciles that, so the incremental watermark is - * dropped and a sync queued unless the source is paused or disabled. The - * write refuses while a sync owns the row, whose terminal write would - * otherwise put the watermark straight back. - */ if (target.accessMode !== 'members' && target.accessMode === existing.accessMode) { - const now = new Date() - const [updated] = await db - .update(knowledgeConnector) - .set({ + const outcome = await performUpdateKnowledgeConnector({ + ...params, + knowledgeBase: { ...kb, workspaceId: kb.workspaceId ?? null }, + expectedUpdatedAt: params.expectedUpdatedAt ?? existing.updatedAt, + updates: { credentialId: target.credentialId, - lastSyncAt: null, - listingCheckpoint: null, - directoryCheckpoint: null, - nextSyncAt: now, - updatedAt: now, - }) - .where( - and( - eq(knowledgeConnector.id, connectorId), - eq(knowledgeConnector.knowledgeBaseId, kb.id), - inArray(knowledgeConnector.status, [...SWITCHABLE_CONNECTOR_STATUSES, 'disabled']), - eq(knowledgeConnector.status, existing.status), - isNull(knowledgeConnector.syncLockToken), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - .returning() - if (!updated) { - const current = await getKnowledgeConnector(kb.id, connectorId) - return current - ? fail('Sync already in progress', 'conflict') - : fail('Connector not found', 'not_found') - } - logger.info(`[${requestId}] Changed the credential of connector ${connectorId}`) - const { encryptedApiKey: _secret, ...connector } = updated - if (existing.status !== 'paused' && existing.status !== 'disabled') { - await dispatchContentSyncBestEffort(connectorId, params, requestId, now) - } - return { success: true, connector, changed: true } + sourceConfig: params.sourceConfig, + syncIntervalMinutes: params.syncIntervalMinutes, + }, + recordSemanticAudit: false, + }) + return outcome.success ? { ...outcome, changed: true } : outcome } const switchId = generateId() diff --git a/apps/sim/lib/knowledge/orchestration/connectors.test.ts b/apps/sim/lib/knowledge/orchestration/connectors.test.ts index e9606037f74..80c4cb3168c 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.test.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.test.ts @@ -804,7 +804,7 @@ describe('performUpdateKnowledgeConnector', () => { }) }) - it('reports a queue failure and leaves the source sync due for retry', async () => { + it('returns the committed settings and leaves the source sync due when dispatch fails', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { id: 'conn-1', @@ -833,9 +833,8 @@ describe('performUpdateKnowledgeConnector', () => { }) expect(outcome).toMatchObject({ - success: false, - errorCode: 'internal', - error: 'queue unavailable', + success: true, + connector: { id: 'conn-1' }, }) expect(dbChainMockFns.update).toHaveBeenCalledOnce() expect(dbChainMockFns.set).toHaveBeenCalledWith( diff --git a/apps/sim/lib/knowledge/orchestration/connectors.ts b/apps/sim/lib/knowledge/orchestration/connectors.ts index 209d500ea68..f4e887ee237 100644 --- a/apps/sim/lib/knowledge/orchestration/connectors.ts +++ b/apps/sim/lib/knowledge/orchestration/connectors.ts @@ -659,6 +659,8 @@ export interface PerformUpdateKnowledgeConnectorParams extends KnowledgeOperatio knowledgeBase: ConnectorKnowledgeBase connectorId: string updates: { + /** An authorized access operation has already validated this replacement. */ + credentialId?: string | null sourceConfig?: Record syncIntervalMinutes?: number status?: 'active' | 'paused' @@ -759,9 +761,15 @@ export async function performUpdateKnowledgeConnector( * so allowing it would silently discard the change. Refusing is the only * answer that is honest about either. */ + const credentialChanged = + updates.credentialId !== undefined && updates.credentialId !== existing.credentialId + if (updates.credentialId !== undefined && existing.accessMode === 'members') { + return fail('Member account changes require the access operation', 'validation') + } const membershipOnly = Boolean( params.permissionChange && !params.permissionChange.requiresContentSync && + !credentialChanged && updates.sourceConfig === undefined && updates.syncIntervalMinutes === undefined && updates.status === undefined @@ -780,7 +788,8 @@ export async function performUpdateKnowledgeConnector( */ if ( existing.status === 'pending' && - (updates.sourceConfig !== undefined || + (credentialChanged || + updates.sourceConfig !== undefined || updates.syncIntervalMinutes !== undefined || params.permissionChange?.requiresContentSync) ) { @@ -869,7 +878,9 @@ export async function performUpdateKnowledgeConnector( const resultingStatus = updates.status ?? existing.status const shouldDispatchSourceSync = - (updates.sourceConfig !== undefined || params.permissionChange?.requiresContentSync === true) && + (credentialChanged || + updates.sourceConfig !== undefined || + params.permissionChange?.requiresContentSync === true) && resultingStatus !== 'paused' && resultingStatus !== 'disabled' /** @@ -897,6 +908,13 @@ export async function performUpdateKnowledgeConnector( const values: Partial = { updatedAt: updateTimestamp, } + if (credentialChanged) { + values.credentialId = updates.credentialId + values.lastSyncAt = null + values.listingCheckpoint = null + values.directoryCheckpoint = null + values.nextSyncAt = updateTimestamp + } if (params.permissionChange?.encryptedApiKey) values.encryptedApiKey = params.permissionChange.encryptedApiKey if (params.permissionChange?.requiresAclReset) values.accessRewritePending = true @@ -966,7 +984,11 @@ export async function performUpdateKnowledgeConnector( isNull(knowledgeConnector.deletedAt), ] updateConditions.push(eq(knowledgeConnector.status, existing.status)) - if (sourceConfigToStore !== undefined || params.permissionChange) + if (credentialChanged) { + updateConditions.push(isNull(knowledgeConnector.syncLockToken)) + updateConditions.push(eq(knowledgeConnector.accessMode, existing.accessMode)) + } + if (credentialChanged || sourceConfigToStore !== undefined || params.permissionChange) updateConditions.push(eq(knowledgeConnector.updatedAt, existing.updatedAt)) if (syncsPerMember) { updateConditions.push(eq(knowledgeConnector.memberSyncStatus, existing.memberSyncStatus)) @@ -1051,10 +1073,12 @@ export async function performUpdateKnowledgeConnector( requireRunnable: true, }) } catch (error) { - return classifyKnowledgeFailure( - error, - requestId, - `Dispatch source-change member sync for connector ${connectorId}` + logger.error( + `[${requestId}] Saved connector; member sync remains due after dispatch failed`, + { + connectorId, + error, + } ) } } @@ -1068,11 +1092,10 @@ export async function performUpdateKnowledgeConnector( requireRunnable: true, }) } catch (error) { - return classifyKnowledgeFailure( + logger.error(`[${requestId}] Saved connector; sync remains due after dispatch failed`, { + connectorId, error, - requestId, - `Dispatch source-change sync for connector ${connectorId}` - ) + }) } }