Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-show
vi.mock('@/hooks/queries/kb/connectors', () => ({
useSearchSources: mocks.sources,
useSearchSourceOverview: mocks.overview,
searchSourceKeys: { list: (scope: unknown) => ['sources', scope] },
}))
vi.mock('@/hooks/use-member-enrollment', () => ({
CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']),
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/o/[organizationId]/integrations/integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,9 @@ import {
RESOURCE_LIST_STACK,
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import {
searchSourceKeys,
useSearchSourceOverview,
useSearchSources,
} from '@/hooks/queries/kb/connectors'
import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors'
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
import { useDebounce } from '@/hooks/use-debounce'
import { useMemberEnrollment } from '@/hooks/use-member-enrollment'
import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client'

import { ChipConfirmModal, ChipModalError } from '@sim/emcn'
import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts'
import { useUpdateOrganizationAccounts } from '@/hooks/queries/organization-accounts'

interface OrganizationSlackAccountRemovalProps {
organizationId: string
group: NonNullable<OrganizationAccountsSettings['credentialGroup']>
onClose: () => void
onRemoved: () => void
}

export function OrganizationSlackAccountRemoval({
Comment thread
TheodoreSpeaks marked this conversation as resolved.
organizationId,
group,
onClose,
onRemoved,
}: OrganizationSlackAccountRemovalProps) {
const update = useUpdateOrganizationAccounts()
return (
<ChipConfirmModal
open
onOpenChange={(open) => {
if (!open && !update.isPending) onClose()
}}
title='Remove Slack account setup?'
text='This disconnects your organization’s Slack accounts and clears their saved app configuration. Remove any sources using these accounts first.'
confirm={{
label: 'Remove',
variant: 'destructive',
pending: update.isPending,
onClick: () =>
update.mutate(
{
organizationId,
groupId: group.id,
update: {
options: group.options
.filter((option) => option.provider !== 'slack')
.map(({ id, provider, label, required }) => ({ id, provider, label, required })),
},
},
{ onSuccess: onRemoved }
),
}}
>
<ChipModalError>{update.error?.message}</ChipModalError>
</ChipConfirmModal>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const mocks = vi.hoisted(() => ({
approvalError: null as Error | null,
availabilityError: null as Error | null,
retryAvailability: vi.fn(),
removeAccounts: vi.fn(),
accountRemovalError: null as Error | null,
accountRemovalPending: false,
}))

vi.mock('next/navigation', () => ({
Expand Down Expand Up @@ -60,6 +63,11 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({
}))
vi.mock('@/hooks/queries/organization-accounts', () => ({
useOrganizationAccounts: mocks.accounts,
useUpdateOrganizationAccounts: () => ({
mutate: mocks.removeAccounts,
error: mocks.accountRemovalError,
isPending: mocks.accountRemovalPending,
}),
}))
vi.mock('@/hooks/queries/search-integrations', () => ({
useUpdateSearchIntegration: () => ({
Expand Down Expand Up @@ -126,6 +134,8 @@ describe('organization provider management', () => {
mocks.access = { admin: true, members: true }
mocks.approvalError = null
mocks.availabilityError = null
mocks.accountRemovalError = null
mocks.accountRemovalPending = false
mocks.overview.mockReturnValue({ data: { providers: [provider] }, isPending: false })
mocks.sources.mockReturnValue({
data: [source],
Expand Down Expand Up @@ -168,6 +178,96 @@ describe('organization provider management', () => {
await act(async () => button!.click())
}

function withSlackAccounts(approved = true, status = 'active') {
mocks.overview.mockReturnValue({
data: { providers: [{ ...provider, connectorType: 'slack', approved }] },
})
mocks.accounts.mockReturnValue({
data: {
credentialGroup: {
...credentialGroup,
options: [
{ ...credentialGroup.options[0], label: 'Google', required: true },
{
id: 'slack-option',
provider: 'slack',
label: 'Slack',
required: false,
status,
configurationStatus: 'ready',
},
],
},
},
})
}

it.each(['active', 'disabled'])(
'removes only Slack account setup after confirmation, including a %s option',
async (status) => {
withSlackAccounts(true, status)
await render('slack')
await click('Remove account setup')
expect(mocks.removeAccounts).not.toHaveBeenCalled()
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('saved app configuration')
await click('Remove')
expect(mocks.removeAccounts).toHaveBeenCalledExactlyOnceWith(
{
organizationId: 'org-one',
groupId: 'accounts-one',
update: {
options: [{ id: 'google-option', provider: 'google', label: 'Google', required: true }],
},
},
{ onSuccess: expect.any(Function) }
)
await act(async () => mocks.removeAccounts.mock.calls[0][1].onSuccess())
expect(document.querySelector('[role="dialog"]')).toBeNull()
}
)

it('offers removal when Slack is deactivated and allows cancelling without a mutation', async () => {
withSlackAccounts(false)
await render('slack')
await click('Remove account setup')
await click('Cancel')
expect(document.querySelector('[role="dialog"]')).toBeNull()
expect(mocks.removeAccounts).not.toHaveBeenCalled()
})

it('keeps connector-dependency errors visible in the removal dialog', async () => {
withSlackAccounts()
mocks.accountRemovalError = new Error('Remove the source using these accounts first.')
await render('slack')
await click('Remove account setup')
await click('Remove')
expect(document.querySelector('[role="dialog"] [role="alert"]')).toHaveTextContent(
'Remove the source using these accounts first.'
)
})

it('passes the removal action to the Slack Accounts tab header', async () => {
withSlackAccounts()
await render('slack', '?view=accounts')
const actions = mocks.people.mock.calls.at(-1)![0].panel.actions
expect(actions).toEqual([
expect.objectContaining({ text: 'Remove account setup', onSelect: expect.any(Function) }),
])
await act(async () => actions[0].onSelect())
expect(document.querySelector('[role="dialog"]')).toHaveTextContent(
'Remove Slack account setup?'
)
})

it('keeps Slack cleanup available even when personal source creation is unavailable', async () => {
withSlackAccounts()
mocks.personal = false
await render('slack')
expect(mocks.accounts).toHaveBeenCalledWith('org-one')
await click('Remove account setup')
expect(document.querySelector('[role="dialog"]')).not.toBeNull()
})

it('uses named source links even when the admin has not reconnected their own account', async () => {
await render()
expect(mocks.sources).toHaveBeenCalledWith(
Expand Down Expand Up @@ -450,6 +550,9 @@ describe('organization provider management', () => {
expect(container.textContent).toContain('Set up the Slack app to connect accounts.')
expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex')
await click('Set up Slack app')
await act(async () => {
await vi.waitFor(() => expect(mocks.updateUrl).toHaveBeenCalled())
})
const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString)
expect(query.get('connectedAccounts')).toBe('slack')
expect(query.get('view')).toBe('accounts')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
connectedAccountsParam,
organizationProviderTabParam,
} from '@/app/o/[organizationId]/settings/components/integrations/search-params'
import { OrganizationSlackAccountRemoval } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-removal'
import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup'
import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination'
import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup'
Expand Down Expand Up @@ -59,6 +60,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid
const [peopleSearch, setPeopleSearch] = useOrganizationAccountPeopleSearch()
const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS)
const [deactivating, setDeactivating] = useState(false)
const [removingSlackAccounts, setRemovingSlackAccounts] = useState(false)
const scope = { kind: 'organization', organizationId: organization.id } as const
const meta = CONNECTOR_META_REGISTRY[connectorType]
const personal = Boolean(meta && canConnectPersonally(meta) && searchAccess.memberScoped)
Expand All @@ -72,7 +74,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid
const availability = usePermissionConfig()
const approval = useUpdateSearchIntegration()
const accounts = useOrganizationAccounts(
viewer.isAdmin && personal && (showAccounts || connectorType === 'slack')
viewer.isAdmin && (connectorType === 'slack' || (personal && showAccounts))
? organization.id
: undefined
)
Expand Down Expand Up @@ -123,6 +125,20 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid
const option = accounts.data?.credentialGroup?.options.find(
(item) => item.provider === credentialProvider && item.status === 'active'
)
const group = accounts.data?.credentialGroup
const removalActions: SettingsAction[] =
connectorType === 'slack' &&
!accounts.isError &&
group?.options.some((item) => item.provider === 'slack')
? [
{
text: 'Remove account setup',
textTone: 'error',
disabled: accounts.isFetching,
onSelect: () => setRemovingSlackAccounts(true),
},
]
: []
const needsSlackSetup =
connectorType === 'slack' &&
(option?.provider !== 'slack' || option.configurationStatus !== 'ready')
Expand Down Expand Up @@ -170,6 +186,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid
onSelect: activate,
},
]
actions.push(...removalActions)
if (overview.isError)
return (
<SettingsPanel {...panel}>
Expand Down Expand Up @@ -297,7 +314,7 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid
<OrganizationAccountPeople
organizationId={organization.id}
searchConnection={{ optionId: option.id, providerName: meta.name }}
panel={panel}
panel={{ ...panel, actions: removalActions }}
/>
) : (
<SettingsPanel {...panel} actions={actions}>
Expand Down Expand Up @@ -335,6 +352,14 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid
mirroredAccessAvailable={searchAccess.sourceMirrored}
/>
<OrganizationSlackAccountSetup />
{removingSlackAccounts && group && (
<OrganizationSlackAccountRemoval
organizationId={organization.id}
group={group}
onClose={() => setRemovingSlackAccounts(false)}
onRemoved={() => setRemovingSlackAccounts(false)}
/>
)}
<ChipConfirmModal
open={deactivating}
onOpenChange={(open) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ vi.mock('@/hooks/queries/workspace', () => ({
useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: mocks.canAdmin } } }),
}))
vi.mock('@/hooks/queries/kb/connectors', () => ({
searchSourceKeys: { list: (id: string) => ['search-sources', id] },
useSearchSources: (id: string, options: { search: string }) => {
mocks.sourceQuery(id, options)
return {
Expand Down
7 changes: 2 additions & 5 deletions apps/sim/app/workspace/[workspaceId]/search/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,8 @@ import {
SettingsEmptyState,
SettingsQueryErrorState,
} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import {
searchSourceKeys,
useSearchSources,
useWorkspaceMemberConnectors,
} from '@/hooks/queries/kb/connectors'
import { useSearchSources, useWorkspaceMemberConnectors } from '@/hooks/queries/kb/connectors'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { type ReactNode, useState } from 'react'
import { Chip, ChipConfirmModal, ChipModalError, toast } from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
import type { SettingsBackAction } from '@/components/settings/settings-header'
import type { SettingsAction, SettingsBackAction } from '@/components/settings/settings-header'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
Expand All @@ -29,7 +29,13 @@ import { useOrganizationAccountPeopleSearch } from '@/hooks/use-organization-acc
interface OrganizationAccountPeopleProps {
organizationId: string
searchConnection?: { optionId: string; providerName: string }
panel?: { back: SettingsBackAction; title: string; description?: string; docsLink?: string }
panel?: {
back: SettingsBackAction
title: string
description?: string
docsLink?: string
actions?: SettingsAction[]
}
enabled?: boolean
setupFallback?: ReactNode
}
Expand Down Expand Up @@ -66,6 +72,7 @@ export function OrganizationAccountPeople({
disabled: pending || awaitingSetup,
onSelect: () => setInviteOpen(true),
},
...(panel?.actions ?? []),
]}
>
{awaitingSetup ? (
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/kb/connectors-cache.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import { act } from 'react'
import { QueryClient, QueryClientProvider, type QueryKey } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'

const mocks = vi.hoisted(() => ({ requestJson: vi.fn() }))

vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson }))

import {
searchSourceKeys,
useConnectSimSearchConnector,
useCreateConnector,
useDeleteConnector,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/hooks/queries/kb/connectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys'

const mocks = vi.hoisted(() => ({
requestJson: vi.fn(),
Expand Down Expand Up @@ -51,7 +52,6 @@ import {
connectorKeys,
isConnectorSyncingOrPending,
memberConnectorKeys,
searchSourceKeys,
useConnectorDetail,
useConnectorDocuments,
useConnectorList,
Expand Down
Loading
Loading