mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
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:
+226
@@ -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()
|
||||
})
|
||||
})
|
||||
+17
-1
@@ -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
|
||||
|
||||
+227
-11
@@ -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' }))
|
||||
|
||||
+58
-28
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+15
-3
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+15
-3
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user