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
@@ -1,6 +1,7 @@
'use client'

import { Chip, toast } from '@sim/emcn'
import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes'
import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema'
import { liveSearchProviderForCredential } from '@/lib/sim-search/live/provider-catalog'
import {
Expand Down Expand Up @@ -133,6 +134,8 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt
Boolean(option || server) &&
approved &&
(!option || option.configurationStatus === 'ready') &&
(provider !== 'slack' ||
(option?.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) &&
((provider !== 'hubspot' && provider !== 'zoom') ||
data.availableMcpConnectors.includes(provider))
const scope =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ describe('live search administration', () => {
it('opens Slack setup in Sources instead of its redirected service-account page', async () => {
mocks.policies.mockReturnValue({ data: [{ connectorType: 'slack', approved: true }] })
await render()
await act(async () => button('Slack app')!.click())
await act(async () => button('Verify permissions')!.click())
expect(mockPush).not.toHaveBeenCalled()
expect(container.querySelector('a[href*="providers/slack"]')).toBeNull()
await act(async () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import { SettingsPanel } from '@/components/settings/settings-panel'
import type { SearchIntegrationApproval } from '@/lib/api/contracts/knowledge/search-integrations'
import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes'
import { organizationRoutes } from '@/lib/navigation/paths'
import {
defaultLiveSearchPolicy,
Expand Down Expand Up @@ -163,6 +164,14 @@ export function LiveSearchSettings() {
const memberProvider = liveSearchMemberAccountProvider(type)
const mcpProvider = liveSearchMcpConnector(type)
const group = accounts.data?.credentialGroup
const slackOption = group?.options.find((option) => option.provider === 'slack')
const needsSlackSetup =
type === 'slack' &&
accounts.data &&
(group?.status !== 'active' ||
slackOption?.status !== 'active' ||
slackOption.configurationStatus !== 'ready' ||
!hasSlackSearchUserScopes(slackOption.requiredScopes))
const needsMemberSetup =
integration.available !== false &&
accounts.data &&
Expand Down Expand Up @@ -193,7 +202,13 @@ export function LiveSearchSettings() {
iconVariant='custom'
icon={<IntegrationTile blockType={type} icon={meta.icon} />}
title={meta.name}
description={integration.available === false ? 'Currently unavailable' : scope}
description={
integration.available === false
? 'Currently unavailable'
: needsSlackSetup
? 'Member accounts · Verify Search permissions'
: scope
}
trailing={
<div className='flex gap-2'>
{serviceAccount && (
Expand All @@ -206,7 +221,9 @@ export function LiveSearchSettings() {
</ChipLink>
)}
{type === 'slack' && (
<Chip onClick={() => void setConnectedAccounts('slack')}>Slack app</Chip>
<Chip onClick={() => void setConnectedAccounts('slack')}>
{needsSlackSetup ? 'Verify permissions' : 'Slack app'}
</Chip>
)}
{needsMemberSetup && (
<Chip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { WorkspaceCredential } from '@/lib/api/contracts'
import type { OrganizationCredential } from '@/lib/api/contracts/organization-credentials'
import { resourceScopeFields, resourceScopeFromOwner } from '@/lib/core/resource-scope'
import {
hasSlackSearchUserScopes,
resolveSlackManagedUserScopes,
SLACK_MANAGED_USER_SCOPES,
SLACK_SEARCH_USER_SCOPES,
Expand All @@ -32,6 +33,7 @@ import {
organizationAccountsKeys,
useOrganizationAccounts,
} from '@/hooks/queries/organization-accounts'
import { useSearchIntegrations } from '@/hooks/queries/search-integrations'
import { useSlackSearchInstallations } from '@/hooks/queries/slack-search'
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'

Expand Down Expand Up @@ -107,11 +109,15 @@ export function SlackManagedUsersModal({
availableApps.find((app) => app.appId === appId) ??
(availableApps.length === 1 && !appId ? availableApps[0] : undefined)
const sharedAppInstalled = organizationSetup && selectedApp?.appKind === 'shared'
const searchPolicies = useSearchIntegrations(organizationId ?? '', {
enabled: open && sharedAppInstalled,
})
const searchApproved = searchPolicies.data?.some(
(policy) => policy.connectorType === 'slack' && policy.approved
)
const accounts = useOrganizationAccounts(open && sharedAppInstalled ? organizationId : undefined)
const memberGroup = accounts.data?.credentialGroup
const memberOption = memberGroup?.options.find(
(option) => option.provider === 'slack' && option.status === 'active'
)
const memberOption = memberGroup?.options.find((option) => option.provider === 'slack')
const sharedAppCanAuthorize = Boolean(
sharedAppInstalled &&
apps.isSuccess &&
Expand All @@ -123,11 +129,22 @@ export function SlackManagedUsersModal({
accounts.isSuccess &&
!accounts.isFetching &&
!accounts.error &&
memberGroup?.id === credentialGroupId
searchPolicies.isSuccess &&
!searchPolicies.isFetching &&
!searchPolicies.error &&
memberGroup?.id === credentialGroupId &&
memberOption?.status === 'active'
)
const sharedAppReady = sharedAppCanAuthorize && memberOption?.configurationStatus === 'ready'
const searchPermissionsMissing =
searchApproved && !hasSlackSearchUserScopes(memberOption?.requiredScopes)
const sharedAppReady =
sharedAppCanAuthorize &&
memberOption?.configurationStatus === 'ready' &&
!searchPermissionsMissing
const sharedAppNeedsUpdate =
sharedAppCanAuthorize && memberOption?.configurationStatus === 'needs_update'
sharedAppCanAuthorize &&
(memberOption?.configurationStatus === 'needs_update' ||
(memberOption?.configurationStatus === 'ready' && searchPermissionsMissing))
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [pending, setPending] = useState(false)
Expand Down Expand Up @@ -389,8 +406,19 @@ export function SlackManagedUsersModal({
const needsApp = organizationSetup && apps.isSuccess && availableApps.length === 0
const checkingSetup =
apps.isPending ||
(sharedAppInstalled && (apps.isFetching || accounts.isPending || accounts.isFetching))
const failedSetup = apps.error ? apps : sharedAppInstalled && accounts.error ? accounts : null
(sharedAppInstalled &&
(apps.isFetching ||
accounts.isPending ||
accounts.isFetching ||
searchPolicies.isPending ||
searchPolicies.isFetching))
const failedSetup = apps.error
? apps
: sharedAppInstalled && searchPolicies.error
? searchPolicies
: sharedAppInstalled && accounts.error
? accounts
: null
const title = organizationSetup ? 'Set up Slack app' : 'Set up Slack'
const primaryLabel = isLoading
? 'Loading...'
Expand All @@ -405,7 +433,7 @@ export function SlackManagedUsersModal({
(!organizationSetup && !selectedBot) ||
pending ||
(organizationSetup
? apps.isPending || Boolean(apps.error) || !selectedApp || !requiredScopes.length
? checkingSetup || Boolean(failedSetup) || !selectedApp || !requiredScopes.length
: !clientId.trim() || !clientSecret.trim())

return (
Expand Down Expand Up @@ -472,9 +500,9 @@ export function SlackManagedUsersModal({
<p className='text-[var(--text-secondary)] text-sm'>
{sharedAppInstalled
? sharedAppNeedsUpdate
? 'Member access is outdated. Update it so members can reconnect their Slack accounts.'
? 'Verify member permissions. Members will need to reconnect if their app or permissions change.'
: 'The Sim Search installation needs attention. Manage the app to finish setup.'
: 'Verify member authorization for the installed app. Members can then search the Slack conversations they can access.'}
: 'Verify member permissions. Members will need to reconnect if their app or permissions change.'}
</p>
{selectedApp && (
<Chip onClick={() => setAppSetupOpen(true)} disabled={pending}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ export const completeSlackCredentialGroupConfiguration: OperationUseCase<
attempt.expectedAppId !== pending.expectedAppId ||
attempt.expectedTeamId !== pending.expectedTeamId ||
attempt.appRevision !== pending.appRevision ||
attempt.searchApproval?.approved !== pending.searchApproval?.approved ||
attempt.searchApproval?.updatedAt !== pending.searchApproval?.updatedAt ||
attempt.clientId !== pending.clientId ||
attempt.redirectUri !== pending.redirectUri ||
credentialGroupScopePolicyVersion(attempt.requiredScopes) !==
Expand Down
27 changes: 1 addition & 26 deletions apps/sim/lib/credential-groups/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ export async function ensureWorkspaceAccountsGroup(
}
}

/** Adds a provider or extends its required consent during an explicit administrator action. */
/** Adds a provider without changing the verified consent policy of existing connections. */
export async function addOrganizationAccountProvider(
organizationId: string,
userId: string,
Expand Down Expand Up @@ -430,31 +430,6 @@ export async function addOrganizationAccountProvider(
'validation',
`Enable ${option.label} in Connected accounts first`
)
if (option.requiredScopes) {
const previousScopes =
current.provider === 'slack'
? resolveSlackManagedUserScopes(current.requiredScopes)
: current.requiredScopes
const requiredScopes = [...new Set([...previousScopes, ...option.requiredScopes])]
const scopeVersion = credentialGroupScopePolicyVersion(requiredScopes)
if (!scopesEqual(requiredScopes, previousScopes) || scopeVersion !== current.scopeVersion) {
const [updated] = await executor
.update(credentialGroup)
.set({
options: existing.options.map((entry) =>
entry.id === current.id ? { ...entry, requiredScopes, scopeVersion } : entry
),
updatedAt: new Date(),
})
.where(
and(eq(credentialGroup.id, group.id), resourceScopeCondition(credentialGroup, scope))
)
.returning({ id: credentialGroup.id })
if (!updated) throw new Error('Connected accounts policy update returned no row')
await invalidateOptionGrants(executor, group.id, [current.id])
return { groupId: group.id, changed: true }
}
}
return { groupId: group.id, changed: group.created }
}
if (
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/credential-groups/slack-managed-user-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export const SLACK_SEARCH_USER_SCOPES = [
...SLACK_RTS_USER_SCOPES,
] as const

/** Search readiness is separate from a connection's existing workflow permissions. */
export function hasSlackSearchUserScopes(scopes: readonly string[] | undefined): boolean {
return SLACK_SEARCH_USER_SCOPES.every((scope) => scopes?.includes(scope))
}

/** Existing workflow options retain their scope policy; every user grant must attest identity. */
export function resolveSlackManagedUserScopes(requiredScopes?: readonly string[]): string[] {
return [
Expand Down
Loading
Loading