From eaf7d3856fe4bce49f52a0f5a71ed0a9efb96721 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 27 Aug 2026 23:01:01 -0700 Subject: [PATCH 1/2] 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. --- .../[id]/credential-groups/route.test.ts | 4 +- .../settings/[section]/search-params.ts | 16 + apps/sim/blocks/blocks/credential-group.ts | 7 +- .../components/credential-group-detail.tsx | 19 + .../components/credential-group-details.tsx | 41 +- .../components/credential-groups-settings.tsx | 4 +- apps/sim/hooks/queries/credential-groups.ts | 6 +- .../queries/utils/credential-group-queries.ts | 17 +- .../providers/workspace/selectors.ts | 9 +- .../lib/api/contracts/credential-groups.ts | 16 +- .../lib/auth/connectors/managed-oauth.test.ts | 277 +++++- apps/sim/lib/auth/connectors/managed-oauth.ts | 837 +++++++++++++++++- .../application/manage-groups.test.ts | 7 +- .../application/manage-groups.ts | 6 +- .../provider-availability.ts | 27 + .../provider-registry.test.ts | 31 + .../credential-groups/provider-registry.ts | 17 + apps/sim/lib/credential-groups/providers.ts | 102 +++ .../standard-oauth-provider.test.ts | 47 + .../standard-oauth-provider.ts | 13 +- 20 files changed, 1462 insertions(+), 41 deletions(-) create mode 100644 apps/sim/lib/credential-groups/provider-availability.ts diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts index 2bbb4e1b378..6259951e256 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -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 () => { @@ -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 }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts index c95046d925f..7695deefb6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -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 diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 496ff8a6de0..ec84791f943 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -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' @@ -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 { diff --git a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx index 08947b0812b..a6a76bf9ab4 100644 --- a/apps/sim/ee/credential-groups/components/credential-group-detail.tsx +++ b/apps/sim/ee/credential-groups/components/credential-group-detail.tsx @@ -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' @@ -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 @@ -111,6 +114,11 @@ export function CredentialGroupDetail({ groupId, enabled: activeTab === 'access', }) + const [providerSearch, setProviderSearchParam] = useQueryState( + credentialGroupProviderSearchParam.key, + { ...credentialGroupProviderSearchParam.parser, ...credentialGroupProviderSearchUrlKeys } + ) + const setProviderSearch = useDebouncedSearchSetter(setProviderSearchParam) const [showInvite, setShowInvite] = useState(false) const [showDelete, setShowDelete] = useState(false) const [deletingEnrollmentId, setDeletingEnrollmentId] = useState(null) @@ -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 ? ( @@ -280,6 +298,7 @@ export function CredentialGroupDetail({ void @@ -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', @@ -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 ( <> @@ -161,8 +191,15 @@ export function CredentialGroupDetails({ + {shownProviders.length === 0 ? ( + + {providerSearch.trim() + ? `No account types found matching "${providerSearch}"` + : 'No account types are available. Configure an OAuth client to offer one.'} + + ) : null}
- {CREDENTIAL_GROUP_PROVIDER_IDS.map((provider) => { + {shownProviders.map((provider) => { const service = getCredentialGroupProviderService(provider) const support = getCredentialGroupProviderSupport(provider) const option = credentialGroup.options.find( diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx index f6d81bf954f..2e92dbc08e1 100644 --- a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx +++ b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx @@ -28,7 +28,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, { diff --git a/apps/sim/hooks/queries/credential-groups.ts b/apps/sim/hooks/queries/credential-groups.ts index f8486694e96..cd18c3d79cf 100644 --- a/apps/sim/hooks/queries/credential-groups.ts +++ b/apps/sim/hooks/queries/credential-groups.ts @@ -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) }, enabled: Boolean(workspaceId), staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, diff --git a/apps/sim/hooks/queries/utils/credential-group-queries.ts b/apps/sim/hooks/queries/utils/credential-group-queries.ts index e7964c04ad2..b1c645555aa 100644 --- a/apps/sim/hooks/queries/utils/credential-group-queries.ts +++ b/apps/sim/hooks/queries/utils/credential-group-queries.ts @@ -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 @@ -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 { - const data = await requestJson(listCredentialGroupsContract, { - params: { id: workspaceId }, - signal, - }) - return data.credentialGroups +): Promise { + return requestJson(listCredentialGroupsContract, { params: { id: workspaceId }, signal }) } diff --git a/apps/sim/hooks/selectors/providers/workspace/selectors.ts b/apps/sim/hooks/selectors/providers/workspace/selectors.ts index 62f7314cc4a..55bacdf8a0f 100644 --- a/apps/sim/hooks/selectors/providers/workspace/selectors.ts +++ b/apps/sim/hooks/selectors/providers/workspace/selectors.ts @@ -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 { @@ -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({ 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( diff --git a/apps/sim/lib/api/contracts/credential-groups.ts b/apps/sim/lib/api/contracts/credential-groups.ts index 770f6f2b65a..94fba1f1e92 100644 --- a/apps/sim/lib/api/contracts/credential-groups.ts +++ b/apps/sim/lib/api/contracts/credential-groups.ts @@ -357,14 +357,22 @@ export const updateCredentialGroupBodySchema = z export type UpdateCredentialGroupBody = z.input +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), +}) + +export type CredentialGroupSettingsList = z.output + 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({ diff --git a/apps/sim/lib/auth/connectors/managed-oauth.test.ts b/apps/sim/lib/auth/connectors/managed-oauth.test.ts index 6f37cbdd325..329278549c5 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.test.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { createAtlassianManagedOAuthConnector } from '@/lib/auth/connectors/managed-oauth' +import { + createAtlassianManagedOAuthConnector, + getManagedOAuthConnectorPolicy, +} from '@/lib/auth/connectors/managed-oauth' const ATLASSIAN_SCOPES = ['read:me', 'read:jira-work', 'offline_access'] @@ -112,3 +115,275 @@ describe('Atlassian managed OAuth connector', () => { expect(jira.isTerminalRefreshError('temporarily_unavailable')).toBe(false) }) }) + +describe('userinfo-backed managed OAuth connectors', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + function stubProfile(profile: unknown): ReturnType { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify(profile), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + return fetchMock + } + + function policyFor(providerId: string) { + const policy = getManagedOAuthConnectorPolicy(providerId) + if (!policy) throw new Error(`No managed OAuth policy registered for ${providerId}`) + return policy + } + + it.each([ + ['linkedin', { sub: 'sub-1', email: 'person@example.com', email_verified: false }], + ['zoom', { id: 'zoom-1', email: 'person@example.com', verified: 0, account_id: 'account-1' }], + ['dropbox', { account_id: 'dbid:1', email: 'person@example.com', email_verified: false }], + ['pipedrive', { data: { id: 7, email: 'person@example.com', activated: false } }], + ['wordpress', { ID: 12, email: 'person@example.com', email_verified: false }], + ['salesforce', { user_id: 'sf-1', email: 'person@example.com', email_verified: false }], + ])( + 'reports %s email verification from the provider rather than assuming it', + async (providerId, profile) => { + stubProfile(profile) + + const identity = await policyFor(providerId).verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.email).toBe('person@example.com') + expect(identity.emailVerified).toBe(false) + } + ) + + it.each([ + ['linkedin', { sub: 'sub-1', email: 'person@example.com', email_verified: true }], + ['zoom', { id: 'zoom-1', email: 'person@example.com', verified: 1 }], + ['dropbox', { account_id: 'dbid:1', email: 'person@example.com', email_verified: true }], + ['pipedrive', { data: { id: 7, email: 'person@example.com', activated: true } }], + ])('accepts a verified %s identity', async (providerId, profile) => { + stubProfile(profile) + + const identity = await policyFor(providerId).verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.emailVerified).toBe(true) + }) + + it('fails closed when the provider returns no email to bind the invitation to', async () => { + stubProfile({ sub: 'sub-1' }) + + await expect( + policyFor('linkedin').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('LinkedIn email') + }) + + it('fails closed when the identity request itself fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 401 }))) + + await expect( + policyFor('zoom').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('HTTP 401') + }) + + it('reads DocuSign granted scopes from userinfo and falls back to the token response', async () => { + stubProfile({ + sub: 'ds-1', + email: 'person@example.com', + scope: 'signature extended', + accounts: [{ account_id: 'acct-2', is_default: true }], + }) + const fromProfile = await policyFor('docusign').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['signature'] }, + clientId: 'client-1', + }) + expect(fromProfile.grantedScopes).toEqual(['signature', 'extended']) + expect(fromProfile.providerTenantId).toBe('acct-2') + + stubProfile({ sub: 'ds-1', email: 'person@example.com', accounts: [] }) + const fromTokens = await policyFor('docusign').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['signature', 'extended'] }, + clientId: 'client-1', + }) + expect(fromTokens.grantedScopes).toEqual(['signature', 'extended']) + }) + + it('falls back to the requested scope set for a provider that reports none', async () => { + stubProfile({ ID: 12, email: 'person@example.com', email_verified: true }) + + const identity = await policyFor('wordpress').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.grantedScopes).toEqual(['global']) + expect(policyFor('wordpress').hasRequiredScopes(identity.grantedScopes, ['global'])).toBe(true) + }) + + it('sends Salesforce identity lookups to the authorization server the provider id names', async () => { + const fetchMock = stubProfile({ + user_id: 'sf-1', + email: 'person@example.com', + email_verified: true, + organization_id: 'org-1', + }) + + const identity = await policyFor('salesforce').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['api'] }, + clientId: 'client-1', + }) + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://login.salesforce.com/services/oauth2/userinfo' + ) + expect(identity.providerTenantId).toBe('org-1') + }) + + it('keeps a provider id that collides with an Object prototype member unresolved', () => { + expect(getManagedOAuthConnectorPolicy('toString')).toBeUndefined() + expect(getManagedOAuthConnectorPolicy('constructor')).toBeUndefined() + }) + it('refuses a Notion integration that identifies a workspace rather than a person', async () => { + stubProfile({ id: 'bot-1', bot: { owner: { type: 'workspace', workspace: true } } }) + + await expect( + policyFor('notion').verifyIdentity({ + tokens: { tokenType: 'bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('identifies no person') + }) + + it('reads the authorizing human behind a Notion integration token', async () => { + stubProfile({ + id: 'bot-1', + bot: { + owner: { + type: 'user', + user: { id: 'user-1', name: 'Person', person: { email: 'person@example.com' } }, + }, + }, + }) + + const identity = await policyFor('notion').verifyIdentity({ + tokens: { tokenType: 'bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity).toMatchObject({ + providerSubjectId: 'user-1', + email: 'person@example.com', + emailVerified: true, + }) + }) + + it.each(['notion', 'clickup', 'calcom'])( + 'declares %s scopeless so an empty scope policy is not read as a misconfiguration', + (providerId) => { + expect(policyFor(providerId).scopeless).toBe(true) + } + ) + + it.each(['linear', 'monday'])( + 'treats a partial %s GraphQL response as no identity at all', + async (providerId) => { + stubProfile({ data: { viewer: null, me: null }, errors: [{ message: 'denied' }] }) + + await expect( + policyFor(providerId).verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + ).rejects.toThrow('invalid user identity') + } + ) + + it('resolves the Attio member who authorized the token, not an arbitrary one', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + active: true, + workspace_id: 'workspace-1', + authorized_by_workspace_member_id: 'member-2', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + id: { workspace_id: 'workspace-1', workspace_member_id: 'member-2' }, + first_name: 'Person', + last_name: 'Example', + email_address: 'person@example.com', + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + const identity = await policyFor('attio').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ['user_management:read'] }, + clientId: 'client-1', + }) + + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://api.attio.com/v2/workspace_members/member-2') + expect(identity).toMatchObject({ + providerSubjectId: 'member-2', + email: 'person@example.com', + providerTenantId: 'workspace-1', + displayName: 'Person Example', + }) + }) + + it('identifies a HubSpot seat through the token-metadata endpoint', async () => { + const fetchMock = stubProfile({ + user_id: 42, + user: 'person@example.com', + hub_id: 7, + scopes: ['crm.objects.contacts.read'], + }) + + const identity = await policyFor('hubspot').verifyIdentity({ + tokens: { tokenType: 'bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://api.hubapi.com/oauth/v1/access-tokens/access-1' + ) + expect(identity).toMatchObject({ + providerSubjectId: '42', + email: 'person@example.com', + providerTenantId: '7', + grantedScopes: ['crm.objects.contacts.read'], + }) + }) + + it('takes Airtable granted scopes from whoami when the token response reports none', async () => { + stubProfile({ id: 'usr1', email: 'person@example.com', scopes: ['data.records:read'] }) + + const identity = await policyFor('airtable').verifyIdentity({ + tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: [] }, + clientId: 'client-1', + }) + + expect(identity.grantedScopes).toEqual(['data.records:read']) + }) +}) diff --git a/apps/sim/lib/auth/connectors/managed-oauth.ts b/apps/sim/lib/auth/connectors/managed-oauth.ts index 5bf528368a9..7c88e5da422 100644 --- a/apps/sim/lib/auth/connectors/managed-oauth.ts +++ b/apps/sim/lib/auth/connectors/managed-oauth.ts @@ -1,9 +1,15 @@ import { createHash } from 'node:crypto' import type { OAuth2Tokens } from '@better-auth/core/oauth2' +import { isRecordLike } from '@sim/utils/object' import type { GenericOAuthConfig } from 'better-auth/plugins' import { OAuth2Client, type TokenPayload } from 'google-auth-library' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { getDocusignOAuthUrl } from '@/lib/oauth/docusign' +import { SALESFORCE_LOGIN_HOSTS } from '@/lib/oauth/salesforce' +import { isTerminalRefreshError } from '@/lib/oauth/terminal-errors' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { MONDAY_API_URL, MONDAY_API_VERSION } from '@/tools/monday/utils' const GOOGLE_OPENID_SCOPE = 'openid' const GOOGLE_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email' @@ -33,6 +39,12 @@ export interface ManagedOAuthConnectorConfig { additionalScopes: string[] requiresRefreshToken: boolean pkce: boolean + /** + * Set when the provider takes no scopes at all (Notion, ClickUp, Cal.com all authorize with an + * empty scope list). Without it the empty scope policy is indistinguishable from a + * misconfigured connector, which is what the policy guard exists to catch. + */ + scopeless?: boolean nonceVerification: 'id_token' | 'state_only' includeLoginHint: boolean prompt?: string @@ -215,20 +227,827 @@ export function createAtlassianManagedOAuthConnector( } } +const USER_INFO_TIMEOUT_MS = 10_000 +const USER_INFO_MAX_BYTES = 256 * 1024 + +/** + * Identity a provider's own profile endpoint can establish. Deliberately narrower than + * {@link ManagedOAuthConnectorIdentity}: `nonce` is meaningless outside an OIDC id_token, and + * `grantedScopes` is recovered separately because most providers do not report it here. + */ +export interface ManagedOAuthProfileIdentity { + providerSubjectId: string + email: string + emailVerified: boolean + providerTenantId?: string | null + displayName?: string + avatarUrl?: string +} + +/** + * How the granted scope list is recovered. + * + * Better Auth derives `tokens.scopes` from the token response's `scope` field and splits it on + * spaces, so a provider that omits `scope` yields an empty list — and an empty granted list fails + * the scope check on every provider that requires any scope, rejecting a grant the user actually + * approved. + * + * - `token_response` — the provider reports `scope` on the token response. The honest default. + * - `profile` — the scope list comes back on the identity response instead (DocuSign's + * `/oauth/userinfo`, HubSpot's token-introspection endpoint). + * - `requested` — the provider reports scopes nowhere. Falls back to the scope set Sim asked for, + * which is sound only when the provider grants all-or-nothing and offers the user no way to + * deselect individual scopes at the consent screen. Verify that per provider before choosing it. + */ +export type ManagedOAuthScopeResolution = + | { from: 'token_response' } + | { from: 'profile'; read(profile: unknown, tokens: OAuth2Tokens): string[] } + | { from: 'requested' } + +export interface UserInfoManagedOAuthConnectorOptions { + providerId: string + userInfo: { + /** A function when the access token belongs in the path rather than the header. */ + url: string | ((tokens: OAuth2Tokens) => string) + method?: 'GET' | 'POST' + headers?(accessToken: string): Record + /** Request body, for the providers whose identity lives behind a GraphQL query. */ + body?: string + } + /** Must throw when the response does not establish an identity — never invent a fallback. */ + parse(profile: unknown, tokens: OAuth2Tokens): ManagedOAuthProfileIdentity + scopes: ManagedOAuthScopeResolution + requiresRefreshToken: boolean + pkce?: boolean + scopeless?: boolean + additionalScopes?: string[] + prompt?: string + authorizationUrlParams?: Record +} + +async function fetchManagedOAuthProfile( + options: UserInfoManagedOAuthConnectorOptions, + accessToken: string, + tokens: OAuth2Tokens +): Promise { + const { providerId, userInfo } = options + const url = typeof userInfo.url === 'function' ? userInfo.url(tokens) : userInfo.url + const response = await fetch(url, { + method: userInfo.method ?? 'GET', + headers: { + Accept: 'application/json', + ...(userInfo.headers?.(accessToken) ?? { Authorization: `Bearer ${accessToken}` }), + }, + ...(userInfo.body ? { body: userInfo.body } : {}), + signal: AbortSignal.timeout(USER_INFO_TIMEOUT_MS), + }) + const profile = await readResponseJsonWithLimit(response, { + maxBytes: USER_INFO_MAX_BYTES, + label: `${providerId} user identity response`, + }) + if (!response.ok) { + throw new Error(`${providerId} user identity request failed with HTTP ${response.status}`) + } + return profile +} + +function resolveGrantedScopes( + options: UserInfoManagedOAuthConnectorOptions, + profile: unknown, + tokens: OAuth2Tokens +): string[] { + switch (options.scopes.from) { + case 'profile': + return [...new Set(options.scopes.read(profile, tokens))] + case 'requested': + return [ + ...new Set([ + ...getCanonicalScopesForProvider(options.providerId), + ...(options.additionalScopes ?? []), + ]), + ] + default: + return [...new Set(tokens.scopes ?? [])] + } +} + +/** + * Managed enrollment policy for a provider whose identity comes from a plain profile endpoint + * rather than an OIDC id_token. + * + * The matching `getUserInfo` in `connectors/providers.ts` is not reusable here even though it + * calls the same endpoint: it exists to satisfy Better Auth's `email_is_missing` guard, so it + * substitutes a synthetic address when the provider returns none and asserts `emailVerified: true` + * in several places the provider never verified. Managed enrollment binds a credential to an + * invited person, so `parse` must report only what the provider actually proves. + */ +export function createUserInfoManagedOAuthConnector( + options: UserInfoManagedOAuthConnectorOptions +): ManagedOAuthConnectorConfig { + const { providerId } = options + return { + additionalScopes: options.additionalScopes ?? [], + requiresRefreshToken: options.requiresRefreshToken, + pkce: options.pkce ?? false, + nonceVerification: 'state_only', + includeLoginHint: false, + ...(options.scopeless ? { scopeless: true } : {}), + ...(options.prompt ? { prompt: options.prompt } : {}), + ...(options.authorizationUrlParams + ? { authorizationUrlParams: options.authorizationUrlParams } + : {}), + getAuthorizationAppId(clientId) { + return `${providerId}:${createHash('sha256').update(clientId).digest('hex')}` + }, + async verifyIdentity({ tokens }) { + const accessToken = tokens.accessToken + if (!accessToken) { + throw new Error(`${providerId} returned an incomplete authorization`) + } + const profile = await fetchManagedOAuthProfile(options, accessToken, tokens) + const identity = options.parse(profile, tokens) + if (!identity.providerSubjectId.trim() || !identity.email.trim()) { + throw new Error(`${providerId} returned an invalid user identity`) + } + return { + providerSubjectId: identity.providerSubjectId, + providerTenantId: identity.providerTenantId ?? null, + email: identity.email, + emailVerified: identity.emailVerified, + ...(identity.displayName ? { displayName: identity.displayName } : {}), + ...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}), + grantedScopes: resolveGrantedScopes(options, profile, tokens), + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + const granted = new Set(grantedScopes) + return requiredScopes.every((scope) => granted.has(scope)) + }, + isTerminalRefreshError, + } +} + +function asProfileRecord(profile: unknown, providerName: string): Record { + if (!isRecordLike(profile)) { + throw new Error(`${providerName} returned an invalid user identity`) + } + return profile +} + +/** Reads a required identity field, accepting a numeric id as the string it stands for. */ +function requireIdentityField(value: unknown, label: string): string { + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${label} is missing from the provider's user identity`) + } + return value +} + +function optionalIdentityField(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +function withOptionalIdentityFields( + identity: ManagedOAuthProfileIdentity, + fields: { displayName?: unknown; avatarUrl?: unknown; providerTenantId?: unknown } +): ManagedOAuthProfileIdentity { + const displayName = optionalIdentityField(fields.displayName) + const avatarUrl = optionalIdentityField(fields.avatarUrl) + const providerTenantId = + typeof fields.providerTenantId === 'number' && Number.isFinite(fields.providerTenantId) + ? String(fields.providerTenantId) + : optionalIdentityField(fields.providerTenantId) + return { + ...identity, + ...(displayName ? { displayName } : {}), + ...(avatarUrl ? { avatarUrl } : {}), + ...(providerTenantId ? { providerTenantId } : {}), + } +} + +/** + * Unwraps a GraphQL identity response, treating a partial success as a failure: a `data` payload + * accompanied by `errors` means the provider could not answer the whole query, and the fields it + * did return are not a complete identity. + */ +function readGraphQLIdentity( + profile: unknown, + path: string, + providerName: string +): Record { + const envelope = asProfileRecord(profile, providerName) + if (Array.isArray(envelope.errors) && envelope.errors.length > 0) { + throw new Error(`${providerName} returned an invalid user identity`) + } + const data = asProfileRecord(envelope.data, providerName) + return asProfileRecord(data[path], providerName) +} + +/** Splits a provider's space-delimited `scope` string, tolerating its absence. */ +function readScopeString(value: unknown): string[] { + return typeof value === 'string' ? value.split(/\s+/).filter(Boolean) : [] +} + +/** + * Salesforce registers one connector per authorization server, so the userinfo host has to follow + * the provider id rather than being fixed. + */ +function createSalesforceManagedOAuthConnector(providerId: string): ManagedOAuthConnectorConfig { + const loginHost = SALESFORCE_LOGIN_HOSTS[providerId] + if (!loginHost) { + throw new Error(`Unknown Salesforce authorization server: ${providerId}`) + } + return createUserInfoManagedOAuthConnector({ + providerId, + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { url: `https://${loginHost}/services/oauth2/userinfo` }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Salesforce') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.user_id ?? user.sub, 'Salesforce user id'), + email: requireIdentityField(user.email, 'Salesforce email'), + emailVerified: user.email_verified === true, + }, + { displayName: user.name, avatarUrl: user.picture, providerTenantId: user.organization_id } + ) + }, + }) +} + +/** + * Attio's identity takes two calls: `/v2/self` names the member who authorized the token, and only + * the member record carries their email. Listing members instead would return them in no defined + * order, recording a stranger as the account's subject. + */ +function createAttioManagedOAuthConnector(): ManagedOAuthConnectorConfig { + return { + additionalScopes: [], + requiresRefreshToken: false, + pkce: false, + nonceVerification: 'state_only', + includeLoginHint: false, + getAuthorizationAppId(clientId) { + return `attio:${createHash('sha256').update(clientId).digest('hex')}` + }, + async verifyIdentity({ tokens }) { + if (!tokens.accessToken) { + throw new Error('Attio returned an incomplete authorization') + } + const headers = { + Accept: 'application/json', + Authorization: `Bearer ${tokens.accessToken}`, + } + const selfResponse = await fetch('https://api.attio.com/v2/self', { + headers, + signal: AbortSignal.timeout(USER_INFO_TIMEOUT_MS), + }) + const self = await readResponseJsonWithLimit(selfResponse, { + maxBytes: USER_INFO_MAX_BYTES, + label: 'Attio identity response', + }) + if (!selfResponse.ok) { + throw new Error(`Attio identity request failed with HTTP ${selfResponse.status}`) + } + const identity = asProfileRecord(self, 'Attio') + const memberId = requireIdentityField( + identity.authorized_by_workspace_member_id, + 'Attio workspace member id' + ) + const memberResponse = await fetch( + `https://api.attio.com/v2/workspace_members/${encodeURIComponent(memberId)}`, + { headers, signal: AbortSignal.timeout(USER_INFO_TIMEOUT_MS) } + ) + const memberBody = await readResponseJsonWithLimit(memberResponse, { + maxBytes: USER_INFO_MAX_BYTES, + label: 'Attio workspace member response', + }) + if (!memberResponse.ok) { + throw new Error(`Attio workspace member request failed with HTTP ${memberResponse.status}`) + } + const member = asProfileRecord(asProfileRecord(memberBody, 'Attio').data, 'Attio') + const name = `${member.first_name ?? ''} ${member.last_name ?? ''}`.trim() + const base = withOptionalIdentityFields( + { + providerSubjectId: memberId, + email: requireIdentityField(member.email_address, 'Attio email'), + /** An Attio workspace member is only created by accepting a mailed invitation. */ + emailVerified: true, + }, + { displayName: name, avatarUrl: member.avatar_url, providerTenantId: identity.workspace_id } + ) + /** + * Attio's token response omits `scope`, and its scope set is fixed by the OAuth app rather + * than chosen at consent, so the requested set is what was granted. + */ + const grantedScopes = tokens.scopes?.length + ? tokens.scopes + : getCanonicalScopesForProvider('attio') + return { + ...base, + providerTenantId: base.providerTenantId ?? null, + grantedScopes: [...new Set(grantedScopes)], + } + }, + hasRequiredScopes(grantedScopes, requiredScopes) { + const granted = new Set(grantedScopes) + return requiredScopes.every((scope) => granted.has(scope)) + }, + isTerminalRefreshError, + } +} + +/** + * Managed enrollment policies for the providers whose identity endpoint reports an email the + * provider itself vouches for. Keyed by connector provider id. + * + * A `Map` rather than an object literal so a provider id that collides with an `Object.prototype` + * member cannot resolve to an inherited function and be invoked as a policy builder. + */ +const USER_INFO_MANAGED_OAUTH_CONNECTORS = new Map ManagedOAuthConnectorConfig>([ + [ + 'dropbox', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'dropbox', + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.dropboxapi.com/2/users/get_current_account', + method: 'POST', + }, + parse: (profile) => { + const account = asProfileRecord(profile, 'Dropbox') + const name = isRecordLike(account.name) ? account.name : {} + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(account.account_id, 'Dropbox account id'), + email: requireIdentityField(account.email, 'Dropbox email'), + emailVerified: account.email_verified === true, + }, + { displayName: name.display_name, avatarUrl: account.profile_photo_url } + ) + }, + }), + ], + [ + 'zoom', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'zoom', + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.zoom.us/v2/users/me' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Zoom') + const displayName = `${user.first_name ?? ''} ${user.last_name ?? ''}`.trim() + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Zoom user id'), + email: requireIdentityField(user.email, 'Zoom email'), + /** Zoom reports `1` for an activated, email-confirmed account. */ + emailVerified: user.verified === 1, + }, + { displayName, avatarUrl: user.pic_url, providerTenantId: user.account_id } + ) + }, + }), + ], + ['salesforce', () => createSalesforceManagedOAuthConnector('salesforce')], + [ + 'notion', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'notion', + /** Notion authorizes without scopes and its integration tokens do not expire. */ + scopeless: true, + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.notion.com/v1/users/me', + headers: (accessToken) => ({ + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': '2022-06-28', + }), + }, + parse: (profile) => { + const self = asProfileRecord(profile, 'Notion') + /** + * An integration token always resolves to a bot, so the human is reachable only through + * `bot.owner.user`. A workspace-owned internal integration reports + * `{ type: 'workspace' }` and identifies nobody, which cannot be bound to an invitation. + */ + const bot = isRecordLike(self.bot) ? self.bot : {} + const owner = isRecordLike(bot.owner) ? bot.owner : {} + if (owner.type !== 'user') { + throw new Error( + 'Notion returned a workspace-owned integration, which identifies no person to bind this invitation to' + ) + } + const user = asProfileRecord(owner.user, 'Notion') + const person = isRecordLike(user.person) ? user.person : {} + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Notion user id'), + email: requireIdentityField(person.email, 'Notion email'), + /** Notion only exposes `person.email` for a confirmed workspace member. */ + emailVerified: true, + }, + { displayName: user.name, avatarUrl: user.avatar_url } + ) + }, + }), + ], + [ + 'clickup', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'clickup', + /** ClickUp authorizes without scopes and its access tokens do not expire. */ + scopeless: true, + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.clickup.com/api/v2/user' }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'ClickUp') + const user = asProfileRecord(envelope.user, 'ClickUp') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'ClickUp user id'), + email: requireIdentityField(user.email, 'ClickUp email'), + /** A ClickUp seat is only activated by confirming a mailed invitation. */ + emailVerified: true, + }, + { displayName: user.username, avatarUrl: user.profilePicture } + ) + }, + }), + ], + [ + 'calcom', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'calcom', + /** Cal.com's OAuth app authorizes without a scope list. */ + scopeless: true, + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.cal.com/v2/me', + headers: (accessToken) => ({ + Authorization: `Bearer ${accessToken}`, + 'cal-api-version': '2024-08-13', + }), + }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'Cal.com') + const user = asProfileRecord(envelope.data ?? envelope, 'Cal.com') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Cal.com user id'), + email: requireIdentityField(user.email, 'Cal.com email'), + /** A Cal.com account is only usable once its address has confirmed signup. */ + emailVerified: true, + }, + { displayName: user.name, avatarUrl: user.avatarUrl } + ) + }, + }), + ], + ['attio', createAttioManagedOAuthConnector], + [ + 'hubspot', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'hubspot', + requiresRefreshToken: true, + /** + * HubSpot reports neither identity nor scopes on the token response. Its token-metadata + * endpoint carries both, so one call answers each. + */ + scopes: { + from: 'profile', + read: (profile) => { + const metadata = isRecordLike(profile) ? profile : {} + if (Array.isArray(metadata.scopes)) { + return metadata.scopes.filter((scope): scope is string => typeof scope === 'string') + } + return readScopeString(metadata.scope) + }, + }, + userInfo: { + /** The token identifies itself: it is the path, and the endpoint takes no credential. */ + url: (tokens) => + `https://api.hubapi.com/oauth/v1/access-tokens/${encodeURIComponent( + tokens.accessToken ?? '' + )}`, + headers: () => ({}), + }, + parse: (profile) => { + const metadata = asProfileRecord(profile, 'HubSpot') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(metadata.user_id, 'HubSpot user id'), + /** HubSpot reports the authorizing seat's address as `user`. */ + email: requireIdentityField(metadata.user, 'HubSpot user email'), + /** A HubSpot seat is only activated by confirming a mailed invitation. */ + emailVerified: true, + }, + { providerTenantId: metadata.hub_id } + ) + }, + }), + ], + [ + 'linear', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'linear', + pkce: true, + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { + url: 'https://api.linear.app/graphql', + method: 'POST', + headers: (accessToken) => ({ + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ query: '{ viewer { id email name avatarUrl } }' }), + }, + parse: (profile) => { + const viewer = readGraphQLIdentity(profile, 'viewer', 'Linear') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(viewer.id, 'Linear user id'), + email: requireIdentityField(viewer.email, 'Linear email'), + /** A Linear account only exists once its address has accepted a mailed invite. */ + emailVerified: true, + }, + { displayName: viewer.name, avatarUrl: viewer.avatarUrl } + ) + }, + }), + ], + [ + 'monday', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'monday', + /** monday.com access tokens do not expire and no refresh token is issued. */ + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { + url: MONDAY_API_URL, + method: 'POST', + headers: (accessToken) => ({ + Authorization: accessToken, + 'Content-Type': 'application/json', + 'API-Version': MONDAY_API_VERSION, + }), + body: JSON.stringify({ query: '{ me { id name email } }' }), + }, + parse: (profile) => { + const user = readGraphQLIdentity(profile, 'me', 'monday.com') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'monday.com user id'), + email: requireIdentityField(user.email, 'monday.com email'), + /** monday.com activates a seat only after its address confirms the invitation. */ + emailVerified: true, + }, + { displayName: user.name } + ) + }, + }), + ], + [ + 'box', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'box', + requiresRefreshToken: true, + /** + * Box's token response reports `restricted_to`, never `scope`, and the scope set is fixed + * by the Box app registration rather than chosen at consent — so the requested set is the + * granted set, and reading the token response would fail every enrollment on an empty list. + */ + scopes: { from: 'requested' }, + userInfo: { url: 'https://api.box.com/2.0/users/me' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Box') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Box user id'), + /** Box's `login` is the account's confirmed sign-in address. */ + email: requireIdentityField(user.login, 'Box login'), + emailVerified: user.status === 'active', + }, + { displayName: user.name, avatarUrl: user.avatar_url } + ) + }, + }), + ], + [ + 'asana', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'asana', + requiresRefreshToken: true, + scopes: { from: 'requested' }, + userInfo: { url: 'https://app.asana.com/api/1.0/users/me' }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'Asana') + const user = asProfileRecord(envelope.data, 'Asana') + const photo = isRecordLike(user.photo) ? user.photo : {} + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.gid, 'Asana user id'), + email: requireIdentityField(user.email, 'Asana email'), + /** An Asana account is only usable once its address has confirmed the invitation. */ + emailVerified: true, + }, + { displayName: user.name, avatarUrl: photo.image_128x128 } + ) + }, + }), + ], + [ + 'airtable', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'airtable', + pkce: true, + requiresRefreshToken: true, + /** Airtable's `whoami` reports the token's own scopes; the token response does not. */ + scopes: { + from: 'profile', + read: (profile, tokens) => { + const granted = + isRecordLike(profile) && Array.isArray(profile.scopes) + ? profile.scopes.filter((scope): scope is string => typeof scope === 'string') + : [] + return granted.length ? granted : (tokens.scopes ?? []) + }, + }, + userInfo: { url: 'https://api.airtable.com/v0/meta/whoami' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'Airtable') + return { + providerSubjectId: requireIdentityField(user.id, 'Airtable user id'), + email: requireIdentityField(user.email, 'Airtable email'), + /** Airtable only returns `email` once the address has been confirmed. */ + emailVerified: true, + } + }, + }), + ], + [ + 'linkedin', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'linkedin', + requiresRefreshToken: false, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.linkedin.com/v2/userinfo' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'LinkedIn') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.sub, 'LinkedIn subject id'), + email: requireIdentityField(user.email, 'LinkedIn email'), + /** + * The OIDC claim, not a constant. The connector-layer `getUserInfo` asserts `true` + * unconditionally, which would let an unverified address satisfy an invitation. + */ + emailVerified: user.email_verified === true || user.email_verified === 'true', + }, + { displayName: user.name, avatarUrl: user.picture } + ) + }, + }), + ], + [ + 'pipedrive', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'pipedrive', + requiresRefreshToken: true, + scopes: { from: 'token_response' }, + userInfo: { url: 'https://api.pipedrive.com/v1/users/me' }, + parse: (profile) => { + const envelope = asProfileRecord(profile, 'Pipedrive') + const user = asProfileRecord(envelope.data, 'Pipedrive') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.id, 'Pipedrive user id'), + email: requireIdentityField(user.email, 'Pipedrive email'), + /** Pipedrive activates a seat only once its invitation email is accepted. */ + emailVerified: user.activated === true, + }, + { displayName: user.name, avatarUrl: user.icon_url, providerTenantId: user.company_id } + ) + }, + }), + ], + [ + 'wordpress', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'wordpress', + /** WordPress.com issues long-lived tokens and no refresh token. */ + requiresRefreshToken: false, + /** + * WordPress.com's only scope is `global`, granted whole or not at all, so the requested set + * is the granted set and there is nothing a consent screen could downgrade. + */ + scopes: { from: 'requested' }, + userInfo: { url: 'https://public-api.wordpress.com/rest/v1.1/me' }, + parse: (profile) => { + const user = asProfileRecord(profile, 'WordPress.com') + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.ID ?? user.id, 'WordPress.com user id'), + email: requireIdentityField(user.email, 'WordPress.com email'), + emailVerified: user.email_verified === true, + }, + { displayName: user.display_name ?? user.username, avatarUrl: user.avatar_URL } + ) + }, + }), + ], + [ + 'docusign', + () => + createUserInfoManagedOAuthConnector({ + providerId: 'docusign', + requiresRefreshToken: true, + /** DocuSign reports the granted scopes on userinfo, not on the token response. */ + scopes: { + from: 'profile', + read: (profile, tokens) => { + const granted = isRecordLike(profile) ? readScopeString(profile.scope) : [] + return granted.length ? granted : (tokens.scopes ?? []) + }, + }, + userInfo: { url: getDocusignOAuthUrl('/oauth/userinfo') }, + parse: (profile) => { + const user = asProfileRecord(profile, 'DocuSign') + const accounts = Array.isArray(user.accounts) ? user.accounts.filter(isRecordLike) : [] + const defaultAccount = + accounts.find((account) => account.is_default === true) ?? accounts[0] + return withOptionalIdentityFields( + { + providerSubjectId: requireIdentityField(user.sub, 'DocuSign subject id'), + email: requireIdentityField(user.email, 'DocuSign email'), + /** A DocuSign account is only activated by following a link sent to this address. */ + emailVerified: true, + }, + { + displayName: user.name, + providerTenantId: defaultAccount?.account_id, + } + ) + }, + }), + ], +]) + +/** + * The managed enrollment policy for a provider, without the surrounding connector. Separated from + * {@link getManagedOAuthConnectorProviderConfig} so a policy can be inspected without a configured + * OAuth client, which `buildConnectorProviders` requires. + */ +export function getManagedOAuthConnectorPolicy( + providerId: string +): ManagedOAuthConnectorConfig | undefined { + return resolveManagedOAuthPolicy(providerId)?.() +} + +function resolveManagedOAuthPolicy( + providerId: string +): (() => ManagedOAuthConnectorConfig) | undefined { + if (providerId === 'google-email' || providerId === 'google-calendar') { + return () => createGoogleManagedOAuthConnector(providerId) + } + if (providerId === 'confluence' || providerId === 'jira') { + return () => createAtlassianManagedOAuthConnector(providerId) + } + return USER_INFO_MANAGED_OAUTH_CONNECTORS.get(providerId) +} + export function getManagedOAuthConnectorProviderConfig( providerId: string ): ConnectorProviderConfig | undefined { - const isGoogle = providerId === 'google-email' || providerId === 'google-calendar' - const isAtlassian = providerId === 'confluence' || providerId === 'jira' - if (!isGoogle && !isAtlassian) return undefined + const buildPolicy = resolveManagedOAuthPolicy(providerId) + if (!buildPolicy) return undefined const connector = buildConnectorProviders().find( (candidate) => candidate.providerId === providerId ) if (!connector) return undefined - return { - ...connector, - managedOAuth: isGoogle - ? createGoogleManagedOAuthConnector(providerId) - : createAtlassianManagedOAuthConnector(providerId), - } + return { ...connector, managedOAuth: buildPolicy() } } diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index fbc3a0d3a08..d871461244b 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -120,7 +120,12 @@ describe('Credential Group Settings application operations', () => { expect(mocks.requireAvailable).toHaveBeenCalledWith('workspace-1') expect(mocks.list).toHaveBeenCalledWith('workspace-1') - expect(result).toEqual({ credentialGroups: [] }) + expect(result.credentialGroups).toEqual([]) + /** + * Which providers are offerable depends on the OAuth clients this environment configures, so + * the list is asserted as a shape rather than a fixed set. + */ + expect(Array.isArray(result.availableProviders)).toBe(true) }) it('derives created-by identity from the authenticated session principal', async () => { diff --git a/apps/sim/lib/credential-groups/application/manage-groups.ts b/apps/sim/lib/credential-groups/application/manage-groups.ts index f3fbd3e6761..3825bf52b69 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.ts @@ -17,6 +17,7 @@ import { CredentialGroupEnrollmentError, listCredentialGroupEnrollments, } from '@/lib/credential-groups/enrollments' +import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability' import { createCredentialGroup, deleteCredentialGroup, @@ -47,7 +48,10 @@ export const listCredentialGroupSettings = defineAuthorizedWorkspaceUseCase({ authorizationOptions: {}, async execute({ context }) { await requireCredentialGroupSettingsAvailable(context.workspaceId) - return { credentialGroups: await listCredentialGroups(context.workspaceId) } + return { + credentialGroups: await listCredentialGroups(context.workspaceId), + availableProviders: listConfiguredCredentialGroupProviders(), + } }, }) diff --git a/apps/sim/lib/credential-groups/provider-availability.ts b/apps/sim/lib/credential-groups/provider-availability.ts new file mode 100644 index 00000000000..fcc2e806906 --- /dev/null +++ b/apps/sim/lib/credential-groups/provider-availability.ts @@ -0,0 +1,27 @@ +import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { + CREDENTIAL_GROUP_PROVIDER_IDS, + type CredentialGroupProvider, + getCredentialGroupProviderId, + isCredentialGroupStandardOAuthProvider, +} from '@/lib/credential-groups/providers' + +/** + * The providers this deployment can actually enroll. + * + * A standard OAuth provider needs Sim's own OAuth client for that service to be configured; + * without it the connector is never built and starting an enrollment fails with a configuration + * error. Offering the option anyway leaves an admin with a row that only reports its own + * unavailability after they have already added it to a group and invited somebody. + * + * Slack is exempt because its credential is the workspace's own custom bot, configured per group + * after the fact — its readiness is already surfaced by the option's `configurationStatus`. + * + * Server-only: `inspectConfiguredOAuthClient` reads the server environment. + */ +export function listConfiguredCredentialGroupProviders(): CredentialGroupProvider[] { + return CREDENTIAL_GROUP_PROVIDER_IDS.filter((provider) => { + if (!isCredentialGroupStandardOAuthProvider(provider)) return true + return inspectConfiguredOAuthClient(getCredentialGroupProviderId(provider)).state === 'ready' + }) +} diff --git a/apps/sim/lib/credential-groups/provider-registry.test.ts b/apps/sim/lib/credential-groups/provider-registry.test.ts index bc8930df8f5..b9a3bb612c6 100644 --- a/apps/sim/lib/credential-groups/provider-registry.test.ts +++ b/apps/sim/lib/credential-groups/provider-registry.test.ts @@ -5,9 +5,11 @@ import { describe, expect, it } from 'vitest' import { createAtlassianManagedOAuthConnector, createGoogleManagedOAuthConnector, + getManagedOAuthConnectorPolicy, } from '@/lib/auth/connectors/managed-oauth' import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry' import { + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS, getCredentialGroupProviderFromProviderId, getCredentialGroupProviderService, getCredentialGroupStandardOAuthProviderFromProviderId, @@ -99,4 +101,33 @@ describe('Credential Group provider registry', () => { 'Unsupported managed OAuth provider' ) }) + it.each(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)( + 'backs %s with a managed OAuth policy and a round-trippable provider id', + (provider) => { + const service = getCredentialGroupProviderService(provider) + + expect(getCredentialGroupProviderFromProviderId(service.providerId)).toBe(provider) + expect(getCredentialGroupStandardOAuthProviderFromProviderId(service.providerId)).toBe( + provider + ) + expect(getCredentialGroupProviderAdapter(provider).provider).toBe(provider) + /** + * The provider list and the connector policy catalog are maintained separately, so an entry + * added to one and not the other would otherwise only surface as a runtime configuration + * error the first time somebody tried to enroll. + */ + expect(getManagedOAuthConnectorPolicy(service.providerId)).toBeDefined() + } + ) + + it.each(CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS)( + 'gives %s either a scope policy or an explicit scopeless declaration', + (provider) => { + const service = getCredentialGroupProviderService(provider) + const policy = getManagedOAuthConnectorPolicy(service.providerId) + const requiredScopes = [...service.scopes, ...(policy?.additionalScopes ?? [])] + + expect(requiredScopes.length > 0 || policy?.scopeless === true).toBe(true) + } + ) }) diff --git a/apps/sim/lib/credential-groups/provider-registry.ts b/apps/sim/lib/credential-groups/provider-registry.ts index f4517c1e598..08642b7e598 100644 --- a/apps/sim/lib/credential-groups/provider-registry.ts +++ b/apps/sim/lib/credential-groups/provider-registry.ts @@ -14,6 +14,23 @@ const CREDENTIAL_GROUP_PROVIDER_ADAPTERS: Record< 'google-calendar': createStandardOAuthCredentialGroupProviderAdapter('google-calendar'), confluence: createStandardOAuthCredentialGroupProviderAdapter('confluence'), jira: createStandardOAuthCredentialGroupProviderAdapter('jira'), + airtable: createStandardOAuthCredentialGroupProviderAdapter('airtable'), + asana: createStandardOAuthCredentialGroupProviderAdapter('asana'), + attio: createStandardOAuthCredentialGroupProviderAdapter('attio'), + box: createStandardOAuthCredentialGroupProviderAdapter('box'), + calcom: createStandardOAuthCredentialGroupProviderAdapter('calcom'), + clickup: createStandardOAuthCredentialGroupProviderAdapter('clickup'), + docusign: createStandardOAuthCredentialGroupProviderAdapter('docusign'), + hubspot: createStandardOAuthCredentialGroupProviderAdapter('hubspot'), + linear: createStandardOAuthCredentialGroupProviderAdapter('linear'), + monday: createStandardOAuthCredentialGroupProviderAdapter('monday'), + notion: createStandardOAuthCredentialGroupProviderAdapter('notion'), + dropbox: createStandardOAuthCredentialGroupProviderAdapter('dropbox'), + linkedin: createStandardOAuthCredentialGroupProviderAdapter('linkedin'), + pipedrive: createStandardOAuthCredentialGroupProviderAdapter('pipedrive'), + salesforce: createStandardOAuthCredentialGroupProviderAdapter('salesforce'), + wordpress: createStandardOAuthCredentialGroupProviderAdapter('wordpress'), + zoom: createStandardOAuthCredentialGroupProviderAdapter('zoom'), slack: slackCredentialGroupProviderAdapter, } diff --git a/apps/sim/lib/credential-groups/providers.ts b/apps/sim/lib/credential-groups/providers.ts index d3cc25e0612..07cce8fe04d 100644 --- a/apps/sim/lib/credential-groups/providers.ts +++ b/apps/sim/lib/credential-groups/providers.ts @@ -6,6 +6,23 @@ export const CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS = [ 'google-calendar', 'confluence', 'jira', + 'airtable', + 'asana', + 'attio', + 'box', + 'calcom', + 'clickup', + 'docusign', + 'dropbox', + 'hubspot', + 'linear', + 'linkedin', + 'monday', + 'notion', + 'pipedrive', + 'salesforce', + 'wordpress', + 'zoom', ] as const export type CredentialGroupStandardOAuthProvider = @@ -48,6 +65,91 @@ const CREDENTIAL_GROUP_PROVIDER_SUPPORT: Record< description: 'Let each person connect one Jira account', configuration: 'oauth', }, + airtable: { + serviceId: 'airtable', + description: 'Let each person connect one Airtable account', + configuration: 'oauth', + }, + asana: { + serviceId: 'asana', + description: 'Let each person connect one Asana account', + configuration: 'oauth', + }, + attio: { + serviceId: 'attio', + description: 'Let each person connect one Attio account', + configuration: 'oauth', + }, + box: { + serviceId: 'box', + description: 'Let each person connect one Box account', + configuration: 'oauth', + }, + calcom: { + serviceId: 'calcom', + description: 'Let each person connect one Cal.com account', + configuration: 'oauth', + }, + clickup: { + serviceId: 'clickup', + description: 'Let each person connect one ClickUp account', + configuration: 'oauth', + }, + hubspot: { + serviceId: 'hubspot', + description: 'Let each person connect one HubSpot account', + configuration: 'oauth', + }, + linear: { + serviceId: 'linear', + description: 'Let each person connect one Linear account', + configuration: 'oauth', + }, + monday: { + serviceId: 'monday', + description: 'Let each person connect one monday.com account', + configuration: 'oauth', + }, + notion: { + serviceId: 'notion', + description: 'Let each person connect one Notion account', + configuration: 'oauth', + }, + docusign: { + serviceId: 'docusign', + description: 'Let each person connect one DocuSign account', + configuration: 'oauth', + }, + dropbox: { + serviceId: 'dropbox', + description: 'Let each person connect one Dropbox account', + configuration: 'oauth', + }, + linkedin: { + serviceId: 'linkedin', + description: 'Let each person connect one LinkedIn account', + configuration: 'oauth', + }, + pipedrive: { + serviceId: 'pipedrive', + description: 'Let each person connect one Pipedrive account', + configuration: 'oauth', + }, + salesforce: { + serviceId: 'salesforce', + description: 'Let each person connect one Salesforce account', + configuration: 'oauth', + }, + wordpress: { + serviceId: 'wordpress', + description: 'Let each person connect one WordPress.com account', + configuration: 'oauth', + }, + zoom: { + serviceId: 'zoom', + description: 'Let each person connect one Zoom account', + configuration: 'oauth', + }, slack: { serviceId: 'slack', description: 'Let each person connect through your custom Slack app', diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts index dfedb086bc8..f1c568f6d3a 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.test.ts @@ -296,4 +296,51 @@ describe('standard OAuth Credential Group provider', () => { codeVerifier: undefined, }) }) + it.each(['bearer', 'BEARER'])( + 'accepts the RFC 6749 case-insensitive %s token type', + async (tokenType) => { + mockGetToken.mockResolvedValueOnce({ + tokenType, + accessToken: 'access-1', + refreshToken: 'refresh-1', + accessTokenExpiresAt: new Date('2026-08-14T01:00:00Z'), + }) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + const grant = await adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + + expect(grant.accessToken).toBe('access-1') + } + ) + + it('still rejects a token type that is not bearer at all', async () => { + mockGetToken.mockResolvedValueOnce({ + tokenType: 'mac', + accessToken: 'access-1', + refreshToken: 'refresh-1', + }) + const context = buildContext() + const policy = await adapter.getPolicy(context.option, { + workspaceId: context.workspaceId, + credentialGroupId: context.credentialGroupId, + }) + + await expect( + adapter.exchangeAndVerify({ + context, + attempt: buildAttempt(policy.scopeVersion), + code: 'code-1', + policy, + }) + ).rejects.toMatchObject({ statusCode: 502 }) + }) }) diff --git a/apps/sim/lib/credential-groups/standard-oauth-provider.ts b/apps/sim/lib/credential-groups/standard-oauth-provider.ts index 5e312d2766d..c9e7c85c2e7 100644 --- a/apps/sim/lib/credential-groups/standard-oauth-provider.ts +++ b/apps/sim/lib/credential-groups/standard-oauth-provider.ts @@ -33,6 +33,15 @@ interface OAuthEndpoints { tokenEndpoint: string } +/** + * RFC 6749 §7.1 defines `token_type` as case-insensitive, and Better Auth passes the provider's + * raw `token_type` through untouched. Several providers answer with lowercase `bearer`, so an + * exact match would reject a perfectly valid grant as an incomplete authorization. + */ +function isBearerTokenType(tokenType: string | undefined): boolean { + return tokenType?.toLowerCase() === 'bearer' +} + interface CurrentStandardOAuthProvider { connector: ConnectorProviderConfig policy: CredentialGroupProviderPolicy @@ -138,7 +147,7 @@ function getCurrentProvider( const requiredScopes = [ ...new Set([...(connector.scopes ?? []), ...connector.managedOAuth.additionalScopes]), ] - if (requiredScopes.length === 0) { + if (requiredScopes.length === 0 && !connector.managedOAuth.scopeless) { throw new CredentialGroupProviderConfigurationError( `Managed ${service.name} authorization has no scope policy` ) @@ -290,7 +299,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter( 502 ) } - if (tokens.tokenType !== 'Bearer' || !tokens.accessToken) { + if (!isBearerTokenType(tokens.tokenType) || !tokens.accessToken) { throw new CredentialGroupOAuthError( `${service.name} returned an incomplete authorization.`, 502 From 9684e798b0d9db7dc958774d6470645982e5e95f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 27 Aug 2026 23:12:47 -0700 Subject: [PATCH 2/2] fix(credential-groups): hydrate the list cache with the whole response The settings prefetch seeded the shared list key with only the groups array, so every consumer read an empty list for as long as the hydrated value stayed fresh. Also resets the account-type filter when a group opens or closes, alongside the tab it already reset. --- .../settings/[section]/prefetch.test.ts | 15 +++++++++++++-- .../[workspaceId]/settings/[section]/prefetch.ts | 8 +++++++- .../components/credential-groups-settings.tsx | 9 +++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts index f6ad312fa57..0f31daaed49 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.test.ts @@ -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, { @@ -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 () => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts index 91c58850cc2..596cb24a1ac 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/prefetch.ts @@ -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, }) diff --git a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx index 2e92dbc08e1..0deb52501fb 100644 --- a/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx +++ b/apps/sim/ee/credential-groups/components/credential-groups-settings.tsx @@ -8,6 +8,8 @@ import { useQueryState } from 'nuqs' import { credentialGroupIdParam, credentialGroupIdUrlKeys, + credentialGroupProviderSearchParam, + credentialGroupProviderSearchUrlKeys, credentialGroupTabParam, credentialGroupTabUrlKeys, } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' @@ -47,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)