fix(oauth): bind update access to selected credential (#6999)

* fix(oauth): bind update access to selected credential

* fix(oauth): guard unresolved connector credentials

* fix(oauth): clear stale connector return context

* fix(oauth): fail closed when reconnect target disappears

* fix(oauth): wait for reconnect credential lookup

* fix(oauth): refresh resolved connector credential

---------

Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
This commit is contained in:
Bill Leoutsakos
2026-08-24 12:32:26 -07:00
committed by GitHub
parent 472532e1a3
commit 445ef62880
7 changed files with 585 additions and 46 deletions
@@ -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 }) => <span>{children}</span>,
ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
open ? <div>{children}</div> : null,
ChipModalBody: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
ChipModalError: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
ChipModalField: ({ title, children }: { title: string; children?: ReactNode }) => (
<section>
<span>{title}</span>
{children}
</section>
),
ChipModalFooter: ({
primaryAction,
}: {
primaryAction: { label: string; onClick: () => void; disabled: boolean }
}) => (
<button
type='button'
data-testid='connect'
onClick={primaryAction.onClick}
disabled={primaryAction.disabled}
>
{primaryAction.label}
</button>
),
ChipModalHeader: ({ children }: { children?: ReactNode }) => <header>{children}</header>,
InfoCard: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InfoCardItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InfoCardList: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
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> | void
} = {}) {
act(() => {
root.render(
<ConnectOAuthModal
mode='reauthorize'
open={true}
onOpenChange={vi.fn()}
providerId='slack'
toolName='Slack'
reconnectTarget={reconnectTarget}
onConnect={onConnect}
/>
)
})
}
async function clickConnect() {
const button = container.querySelector<HTMLButtonElement>('[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()
})
})
@@ -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> | 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
@@ -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<SVGSVGElement>) => (
<svg data-testid={`icon-${name}`} className={props.className} />
),
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 }) => <span>{children}</span>,
Button: ({ children }: { children?: ReactNode }) => <button type='button'>{children}</button>,
Button: ({
children,
variant: _variant,
size: _size,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string; size?: string }) => (
<button type='button' {...props}>
{children}
</button>
),
Checkbox: () => <input type='checkbox' />,
ChipConfirmModal: () => null,
cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
@@ -38,20 +60,27 @@ vi.mock('@sim/emcn', () => ({
DropdownMenuContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Tooltip: {
Root: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Trigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Content: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
},
}))
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> = {}): 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(
<ConnectorsSection
workspaceId='workspace-1'
knowledgeBaseId='knowledge-1'
connectors={[connector]}
isLoading={false}
canEdit
/>
)
)
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(
<ConnectorsSection
workspaceId='workspace-1'
knowledgeBaseId='knowledge-1'
connectors={[connector]}
isLoading={false}
canEdit
/>
)
)
expect(consumeOAuthReturnContextMock).not.toHaveBeenCalled()
oauthCredentialsState.current = [
{ id: 'credential-1', name: 'Workspace Slack', provider: 'slack-custom' },
]
oauthCredentialsState.isFetching = false
act(() =>
root?.render(
<ConnectorsSection
workspaceId='workspace-1'
knowledgeBaseId='knowledge-1'
connectors={[connector]}
isLoading={false}
canEdit
/>
)
)
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(
<ConnectorsSection
workspaceId='workspace-1'
knowledgeBaseId='knowledge-1'
connectors={[connector]}
isLoading={false}
canEdit
/>
)
)
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' }))
@@ -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])
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])
useCredentialRefreshTriggers(
refetchCredentials,
selectedCredential?.provider ?? providerId ?? '',
workspaceId
)
const missingScopes = useMemo(
() => (selectedCredential ? getMissingRequiredScopes(selectedCredential, requiredScopes) : []),
[selectedCredential, 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 && (
<Button
variant='primary'
disabled={Boolean(connector.credentialId && !selectedCredential)}
onClick={() => {
if (connector.credentialId) {
if (!selectedCredential) return
writeOAuthReturnContext({
origin: 'kb-connectors',
knowledgeBaseId,
displayName: connectorDef?.name ?? connector.connectorType,
providerId: providerId!,
providerId: selectedCredential.provider,
preCount: credentials?.length ?? 0,
workspaceId,
reconnect: true,
@@ -552,11 +572,12 @@ function ConnectorCard({
variant='primary'
onClick={() => {
if (connector.credentialId) {
if (!selectedCredential) return
writeOAuthReturnContext({
origin: 'kb-connectors',
knowledgeBaseId,
displayName: connectorDef?.name ?? connector.connectorType,
providerId: providerId!,
providerId: selectedCredential.provider,
preCount: credentials?.length ?? 0,
workspaceId,
reconnect: true,
@@ -600,23 +621,32 @@ function ConnectorCard({
/>
)}
{showOAuthModal && serviceId && providerId && connector.credentialId && (
<ConnectOAuthModal
mode='reauthorize'
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
toolName={connectorDef?.name ?? connector.connectorType}
requiredScopes={getCanonicalScopesForProvider(providerId)}
newScopes={missingScopes}
serviceId={serviceId}
providerId={providerId}
/>
)}
{showOAuthModal &&
serviceId &&
providerId &&
connector.credentialId &&
selectedCredential && (
<ConnectOAuthModal
mode='reauthorize'
open={showOAuthModal}
onOpenChange={(open) => {
if (!open) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}}
toolName={connectorDef?.name ?? connector.connectorType}
requiredScopes={getCanonicalScopesForProvider(providerId)}
newScopes={missingScopes}
serviceId={serviceId}
providerId={selectedCredential.provider}
reconnectTarget={{
workspaceId,
credentialId: selectedCredential.id,
displayName: selectedCredential.name,
}}
/>
)}
</div>
)
}
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Combobox, type ComboboxOptionGroup } from '@sim/emcn'
import { Key, SquareArrowUpRight } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
@@ -209,6 +209,13 @@ export function CredentialSelector({
!isPreview &&
!credentialsLoading
useEffect(() => {
if (showOAuthModal && selectedId && !selectedCredential && !credentialsLoading) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}, [showOAuthModal, selectedId, selectedCredential, credentialsLoading])
const handleSelect = useCallback(
(credentialId: string) => {
if (isPreview) return
@@ -497,7 +504,7 @@ export function CredentialSelector({
/>
)}
{showOAuthModal && (
{showOAuthModal && selectedCredential && (
<ConnectOAuthModal
mode='reauthorize'
open={showOAuthModal}
@@ -515,7 +522,12 @@ export function CredentialSelector({
// A reauthorize must return to the authorization server that issued
// the credential — deriving it from the service id would send a
// sandbox user to production, where they cannot sign in at all.
providerId={selectedCredential?.provider ?? effectiveProviderId}
providerId={selectedCredential.provider}
reconnectTarget={{
workspaceId,
credentialId: selectedCredential.id,
displayName: selectedCredential.name,
}}
/>
)}
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Combobox } from '@sim/emcn'
import { SquareArrowUpRight } from '@sim/emcn/icons'
import { useParams } from 'next/navigation'
@@ -147,6 +147,13 @@ export function ToolCredentialSelector({
const needsUpdate =
hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading
useEffect(() => {
if (showOAuthModal && selectedId && !selectedCredential && !credentialsLoading) {
consumeOAuthReturnContext()
setShowOAuthModal(false)
}
}, [showOAuthModal, selectedId, selectedCredential, credentialsLoading])
const handleSelect = useCallback(
(credentialId: string) => {
onChange(credentialId)
@@ -279,7 +286,7 @@ export function ToolCredentialSelector({
/>
)}
{showOAuthModal && (
{showOAuthModal && selectedCredential && (
<ConnectOAuthModal
mode='reauthorize'
open={showOAuthModal}
@@ -297,7 +304,12 @@ export function ToolCredentialSelector({
// A reauthorize must return to the authorization server that issued
// the credential — deriving it from the service id would send a
// sandbox user to production, where they cannot sign in at all.
providerId={selectedCredential?.provider ?? effectiveProviderId}
providerId={selectedCredential.provider}
reconnectTarget={{
workspaceId,
credentialId: selectedCredential.id,
displayName: selectedCredential.name,
}}
/>
)}
</div>
@@ -70,6 +70,33 @@ describe('processCredentialDraft', () => {
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft)
})
it('reconnects the credential bound to an exact Slack draft regardless of account identity', async () => {
const draft = {
...credentialDraft('draft-slack', 'workspace-1'),
providerId: 'slack',
displayName: 'Team Slack',
credentialId: 'credential-slack',
}
queueTableRows(schemaMock.pendingCredentialDraft, [draft])
await processCredentialDraft({
draftId: 'draft-slack',
userId: 'user-1',
providerId: 'slack',
accountId: 'T01234567-usr_U01234567-new-account-id',
})
expect(mockHandleReconnectCredential).toHaveBeenCalledWith({
draft,
newAccountId: 'T01234567-usr_U01234567-new-account-id',
workspaceId: 'workspace-1',
userId: 'user-1',
now: expect.any(Date),
})
expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled()
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft)
})
it('fails closed when a legacy callback has multiple active drafts', async () => {
queueTableRows(schemaMock.pendingCredentialDraft, [
credentialDraft('draft-1', 'workspace-1'),