diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
new file mode 100644
index 00000000000..31a82cd10ce
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.test.tsx
@@ -0,0 +1,226 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ createDraft: vi.fn(),
+ connectOAuthService: vi.fn(),
+ onConnect: vi.fn(),
+}))
+
+vi.mock('@sim/emcn', () => ({
+ Badge: ({ children }: { children?: ReactNode }) => {children},
+ ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
+ open ?
{children}
: null,
+ ChipModalBody: ({ children }: { children?: ReactNode }) => {children}
,
+ ChipModalError: ({ children }: { children?: ReactNode }) => {children}
,
+ ChipModalField: ({ title, children }: { title: string; children?: ReactNode }) => (
+
+ ),
+ ChipModalFooter: ({
+ primaryAction,
+ }: {
+ primaryAction: { label: string; onClick: () => void; disabled: boolean }
+ }) => (
+
+ ),
+ ChipModalHeader: ({ children }: { children?: ReactNode }) => ,
+ InfoCard: ({ children }: { children?: ReactNode }) => {children}
,
+ InfoCardItem: ({ children }: { children?: ReactNode }) => {children}
,
+ InfoCardList: ({ children }: { children?: ReactNode }) => {children}
,
+}))
+
+vi.mock('@/lib/auth/auth-client', () => ({
+ useSession: () => ({ data: { user: { name: 'Test User' } } }),
+}))
+
+vi.mock('@/lib/credentials/client-state', () => ({
+ ADD_CONNECTOR_SEARCH_PARAM: 'addConnector',
+ writeOAuthReturnContext: vi.fn(),
+}))
+
+vi.mock('@/lib/credentials/display-name', () => ({
+ defaultCredentialDisplayName: () => 'Test credential',
+}))
+
+vi.mock('@/lib/oauth', () => ({
+ getProviderIdFromServiceId: (serviceId: string) => serviceId,
+ OAUTH_PROVIDERS: {
+ slack: {
+ name: 'Slack',
+ icon: null,
+ services: {},
+ },
+ },
+ parseProvider: (provider: string) => ({ baseProvider: provider }),
+}))
+
+vi.mock('@/lib/oauth/utils', () => ({
+ getScopeDescription: (scope: string) => scope,
+ getServiceConfigByProviderId: () => null,
+}))
+
+vi.mock('@/blocks/brand-icon', () => ({
+ withBrandIcon: () => null,
+}))
+
+vi.mock('@/hooks/queries/credentials', () => ({
+ useCreateCredentialDraft: () => ({
+ mutateAsync: mocks.createDraft,
+ isPending: false,
+ }),
+ useWorkspaceCredentials: () => ({
+ data: [],
+ isPending: false,
+ }),
+}))
+
+vi.mock('@/hooks/queries/oauth/oauth-connections', () => ({
+ useConnectOAuthService: () => ({
+ mutateAsync: mocks.connectOAuthService,
+ isPending: false,
+ }),
+}))
+
+import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal'
+
+let container: HTMLDivElement
+let root: Root
+
+function renderReauthorizeModal({
+ reconnectTarget,
+ onConnect,
+}: {
+ reconnectTarget?: {
+ workspaceId: string
+ credentialId: string
+ displayName: string
+ }
+ onConnect?: () => Promise | void
+} = {}) {
+ act(() => {
+ root.render(
+
+ )
+ })
+}
+
+async function clickConnect() {
+ const button = container.querySelector('[data-testid="connect"]')
+ expect(button).not.toBeNull()
+ await act(async () => {
+ button?.click()
+ })
+}
+
+describe('ConnectOAuthModal reauthorization', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createDraft.mockResolvedValue({ success: true, draftId: 'draft-exact' })
+ mocks.connectOAuthService.mockResolvedValue({ success: true })
+ mocks.onConnect.mockResolvedValue(undefined)
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ })
+
+ afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ })
+
+ it('binds the selected credential draft to the OAuth launch', async () => {
+ renderReauthorizeModal({
+ reconnectTarget: {
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ },
+ })
+
+ await clickConnect()
+
+ expect(mocks.createDraft).toHaveBeenCalledWith({
+ workspaceId: 'workspace-1',
+ providerId: 'slack',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ })
+ expect(mocks.connectOAuthService).toHaveBeenCalledWith({
+ providerId: 'slack',
+ callbackURL: window.location.href,
+ draftId: 'draft-exact',
+ })
+ expect(mocks.createDraft.mock.invocationCallOrder[0]).toBeLessThan(
+ mocks.connectOAuthService.mock.invocationCallOrder[0]
+ )
+ })
+
+ it('does not launch OAuth when the reconnect draft cannot be created', async () => {
+ mocks.createDraft.mockRejectedValue(new Error('Draft creation failed'))
+ renderReauthorizeModal({
+ reconnectTarget: {
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ },
+ })
+
+ await clickConnect()
+
+ expect(mocks.connectOAuthService).not.toHaveBeenCalled()
+ expect(container).toHaveTextContent('Draft creation failed')
+ })
+
+ it('preserves provider-only reauthorization without creating a draft', async () => {
+ renderReauthorizeModal()
+
+ await clickConnect()
+
+ expect(mocks.createDraft).not.toHaveBeenCalled()
+ expect(mocks.connectOAuthService).toHaveBeenCalledWith({
+ providerId: 'slack',
+ callbackURL: window.location.href,
+ draftId: undefined,
+ })
+ })
+
+ it('keeps an onConnect override ahead of credential-bound reauthorization', async () => {
+ renderReauthorizeModal({
+ reconnectTarget: {
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-slack',
+ displayName: 'Team Slack',
+ },
+ onConnect: mocks.onConnect,
+ })
+
+ await clickConnect()
+
+ expect(mocks.onConnect).toHaveBeenCalledOnce()
+ expect(mocks.createDraft).not.toHaveBeenCalled()
+ expect(mocks.connectOAuthService).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index d18cbf39970..552637626c8 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -112,6 +112,11 @@ interface ConnectOAuthModalReauthorizeProps extends ConnectOAuthModalBaseProps {
toolName: string
requiredScopes?: readonly string[]
newScopes?: readonly string[]
+ reconnectTarget?: {
+ workspaceId: string
+ credentialId: string
+ displayName: string
+ }
onConnect?: () => Promise | void
}
@@ -316,6 +321,16 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
handleClose()
return
} else {
+ if (props.reconnectTarget) {
+ const draft = await createDraft.mutateAsync({
+ workspaceId: props.reconnectTarget.workspaceId,
+ providerId,
+ credentialId: props.reconnectTarget.credentialId,
+ displayName: props.reconnectTarget.displayName,
+ })
+ draftId = draft.draftId
+ }
+
logger.info('Reauthorizing OAuth2', {
providerId,
requiredScopes,
@@ -341,7 +356,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
}
}
- const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending
+ const createsDraft = isConnect || (!isConnect && Boolean(props.reconnectTarget))
+ const isPending = (createsDraft && createDraft.isPending) || connectOAuthService.isPending
const isDisabled = isConnect
? !displayName.trim() || isPending || Boolean(existingCredential)
: isPending
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx
index 41d2bc89da2..f481648f220 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.test.tsx
@@ -1,17 +1,30 @@
/**
* @vitest-environment jsdom
*/
-import type { ReactNode, SVGProps } from 'react'
+import type { ButtonHTMLAttributes, ReactNode, SVGProps } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SyncLogData } from '@/lib/api/contracts/knowledge/connectors'
import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS } from '@/lib/knowledge/connectors/sync-limits'
-const { icon } = vi.hoisted(() => ({
+const {
+ consumeOAuthReturnContextMock,
+ connectOAuthModalMock,
+ credentialRefreshTriggersMock,
+ icon,
+ oauthCredentialsState,
+} = vi.hoisted(() => ({
+ consumeOAuthReturnContextMock: vi.fn(),
+ connectOAuthModalMock: vi.fn(),
+ credentialRefreshTriggersMock: vi.fn(),
icon: (name: string) => (props: SVGProps) => (
),
+ oauthCredentialsState: {
+ current: [] as Array<{ id: string; name: string; provider: string }>,
+ isFetching: false,
+ },
}))
vi.mock('@sim/emcn/icons', () => ({
@@ -30,7 +43,16 @@ vi.mock('@sim/emcn/icons', () => ({
vi.mock('@sim/emcn', () => ({
Badge: ({ children }: { children?: ReactNode }) => {children},
- Button: ({ children }: { children?: ReactNode }) => ,
+ Button: ({
+ children,
+ variant: _variant,
+ size: _size,
+ ...props
+ }: ButtonHTMLAttributes & { variant?: string; size?: string }) => (
+
+ ),
Checkbox: () => ,
ChipConfirmModal: () => null,
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
@@ -38,20 +60,27 @@ vi.mock('@sim/emcn', () => ({
DropdownMenuContent: ({ children }: { children?: ReactNode }) => {children}
,
DropdownMenuItem: ({ children }: { children?: ReactNode }) => {children}
,
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => {children}
,
- Tooltip: ({ children }: { children?: ReactNode }) => {children}
,
+ Tooltip: {
+ Root: ({ children }: { children?: ReactNode }) => {children}
,
+ Trigger: ({ children }: { children?: ReactNode }) => {children}
,
+ Content: ({ children }: { children?: ReactNode }) => {children}
,
+ },
}))
vi.mock('@/lib/credentials/client-state', () => ({
- consumeOAuthReturnContext: vi.fn(),
+ consumeOAuthReturnContext: consumeOAuthReturnContextMock,
writeOAuthReturnContext: vi.fn(),
}))
vi.mock('@/lib/oauth', () => ({
getCanonicalScopesForProvider: vi.fn(() => []),
- getProviderIdFromServiceId: vi.fn(() => undefined),
+ getProviderIdFromServiceId: vi.fn(() => 'slack'),
}))
vi.mock('@/lib/oauth/utils', () => ({ getMissingRequiredScopes: vi.fn(() => []) }))
vi.mock('@/app/workspace/[workspaceId]/components/connect-oauth-modal', () => ({
- ConnectOAuthModal: () => null,
+ ConnectOAuthModal: (props: unknown) => {
+ connectOAuthModalMock(props)
+ return null
+ },
}))
vi.mock(
'@/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal',
@@ -59,21 +88,41 @@ vi.mock(
)
vi.mock('@/blocks', () => ({ getBlock: vi.fn(() => undefined) }))
vi.mock('@/blocks/icon-color', () => ({ getTileIconColorClass: vi.fn(() => '') }))
-vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: {} }))
+vi.mock('@/connectors/registry', () => ({
+ CONNECTOR_META_REGISTRY: {
+ slack: {
+ id: 'slack',
+ name: 'Slack',
+ auth: { mode: 'oauth', provider: 'slack', requiredScopes: ['channels:read'] },
+ },
+ },
+}))
vi.mock('@/hooks/queries/kb/connectors', () => ({
+ isConnectorSyncingOrPending: vi.fn(
+ (connector: { status: string }) =>
+ connector.status === 'pending' || connector.status === 'syncing'
+ ),
useConnectorDetail: vi.fn(() => ({ data: undefined, isLoading: false })),
useDeleteConnector: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
useTriggerSync: vi.fn(() => ({ mutate: vi.fn() })),
useUpdateConnector: vi.fn(() => ({ mutate: vi.fn() })),
}))
vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({
- useOAuthCredentials: vi.fn(() => ({ data: [] })),
+ useOAuthCredentials: vi.fn(() => ({
+ data: oauthCredentialsState.current,
+ isFetching: oauthCredentialsState.isFetching,
+ refetch: vi.fn(),
+ })),
}))
vi.mock('@/hooks/use-credential-refresh-triggers', () => ({
- useCredentialRefreshTriggers: vi.fn(),
+ useCredentialRefreshTriggers: credentialRefreshTriggersMock,
}))
-import { SyncHistory } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section'
+import {
+ ConnectorsSection,
+ SyncHistory,
+} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section'
+import type { ConnectorData } from '@/hooks/queries/kb/connectors'
let root: Root | null = null
@@ -102,6 +151,46 @@ function render(log: SyncLogData) {
return container
}
+function makeConnector(overrides: Partial = {}): ConnectorData {
+ return {
+ id: 'connector-1',
+ knowledgeBaseId: 'knowledge-1',
+ connectorType: 'slack',
+ credentialId: 'credential-1',
+ sourceConfig: {},
+ syncMode: null,
+ syncIntervalMinutes: 60,
+ status: 'disabled',
+ lastSyncAt: null,
+ lastSyncError: 'invalid_auth',
+ lastSyncDocCount: null,
+ nextSyncAt: null,
+ consecutiveFailures: 3,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ ...overrides,
+ }
+}
+
+function renderSection(connector: ConnectorData) {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() =>
+ root?.render(
+
+ )
+ )
+ return container
+}
+
function icons(container: HTMLElement) {
return Array.from(container.querySelectorAll('[data-testid^="icon-"]')).map((node) =>
node.getAttribute('data-testid')
@@ -112,9 +201,136 @@ afterEach(() => {
act(() => root?.unmount())
root = null
document.body.innerHTML = ''
+ oauthCredentialsState.current = []
+ oauthCredentialsState.isFetching = false
vi.clearAllMocks()
})
+describe('Connector credential reauthorization', () => {
+ it('fails closed when the connector credential cannot be resolved', () => {
+ const container = renderSection(makeConnector())
+ const reconnectButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Reconnect'
+ )
+
+ expect(reconnectButton?.disabled).toBe(true)
+
+ act(() => reconnectButton?.click())
+
+ expect(connectOAuthModalMock).not.toHaveBeenCalled()
+ })
+
+ it('reauthorizes with the resolved credential provider and identity', () => {
+ oauthCredentialsState.current = [
+ { id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
+ ]
+ const container = renderSection(makeConnector())
+ const reconnectButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Reconnect'
+ )
+
+ expect(reconnectButton?.disabled).toBe(false)
+
+ act(() => reconnectButton?.click())
+
+ expect(connectOAuthModalMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ providerId: 'slack-custom',
+ reconnectTarget: {
+ workspaceId: 'workspace-1',
+ credentialId: 'credential-1',
+ displayName: 'Workspace Slack',
+ },
+ })
+ )
+ expect(credentialRefreshTriggersMock).toHaveBeenLastCalledWith(
+ expect.any(Function),
+ 'slack-custom',
+ 'workspace-1'
+ )
+ })
+
+ it('keeps reauthorization open while the credential query is loading', () => {
+ oauthCredentialsState.current = [
+ { id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
+ ]
+ const connector = makeConnector()
+ const container = renderSection(connector)
+ const reconnectButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Reconnect'
+ )
+
+ act(() => reconnectButton?.click())
+ expect(connectOAuthModalMock).toHaveBeenCalledOnce()
+
+ connectOAuthModalMock.mockClear()
+ oauthCredentialsState.current = []
+ oauthCredentialsState.isFetching = true
+ act(() =>
+ root?.render(
+
+ )
+ )
+
+ expect(consumeOAuthReturnContextMock).not.toHaveBeenCalled()
+
+ oauthCredentialsState.current = [
+ { id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
+ ]
+ oauthCredentialsState.isFetching = false
+ act(() =>
+ root?.render(
+
+ )
+ )
+
+ expect(connectOAuthModalMock).toHaveBeenCalledOnce()
+ })
+
+ it('clears the OAuth return context if the credential disappears while open', () => {
+ oauthCredentialsState.current = [
+ { id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
+ ]
+ const connector = makeConnector()
+ const container = renderSection(connector)
+ const reconnectButton = Array.from(container.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Reconnect'
+ )
+
+ act(() => reconnectButton?.click())
+ expect(connectOAuthModalMock).toHaveBeenCalledOnce()
+
+ connectOAuthModalMock.mockClear()
+ oauthCredentialsState.current = []
+ act(() =>
+ root?.render(
+
+ )
+ )
+
+ expect(consumeOAuthReturnContextMock).toHaveBeenCalledOnce()
+ expect(connectOAuthModalMock).not.toHaveBeenCalled()
+ })
+})
+
describe('SyncHistory', () => {
it('renders a fresh "started" row as in progress, not as a success', () => {
const container = render(makeLog({ status: 'started' }))
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
index 10b0a8b6e4a..f72341b5c83 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useId, useMemo, useState } from 'react'
+import { useEffect, useId, useMemo, useState } from 'react'
import {
Badge,
Button,
@@ -280,18 +280,36 @@ function ConnectorCard({
? (connectorDef.auth.requiredScopes ?? EMPTY_REQUIRED_SCOPES)
: EMPTY_REQUIRED_SCOPES
- const { data: credentials, refetch: refetchCredentials } = useOAuthCredentials(providerId, {
+ const {
+ data: credentials,
+ isFetching: credentialsLoading,
+ refetch: refetchCredentials,
+ } = useOAuthCredentials(providerId, {
workspaceId,
})
- useCredentialRefreshTriggers(refetchCredentials, providerId ?? '', workspaceId)
+ const selectedCredential = useMemo(() => {
+ if (!credentials || !connector.credentialId) return undefined
+ return credentials.find((credential) => credential.id === connector.credentialId)
+ }, [credentials, connector.credentialId])
+
+ useCredentialRefreshTriggers(
+ refetchCredentials,
+ selectedCredential?.provider ?? providerId ?? '',
+ workspaceId
+ )
+
+ const missingScopes = useMemo(
+ () => (selectedCredential ? getMissingRequiredScopes(selectedCredential, requiredScopes) : []),
+ [selectedCredential, requiredScopes]
+ )
- const missingScopes = useMemo(() => {
- if (!credentials || !connector.credentialId) return []
- const credential = credentials.find((c) => c.id === connector.credentialId)
- if (!credential) return []
- return getMissingRequiredScopes(credential, requiredScopes)
- }, [credentials, connector.credentialId, requiredScopes])
+ useEffect(() => {
+ if (showOAuthModal && connector.credentialId && !selectedCredential && !credentialsLoading) {
+ consumeOAuthReturnContext()
+ setShowOAuthModal(false)
+ }
+ }, [showOAuthModal, connector.credentialId, selectedCredential, credentialsLoading])
const { data: detail, isLoading: detailLoading } = useConnectorDetail(
expanded ? knowledgeBaseId : undefined,
@@ -515,13 +533,15 @@ function ConnectorCard({
{canEdit && serviceId && providerId && (