Skip to content

Commit 8a68b31

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
Merge remote-tracking branch 'origin/staging' into feat/unified-server-selector-execution
# Conflicts: # apps/sim/hooks/selectors/providers/workspace/selectors.ts
2 parents 845acaa + 60776ba commit 8a68b31

21 files changed

Lines changed: 1486 additions & 40 deletions

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]/prefetch.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ describe('credential-groups prefetch', () => {
7474
createdAt: '2026-01-01T00:00:00.000Z',
7575
updatedAt: '2026-01-01T00:00:00.000Z',
7676
}
77-
mockExecute.mockResolvedValue({ credentialGroups: [{ ...credentialGroup, internal: true }] })
77+
mockExecute.mockResolvedValue({
78+
credentialGroups: [{ ...credentialGroup, internal: true }],
79+
availableProviders: ['gmail'],
80+
})
7881
const queryClient = new QueryClient()
7982

8083
await SECTION_PREFETCHERS['credential-groups']?.(queryClient, {
@@ -86,7 +89,15 @@ describe('credential-groups prefetch', () => {
8689
principal: { kind: 'session', userId: 'u1', sessionId: 's1' },
8790
input: { workspaceId: 'w1' },
8891
})
89-
expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual([credentialGroup])
92+
/**
93+
* The whole response envelope, not just the groups array: this key is shared with
94+
* `fetchCredentialGroupSettings`, and seeding it with a narrower shape would leave every
95+
* consumer reading an empty list for as long as the hydrated value stayed fresh.
96+
*/
97+
expect(queryClient.getQueryData(credentialGroupKeys.list('w1'))).toEqual({
98+
credentialGroups: [credentialGroup],
99+
availableProviders: ['gmail'],
100+
})
90101
})
91102

92103
it('leaves the cache empty when the use case denies the viewer', async () => {

apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ async function prefetchCredentialGroups(
3939
principal,
4040
input: { workspaceId },
4141
})
42-
return listCredentialGroupsContract.response.schema.parse(result).credentialGroups
42+
/**
43+
* Hydrates the whole response envelope, matching what `fetchCredentialGroupSettings` caches
44+
* under this key. Narrowing to the groups array here would seed the shared entry with a
45+
* shape its consumers do not read, so every one of them would see an empty list until the
46+
* first refetch replaced it.
47+
*/
48+
return listCredentialGroupsContract.response.schema.parse(result)
4349
},
4450
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
4551
})

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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { useQueryState } from 'nuqs'
88
import {
99
credentialGroupIdParam,
1010
credentialGroupIdUrlKeys,
11+
credentialGroupProviderSearchParam,
12+
credentialGroupProviderSearchUrlKeys,
1113
credentialGroupTabParam,
1214
credentialGroupTabUrlKeys,
1315
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
@@ -28,7 +30,9 @@ interface CredentialGroupsSettingsProps {
2830
}
2931

3032
export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) {
31-
const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId)
33+
const { data, isPending, error } = useCredentialGroups(workspaceId)
34+
const groups = data?.credentialGroups ?? []
35+
const availableProviders = data?.availableProviders ?? []
3236
const [search, setSearch] = useSettingsSearch()
3337
const [showCreate, setShowCreate] = useState(false)
3438
const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, {
@@ -45,13 +49,20 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin
4549
...credentialGroupTabParam.parser,
4650
...credentialGroupTabUrlKeys,
4751
})
52+
/** Scoped to one group's account types for the same reason, and reset on the same transitions. */
53+
const [, setProviderSearch] = useQueryState(credentialGroupProviderSearchParam.key, {
54+
...credentialGroupProviderSearchParam.parser,
55+
...credentialGroupProviderSearchUrlKeys,
56+
})
4857
const openGroup = (groupId: string) => {
4958
void setSelectedGroupId(groupId)
5059
void setSelectedTab(null)
60+
void setProviderSearch(null)
5161
}
5262
const closeGroup = () => {
5363
void setSelectedGroupId(null, { history: 'replace' })
5464
void setSelectedTab(null)
65+
void setProviderSearch(null)
5566
}
5667
const selectedGroup = selectedGroupId
5768
? groups.find((group) => group.id === selectedGroupId)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ 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
import { invalidateSelectorQueries } from '@/hooks/queries/utils/selector-keys'
2828

2929
export function useCredentialGroups(workspaceId?: string) {
3030
return useQuery({
3131
queryKey: credentialGroupKeys.list(workspaceId),
3232
queryFn: async ({ signal }) => {
33-
if (!workspaceId) return []
34-
return fetchCredentialGroupList(workspaceId, signal)
33+
if (!workspaceId) return { credentialGroups: [], availableProviders: [] }
34+
return fetchCredentialGroupSettings(workspaceId, signal)
3535
},
3636
enabled: Boolean(workspaceId),
3737
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
}

0 commit comments

Comments
 (0)