Skip to content

Commit eaf7d38

Browse files
feat(credential-groups): add seventeen OAuth providers
Adds Airtable, Asana, Attio, Box, Cal.com, ClickUp, DocuSign, Dropbox, HubSpot, Linear, LinkedIn, monday.com, Notion, Pipedrive, Salesforce, WordPress.com and Zoom to Credential Groups.
1 parent 56abc6c commit eaf7d38

20 files changed

Lines changed: 1462 additions & 41 deletions

File tree

apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe('credential groups collection route', () => {
4747
user: { id: 'user-1' },
4848
session: { id: 'session-1' },
4949
})
50-
mocks.list.mockResolvedValue({ credentialGroups: [] })
50+
mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] })
5151
})
5252

5353
it('authenticates before parsing the request body', async () => {
@@ -64,7 +64,7 @@ describe('credential groups collection route', () => {
6464
const response = await GET(request, context)
6565

6666
expect(response.status).toBe(200)
67-
expect(await response.json()).toEqual({ credentialGroups: [] })
67+
expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] })
6868
expect(mocks.list).toHaveBeenCalledWith({
6969
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
7070
input: { workspaceId: WORKSPACE_ID },

apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,22 @@ export const credentialGroupTabUrlKeys = {
116116
clearOnDefault: true,
117117
} as const
118118

119+
/**
120+
* Filters the account types offered inside a credential group's detail view. Separate from the
121+
* settings-wide search so filtering the picker does not follow the user back out to the list of
122+
* groups, where the same term would usually match nothing.
123+
*/
124+
export const credentialGroupProviderSearchParam = {
125+
key: 'credential-group-provider',
126+
parser: parseAsString.withDefault(''),
127+
} as const
128+
129+
/** A transient picker filter: no back-stack entry, and absent from the URL when empty. */
130+
export const credentialGroupProviderSearchUrlKeys = {
131+
history: 'replace',
132+
clearOnDefault: true,
133+
} as const
134+
119135
/**
120136
* `group-tab` is the active tab inside the deep-linked permission-group detail
121137
* view, so a shared `group-id` link can land on the same tab (mirrors

apps/sim/blocks/blocks/credential-group.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { BlockConfig } from '@/blocks/types'
88
import {
99
CREDENTIAL_GROUP_LIST_STALE_TIME,
1010
credentialGroupKeys,
11-
fetchCredentialGroupList,
11+
fetchCredentialGroupSettings,
1212
} from '@/hooks/queries/utils/credential-group-queries'
1313
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1414
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
@@ -30,11 +30,12 @@ async function fetchCachedCredentialGroups() {
3030
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
3131
if (!workspaceId) return []
3232

33-
return getQueryClient().fetchQuery({
33+
const settings = await getQueryClient().fetchQuery({
3434
queryKey: credentialGroupKeys.list(workspaceId),
35-
queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal),
35+
queryFn: ({ signal }) => fetchCredentialGroupSettings(workspaceId, signal),
3636
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
3737
})
38+
return settings.credentialGroups
3839
}
3940

4041
function resolveCredentialGroupIdForBlock(blockId: string): string | null {

apps/sim/ee/credential-groups/components/credential-group-detail.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { getCredentialGroupProviderService } from '@/lib/credential-groups/provi
1515
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
1616
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
1717
import {
18+
credentialGroupProviderSearchParam,
19+
credentialGroupProviderSearchUrlKeys,
1820
credentialGroupTabParam,
1921
credentialGroupTabUrlKeys,
2022
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
@@ -42,6 +44,7 @@ import {
4244
useUpdateCredentialGroup,
4345
} from '@/hooks/queries/credential-groups'
4446
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
47+
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
4548

4649
interface CredentialGroupDetailProps {
4750
workspaceId: string
@@ -111,6 +114,11 @@ export function CredentialGroupDetail({
111114
groupId,
112115
enabled: activeTab === 'access',
113116
})
117+
const [providerSearch, setProviderSearchParam] = useQueryState(
118+
credentialGroupProviderSearchParam.key,
119+
{ ...credentialGroupProviderSearchParam.parser, ...credentialGroupProviderSearchUrlKeys }
120+
)
121+
const setProviderSearch = useDebouncedSearchSetter(setProviderSearchParam)
114122
const [showInvite, setShowInvite] = useState(false)
115123
const [showDelete, setShowDelete] = useState(false)
116124
const [deletingEnrollmentId, setDeletingEnrollmentId] = useState<string | null>(null)
@@ -262,6 +270,16 @@ export function CredentialGroupDetail({
262270
title={credentialGroup?.name ?? 'Credential group'}
263271
description={credentialGroup?.description ?? undefined}
264272
actions={actions}
273+
search={
274+
activeTab === 'details'
275+
? {
276+
value: providerSearch,
277+
onChange: setProviderSearch,
278+
placeholder: 'Search account types...',
279+
disabled: detail.isPending,
280+
}
281+
: undefined
282+
}
265283
>
266284
{detail.error ? (
267285
<SettingsEmptyState tone='error'>
@@ -280,6 +298,7 @@ export function CredentialGroupDetail({
280298
<CredentialGroupDetails
281299
workspaceId={workspaceId}
282300
credentialGroup={credentialGroup}
301+
providerSearch={providerSearch}
283302
name={name}
284303
onNameChange={setDraftName}
285304
description={description}

apps/sim/ee/credential-groups/components/credential-group-details.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,15 @@ import {
1919
} from '@/lib/credential-groups/providers'
2020
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
2121
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
22+
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
2223
import {
2324
RESOURCE_LIST_STACK,
2425
SettingsResourceRow,
2526
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
2627
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
2728
import { SettingRow } from '@/ee/components/setting-row'
2829
import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal'
29-
import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups'
30+
import { useCredentialGroups, useUpdateCredentialGroup } from '@/hooks/queries/credential-groups'
3031
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
3132

3233
/** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */
@@ -35,6 +36,8 @@ const EMPTY_SLACK_BOTS: WorkspaceCredential[] = []
3536
interface CredentialGroupDetailsProps {
3637
credentialGroup: CredentialGroup
3738
workspaceId: string
39+
/** Filters the account types offered below; owned by the panel header's search field. */
40+
providerSearch: string
3841
/** Edited name; committed by the panel header's Save action, which owns the dirty state. */
3942
name: string
4043
onNameChange: (name: string) => void
@@ -61,12 +64,19 @@ function toOptionUpdateInput(
6164
export function CredentialGroupDetails({
6265
credentialGroup,
6366
workspaceId,
67+
providerSearch,
6468
name,
6569
onNameChange,
6670
description,
6771
onDescriptionChange,
6872
}: CredentialGroupDetailsProps) {
6973
const updateGroup = useUpdateCredentialGroup()
74+
/**
75+
* Reads the same cache entry the list view already populated, so the deployment's configured
76+
* providers arrive without a second request.
77+
*/
78+
const credentialGroups = useCredentialGroups(workspaceId)
79+
const availableProviders = credentialGroups.data?.availableProviders
7080
const slackBots = useWorkspaceCredentials({
7181
workspaceId,
7282
type: 'service_account',
@@ -132,6 +142,26 @@ export function CredentialGroupDetails({
132142
if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null)
133143
}
134144

145+
/**
146+
* A provider whose OAuth client this deployment has not configured can never finish an
147+
* enrollment, so it is not offered — but one already on the group stays listed regardless, or
148+
* the row that removes it would disappear along with it.
149+
*/
150+
const configuredProviders = new Set(credentialGroup.options.map((option) => option.provider))
151+
const offerableProviders = availableProviders ? new Set(availableProviders) : null
152+
const providerQuery = providerSearch.trim().toLowerCase()
153+
const shownProviders = CREDENTIAL_GROUP_PROVIDER_IDS.filter((provider) => {
154+
if (
155+
!configuredProviders.has(provider) &&
156+
offerableProviders &&
157+
!offerableProviders.has(provider)
158+
) {
159+
return false
160+
}
161+
if (!providerQuery) return true
162+
return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery)
163+
})
164+
135165
return (
136166
<>
137167
<SettingsSection label='Group details'>
@@ -161,8 +191,15 @@ export function CredentialGroupDetails({
161191
</SettingsSection>
162192

163193
<SettingsSection label='Accounts people can connect'>
194+
{shownProviders.length === 0 ? (
195+
<SettingsEmptyState variant='inline'>
196+
{providerSearch.trim()
197+
? `No account types found matching "${providerSearch}"`
198+
: 'No account types are available. Configure an OAuth client to offer one.'}
199+
</SettingsEmptyState>
200+
) : null}
164201
<div className={RESOURCE_LIST_STACK}>
165-
{CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => {
202+
{shownProviders.map((provider) => {
166203
const service = getCredentialGroupProviderService(provider)
167204
const support = getCredentialGroupProviderSupport(provider)
168205
const option = credentialGroup.options.find(

apps/sim/ee/credential-groups/components/credential-groups-settings.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ interface CredentialGroupsSettingsProps {
2828
}
2929

3030
export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) {
31-
const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId)
31+
const { data, isPending, error } = useCredentialGroups(workspaceId)
32+
const groups = data?.credentialGroups ?? []
33+
const availableProviders = data?.availableProviders ?? []
3234
const [search, setSearch] = useSettingsSearch()
3335
const [showCreate, setShowCreate] = useState(false)
3436
const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, {

apps/sim/hooks/queries/credential-groups.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@ import {
2222
CREDENTIAL_GROUP_DETAIL_STALE_TIME,
2323
CREDENTIAL_GROUP_LIST_STALE_TIME,
2424
credentialGroupKeys,
25-
fetchCredentialGroupList,
25+
fetchCredentialGroupSettings,
2626
} from '@/hooks/queries/utils/credential-group-queries'
2727

2828
export function useCredentialGroups(workspaceId?: string) {
2929
return useQuery({
3030
queryKey: credentialGroupKeys.list(workspaceId),
3131
queryFn: async ({ signal }) => {
32-
if (!workspaceId) return []
33-
return fetchCredentialGroupList(workspaceId, signal)
32+
if (!workspaceId) return { credentialGroups: [], availableProviders: [] }
33+
return fetchCredentialGroupSettings(workspaceId, signal)
3434
},
3535
enabled: Boolean(workspaceId),
3636
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,

apps/sim/hooks/queries/utils/credential-group-queries.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { requestJson } from '@/lib/api/client/request'
2-
import type { CredentialGroup } from '@/lib/api/contracts/credential-groups'
2+
import type { CredentialGroupSettingsList } from '@/lib/api/contracts/credential-groups'
33
import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups'
44

55
export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY
@@ -22,13 +22,14 @@ export const credentialGroupKeys = {
2222
] as const,
2323
}
2424

25-
export async function fetchCredentialGroupList(
25+
/**
26+
* The workspace's credential groups together with the providers this deployment can enroll. One
27+
* cache entry serves every consumer of the list, so the payload is cached whole and each caller
28+
* reads the part it needs rather than caching two shapes under one key.
29+
*/
30+
export async function fetchCredentialGroupSettings(
2631
workspaceId: string,
2732
signal?: AbortSignal
28-
): Promise<CredentialGroup[]> {
29-
const data = await requestJson(listCredentialGroupsContract, {
30-
params: { id: workspaceId },
31-
signal,
32-
})
33-
return data.credentialGroups
33+
): Promise<CredentialGroupSettingsList> {
34+
return requestJson(listCredentialGroupsContract, { params: { id: workspaceId }, signal })
3435
}

apps/sim/hooks/selectors/providers/workspace/selectors.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getSandboxListQueryOptions } from '@/hooks/queries/sandboxes'
88
import {
99
CREDENTIAL_GROUP_LIST_STALE_TIME,
1010
credentialGroupKeys,
11-
fetchCredentialGroupList,
11+
fetchCredentialGroupSettings,
1212
} from '@/hooks/queries/utils/credential-group-queries'
1313
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
1414
import {
@@ -40,13 +40,14 @@ function workspaceCredentials(workspaceId: string) {
4040
})
4141
}
4242

43-
function credentialGroups(workspaceId: string) {
44-
return getQueryClient().fetchQuery({
43+
async function credentialGroups(workspaceId: string) {
44+
const settings = await getQueryClient().fetchQuery({
4545
queryKey: credentialGroupKeys.list(workspaceId),
4646
queryFn: ({ signal }: { signal?: AbortSignal }) =>
47-
fetchCredentialGroupList(workspaceId, signal),
47+
fetchCredentialGroupSettings(workspaceId, signal),
4848
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
4949
})
50+
return settings.credentialGroups
5051
}
5152

5253
function workspaceScoped(

apps/sim/lib/api/contracts/credential-groups.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,22 @@ export const updateCredentialGroupBodySchema = z
357357

358358
export type UpdateCredentialGroupBody = z.input<typeof updateCredentialGroupBodySchema>
359359

360+
const listCredentialGroupsResponseSchema = z.object({
361+
credentialGroups: z.array(credentialGroupSchema),
362+
/**
363+
* The providers this deployment has an OAuth client for. The settings picker offers only these,
364+
* so an admin is never shown an account type nobody could finish connecting.
365+
*/
366+
availableProviders: z.array(credentialGroupProviderSchema),
367+
})
368+
369+
export type CredentialGroupSettingsList = z.output<typeof listCredentialGroupsResponseSchema>
370+
360371
export const listCredentialGroupsContract = defineRouteContract({
361372
method: 'GET',
362373
path: '/api/workspaces/[id]/credential-groups',
363374
params: credentialGroupWorkspaceParamsSchema,
364-
response: {
365-
mode: 'json',
366-
schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }),
367-
},
375+
response: { mode: 'json', schema: listCredentialGroupsResponseSchema },
368376
})
369377

370378
export const createCredentialGroupContract = defineRouteContract({

0 commit comments

Comments
 (0)