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 @@ -47,7 +47,7 @@ describe('credential groups collection route', () => {
user: { id: 'user-1' },
session: { id: 'session-1' },
})
mocks.list.mockResolvedValue({ credentialGroups: [] })
mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] })
})

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

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ credentialGroups: [] })
expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] })
expect(mocks.list).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
input: { workspaceId: WORKSPACE_ID },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ describe('credential-groups prefetch', () => {
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
mockExecute.mockResolvedValue({ credentialGroups: [{ ...credentialGroup, internal: true }] })
mockExecute.mockResolvedValue({
credentialGroups: [{ ...credentialGroup, internal: true }],
availableProviders: ['gmail'],
})
const queryClient = new QueryClient()

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

it('leaves the cache empty when the use case denies the viewer', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ async function prefetchCredentialGroups(
principal,
input: { workspaceId },
})
return listCredentialGroupsContract.response.schema.parse(result).credentialGroups
/**
* Hydrates the whole response envelope, matching what `fetchCredentialGroupSettings` caches
* under this key. Narrowing to the groups array here would seed the shared entry with a
* shape its consumers do not read, so every one of them would see an empty list until the
* first refetch replaced it.
*/
return listCredentialGroupsContract.response.schema.parse(result)
},
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,22 @@ export const credentialGroupTabUrlKeys = {
clearOnDefault: true,
} as const

/**
* Filters the account types offered inside a credential group's detail view. Separate from the
* settings-wide search so filtering the picker does not follow the user back out to the list of
* groups, where the same term would usually match nothing.
*/
export const credentialGroupProviderSearchParam = {
key: 'credential-group-provider',
parser: parseAsString.withDefault(''),
} as const

/** A transient picker filter: no back-stack entry, and absent from the URL when empty. */
export const credentialGroupProviderSearchUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const

/**
* `group-tab` is the active tab inside the deep-linked permission-group detail
* view, so a shared `group-id` link can land on the same tab (mirrors
Expand Down
7 changes: 4 additions & 3 deletions apps/sim/blocks/blocks/credential-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { BlockConfig } from '@/blocks/types'
import {
CREDENTIAL_GROUP_LIST_STALE_TIME,
credentialGroupKeys,
fetchCredentialGroupList,
fetchCredentialGroupSettings,
} from '@/hooks/queries/utils/credential-group-queries'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
Expand All @@ -30,11 +30,12 @@ async function fetchCachedCredentialGroups() {
const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId
if (!workspaceId) return []

return getQueryClient().fetchQuery({
const settings = await getQueryClient().fetchQuery({
queryKey: credentialGroupKeys.list(workspaceId),
queryFn: ({ signal }) => fetchCredentialGroupList(workspaceId, signal),
queryFn: ({ signal }) => fetchCredentialGroupSettings(workspaceId, signal),
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
})
return settings.credentialGroups
}

function resolveCredentialGroupIdForBlock(blockId: string): string | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { getCredentialGroupProviderService } from '@/lib/credential-groups/provi
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
credentialGroupProviderSearchParam,
credentialGroupProviderSearchUrlKeys,
credentialGroupTabParam,
credentialGroupTabUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
Expand Down Expand Up @@ -42,6 +44,7 @@ import {
useUpdateCredentialGroup,
} from '@/hooks/queries/credential-groups'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'

interface CredentialGroupDetailProps {
workspaceId: string
Expand Down Expand Up @@ -111,6 +114,11 @@ export function CredentialGroupDetail({
groupId,
enabled: activeTab === 'access',
})
const [providerSearch, setProviderSearchParam] = useQueryState(
Comment thread
TheodoreSpeaks marked this conversation as resolved.
credentialGroupProviderSearchParam.key,
{ ...credentialGroupProviderSearchParam.parser, ...credentialGroupProviderSearchUrlKeys }
)
const setProviderSearch = useDebouncedSearchSetter(setProviderSearchParam)
const [showInvite, setShowInvite] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [deletingEnrollmentId, setDeletingEnrollmentId] = useState<string | null>(null)
Expand Down Expand Up @@ -262,6 +270,16 @@ export function CredentialGroupDetail({
title={credentialGroup?.name ?? 'Credential group'}
description={credentialGroup?.description ?? undefined}
actions={actions}
search={
activeTab === 'details'
? {
value: providerSearch,
onChange: setProviderSearch,
placeholder: 'Search account types...',
disabled: detail.isPending,
}
: undefined
}
>
{detail.error ? (
<SettingsEmptyState tone='error'>
Expand All @@ -280,6 +298,7 @@ export function CredentialGroupDetail({
<CredentialGroupDetails
workspaceId={workspaceId}
credentialGroup={credentialGroup}
providerSearch={providerSearch}
name={name}
onNameChange={setDraftName}
description={description}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ import {
} from '@/lib/credential-groups/providers'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import {
RESOURCE_LIST_STACK,
SettingsResourceRow,
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { SettingRow } from '@/ee/components/setting-row'
import { SlackManagedUsersModal } from '@/ee/credential-groups/components/slack-managed-users-modal'
import { useUpdateCredentialGroup } from '@/hooks/queries/credential-groups'
import { useCredentialGroups, useUpdateCredentialGroup } from '@/hooks/queries/credential-groups'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'

/** Stable identity so a pending/errored credentials query cannot churn the modal's `bots` prop. */
Expand All @@ -35,6 +36,8 @@ const EMPTY_SLACK_BOTS: WorkspaceCredential[] = []
interface CredentialGroupDetailsProps {
credentialGroup: CredentialGroup
workspaceId: string
/** Filters the account types offered below; owned by the panel header's search field. */
providerSearch: string
/** Edited name; committed by the panel header's Save action, which owns the dirty state. */
name: string
onNameChange: (name: string) => void
Expand All @@ -61,12 +64,19 @@ function toOptionUpdateInput(
export function CredentialGroupDetails({
credentialGroup,
workspaceId,
providerSearch,
name,
onNameChange,
description,
onDescriptionChange,
}: CredentialGroupDetailsProps) {
const updateGroup = useUpdateCredentialGroup()
/**
* Reads the same cache entry the list view already populated, so the deployment's configured
* providers arrive without a second request.
*/
const credentialGroups = useCredentialGroups(workspaceId)
const availableProviders = credentialGroups.data?.availableProviders
const slackBots = useWorkspaceCredentials({
workspaceId,
type: 'service_account',
Expand Down Expand Up @@ -132,6 +142,26 @@ export function CredentialGroupDetails({
if (await updateOptions(options, `${service.name} removed`)) setRemovingProvider(null)
}

/**
* A provider whose OAuth client this deployment has not configured can never finish an
* enrollment, so it is not offered — but one already on the group stays listed regardless, or
* the row that removes it would disappear along with it.
*/
const configuredProviders = new Set(credentialGroup.options.map((option) => option.provider))
const offerableProviders = availableProviders ? new Set(availableProviders) : null
const providerQuery = providerSearch.trim().toLowerCase()
const shownProviders = CREDENTIAL_GROUP_PROVIDER_IDS.filter((provider) => {
if (
!configuredProviders.has(provider) &&
offerableProviders &&
!offerableProviders.has(provider)
) {
return false
}
if (!providerQuery) return true
return getCredentialGroupProviderService(provider).name.toLowerCase().includes(providerQuery)
})

return (
<>
<SettingsSection label='Group details'>
Expand Down Expand Up @@ -161,8 +191,15 @@ export function CredentialGroupDetails({
</SettingsSection>

<SettingsSection label='Accounts people can connect'>
{shownProviders.length === 0 ? (
<SettingsEmptyState variant='inline'>
{providerSearch.trim()
? `No account types found matching "${providerSearch}"`
: 'No account types are available. Configure an OAuth client to offer one.'}
</SettingsEmptyState>
) : null}
<div className={RESOURCE_LIST_STACK}>
{CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => {
{shownProviders.map((provider) => {
const service = getCredentialGroupProviderService(provider)
const support = getCredentialGroupProviderSupport(provider)
const option = credentialGroup.options.find(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { useQueryState } from 'nuqs'
import {
credentialGroupIdParam,
credentialGroupIdUrlKeys,
credentialGroupProviderSearchParam,
credentialGroupProviderSearchUrlKeys,
credentialGroupTabParam,
credentialGroupTabUrlKeys,
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
Expand All @@ -28,7 +30,9 @@ interface CredentialGroupsSettingsProps {
}

export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettingsProps) {
const { data: groups = [], isPending, error } = useCredentialGroups(workspaceId)
const { data, isPending, error } = useCredentialGroups(workspaceId)
const groups = data?.credentialGroups ?? []
const availableProviders = data?.availableProviders ?? []
const [search, setSearch] = useSettingsSearch()
const [showCreate, setShowCreate] = useState(false)
const [selectedGroupId, setSelectedGroupId] = useQueryState(credentialGroupIdParam.key, {
Expand All @@ -45,13 +49,20 @@ export function CredentialGroupsSettings({ workspaceId }: CredentialGroupsSettin
...credentialGroupTabParam.parser,
...credentialGroupTabUrlKeys,
})
/** Scoped to one group's account types for the same reason, and reset on the same transitions. */
const [, setProviderSearch] = useQueryState(credentialGroupProviderSearchParam.key, {
...credentialGroupProviderSearchParam.parser,
...credentialGroupProviderSearchUrlKeys,
})
const openGroup = (groupId: string) => {
void setSelectedGroupId(groupId)
void setSelectedTab(null)
void setProviderSearch(null)
}
const closeGroup = () => {
void setSelectedGroupId(null, { history: 'replace' })
void setSelectedTab(null)
void setProviderSearch(null)
}
const selectedGroup = selectedGroupId
? groups.find((group) => group.id === selectedGroupId)
Expand Down
6 changes: 3 additions & 3 deletions apps/sim/hooks/queries/credential-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ import {
CREDENTIAL_GROUP_DETAIL_STALE_TIME,
CREDENTIAL_GROUP_LIST_STALE_TIME,
credentialGroupKeys,
fetchCredentialGroupList,
fetchCredentialGroupSettings,
} from '@/hooks/queries/utils/credential-group-queries'

export function useCredentialGroups(workspaceId?: string) {
return useQuery({
queryKey: credentialGroupKeys.list(workspaceId),
queryFn: async ({ signal }) => {
if (!workspaceId) return []
return fetchCredentialGroupList(workspaceId, signal)
if (!workspaceId) return { credentialGroups: [], availableProviders: [] }
return fetchCredentialGroupSettings(workspaceId, signal)
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
TheodoreSpeaks marked this conversation as resolved.
},
enabled: Boolean(workspaceId),
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
Expand Down
17 changes: 9 additions & 8 deletions apps/sim/hooks/queries/utils/credential-group-queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { requestJson } from '@/lib/api/client/request'
import type { CredentialGroup } from '@/lib/api/contracts/credential-groups'
import type { CredentialGroupSettingsList } from '@/lib/api/contracts/credential-groups'
import { listCredentialGroupsContract } from '@/lib/api/contracts/credential-groups'

export const CREDENTIAL_GROUP_DETAIL_STALE_TIME = Number.POSITIVE_INFINITY
Expand All @@ -22,13 +22,14 @@ export const credentialGroupKeys = {
] as const,
}

export async function fetchCredentialGroupList(
/**
* The workspace's credential groups together with the providers this deployment can enroll. One
* cache entry serves every consumer of the list, so the payload is cached whole and each caller
* reads the part it needs rather than caching two shapes under one key.
*/
export async function fetchCredentialGroupSettings(
workspaceId: string,
signal?: AbortSignal
): Promise<CredentialGroup[]> {
const data = await requestJson(listCredentialGroupsContract, {
params: { id: workspaceId },
signal,
})
return data.credentialGroups
): Promise<CredentialGroupSettingsList> {
return requestJson(listCredentialGroupsContract, { params: { id: workspaceId }, signal })
Comment thread
TheodoreSpeaks marked this conversation as resolved.
}
9 changes: 5 additions & 4 deletions apps/sim/hooks/selectors/providers/workspace/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getSandboxListQueryOptions } from '@/hooks/queries/sandboxes'
import {
CREDENTIAL_GROUP_LIST_STALE_TIME,
credentialGroupKeys,
fetchCredentialGroupList,
fetchCredentialGroupSettings,
} from '@/hooks/queries/utils/credential-group-queries'
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
import {
Expand Down Expand Up @@ -40,13 +40,14 @@ function workspaceCredentials(workspaceId: string) {
})
}

function credentialGroups(workspaceId: string) {
return getQueryClient().fetchQuery({
async function credentialGroups(workspaceId: string) {
const settings = await getQueryClient().fetchQuery({
Comment thread
TheodoreSpeaks marked this conversation as resolved.
queryKey: credentialGroupKeys.list(workspaceId),
queryFn: ({ signal }: { signal?: AbortSignal }) =>
fetchCredentialGroupList(workspaceId, signal),
fetchCredentialGroupSettings(workspaceId, signal),
staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME,
})
return settings.credentialGroups
}

function workspaceScoped(
Expand Down
16 changes: 12 additions & 4 deletions apps/sim/lib/api/contracts/credential-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,14 +357,22 @@ export const updateCredentialGroupBodySchema = z

export type UpdateCredentialGroupBody = z.input<typeof updateCredentialGroupBodySchema>

const listCredentialGroupsResponseSchema = z.object({
credentialGroups: z.array(credentialGroupSchema),
/**
* The providers this deployment has an OAuth client for. The settings picker offers only these,
* so an admin is never shown an account type nobody could finish connecting.
*/
availableProviders: z.array(credentialGroupProviderSchema),
Comment thread
TheodoreSpeaks marked this conversation as resolved.
})

export type CredentialGroupSettingsList = z.output<typeof listCredentialGroupsResponseSchema>

export const listCredentialGroupsContract = defineRouteContract({
method: 'GET',
path: '/api/workspaces/[id]/credential-groups',
params: credentialGroupWorkspaceParamsSchema,
response: {
mode: 'json',
schema: z.object({ credentialGroups: z.array(credentialGroupSchema) }),
},
response: { mode: 'json', schema: listCredentialGroupsResponseSchema },
})

export const createCredentialGroupContract = defineRouteContract({
Expand Down
Loading
Loading