Skip to content

Commit 50e3676

Browse files
committed
fix(integrations): show family service accounts on every product they authenticate
An Atlassian API token authenticates Jira, Jira Service Management, and Confluence alike, so it is modeled as an `atlassian` pseudo-provider whose only service is named "Atlassian Service Account". Every credential display surface resolved through `getServiceConfigByProviderId`, which walks OAUTH_PROVIDERS in declaration order — so the credential resolved to that pseudo-service instead of to any product. The result: adding a service account from the Jira page, through a modal titled "Add Jira service account", produced a credential that appeared under neither Jira, JSM, nor Confluence, was titled "Atlassian Service Account" on its detail page, and lost its brand tile and category on the list. The same bug hid a Google service account everywhere except Gmail. - match credentials with `credentialProviderMatchesService`, which accepts a service's OAuth id or its service-account id - add `lib/integrations/credential-display.ts` as the single resolver for catalog join, mark, and copy, replacing three duplicated lookups that keyed the catalog by OAuth service *display name* — the reason the pseudo-service fell off the map - derive "family service account" from the catalog (a service-account id serving >1 integration) rather than hardcoding vendors, so a new integration joining a family needs no edit - title service-account detail pages by credential name, subtitle them with their reach, and state that reach up front on the connect form - keep the service description as the detail subtitle for every non-family credential, unchanged No schema, migration, contract, or persisted value changes; resolution is computed at render time from static config. Coverage for all 22 service-account provider ids is pinned in tests, including that the index and the predicate the Connected list filters on cannot drift apart.
1 parent 6647808 commit 50e3676

13 files changed

Lines changed: 751 additions & 79 deletions

File tree

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { useQueryState } from 'nuqs'
99
import {
1010
blockTypeToIconMap,
1111
type Integration,
12+
resolveCredentialDisplay,
1213
resolveOAuthServiceForIntegration,
1314
} from '@/lib/integrations'
14-
import { getServiceConfigByProviderId } from '@/lib/oauth'
15+
import { credentialProviderMatchesService } from '@/lib/oauth'
1516
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
1617
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
1718
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
@@ -65,13 +66,21 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
6566

6667
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
6768

69+
/**
70+
* Credentials that authenticate this integration. Matching goes through
71+
* `credentialProviderMatchesService` so a family service account lists on
72+
* every product it powers — one Atlassian token covers Jira, Jira Service
73+
* Management, and Confluence. Comparing resolved `providerId`s instead would
74+
* hide it from all three, since `atlassian-service-account` resolves to its
75+
* own pseudo-service rather than to any product.
76+
*/
6877
const connectedCredentials = useMemo(() => {
6978
if (!oauthService) return []
7079
return credentials.filter(
7180
(c) =>
7281
(c.type === 'oauth' || c.type === 'service_account') &&
7382
c.providerId &&
74-
getServiceConfigByProviderId(c.providerId)?.providerId === oauthService.providerId
83+
credentialProviderMatchesService(c.providerId, oauthService)
7584
)
7685
}, [credentials, oauthService])
7786
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
@@ -112,7 +121,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
112121
{
113122
value: CONNECT_MODE.serviceAccount,
114123
label: serviceAccountConnectLabel,
115-
icon: oauthService.serviceIcon,
124+
icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon,
116125
},
117126
]
118127
: []
@@ -170,14 +179,14 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
170179
serviceIcon={oauthService.serviceIcon}
171180
/>
172181
)}
173-
{hasServiceAccount && oauthService?.serviceAccountProviderId && (
182+
{hasServiceAccount && serviceAccountTarget && (
174183
<ConnectServiceAccountModal
175184
open={serviceAccountOpen}
176185
onOpenChange={setServiceAccountOpen}
177186
workspaceId={workspaceId}
178-
serviceAccountProviderId={oauthService.serviceAccountProviderId}
179-
serviceName={oauthService.serviceName}
180-
serviceIcon={oauthService.serviceIcon}
187+
serviceAccountProviderId={serviceAccountTarget.serviceAccountProviderId}
188+
serviceName={serviceAccountTarget.serviceName}
189+
serviceIcon={serviceAccountTarget.serviceIcon}
181190
/>
182191
)}
183192
<div
@@ -219,7 +228,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
219228
{credential.displayName}
220229
</span>
221230
<span className='truncate text-[12px] text-[var(--text-muted)]'>
222-
{credential.description || oauthService?.serviceName}
231+
{credential.description || resolveCredentialDisplay(credential).subtitle}
223232
</span>
224233
</div>
225234
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />

apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
getTokenServiceAccountDescriptor,
2323
type TokenServiceAccountProviderId,
2424
} from '@/lib/credentials/token-service-accounts/descriptors'
25+
import { getServiceAccountCoverageSentence } from '@/lib/integrations/credential-display'
2526
import {
2627
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
2728
SLACK_CUSTOM_BOT_PROVIDER_ID,
@@ -60,6 +61,16 @@ function openDocs(url: string): void {
6061
*/
6162
const ATLASSIAN_DOMAIN_HINT_REGEX = /^[a-z0-9-]+\.atlassian\.net$/i
6263

64+
/**
65+
* States the site-wide reach of the token up front. Users reaching this modal
66+
* from the Jira page were left unsure whether they had connected Jira or Jira
67+
* Service Management; the credential covers both, plus Confluence. Derived from
68+
* the catalog so it cannot drift as Atlassian integrations are added.
69+
*/
70+
const ATLASSIAN_COVERAGE_HINT = getServiceAccountCoverageSentence(
71+
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
72+
)
73+
6374
/**
6475
* Maps server `error.code` values returned by the Atlassian service-account
6576
* route to user-facing messages. Falls back to {@link FALLBACK_ERROR_MESSAGE}
@@ -524,6 +535,7 @@ function AtlassianServiceAccountModal({
524535
? 'Atlassian sites usually look like your-team.atlassian.net.'
525536
: undefined
526537
}
538+
hint={ATLASSIAN_COVERAGE_HINT}
527539
/>
528540

529541
<ChipModalField

apps/sim/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/use-service-account-connect.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ import {
55
getServiceAccountConnectNoun,
66
getServiceAccountGatingBlockType,
77
} from '@/lib/credentials/service-account-provider-ids'
8+
/**
9+
* Imported from the module rather than the `@/lib/integrations` barrel: the
10+
* barrel builds `POPULAR_WORKFLOWS` by calling `getAllBlockMeta()` at module
11+
* load, so importing it from a leaf component drags the whole block registry
12+
* into that component's graph.
13+
*/
14+
import {
15+
getServiceAccountFamilyIcon,
16+
getServiceAccountFamilyName,
17+
} from '@/lib/integrations/credential-display'
818
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
919
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
1020
import { getBlock } from '@/blocks'
@@ -17,6 +27,13 @@ import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
1727
*/
1828
export interface ServiceAccountConnectTarget {
1929
serviceAccountProviderId: ServiceAccountProviderId
30+
/**
31+
* Name the setup surface is titled with. For a family service account this is
32+
* the vendor ("Atlassian"), not the product page you came from — one Atlassian
33+
* token authenticates Jira, Jira Service Management, and Confluence alike, so
34+
* calling it a "Jira service account" is what made users think they had
35+
* connected the wrong product.
36+
*/
2037
serviceName: string
2138
serviceIcon: ComponentType<{ className?: string }>
2239
/**
@@ -71,6 +88,15 @@ export function useServiceAccountConnectTarget({
7188
? 'Set up a custom bot'
7289
: `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}`
7390

74-
return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden }
91+
const familyName = getServiceAccountFamilyName(serviceAccountProviderId)
92+
const familyIcon = getServiceAccountFamilyIcon(serviceAccountProviderId)
93+
94+
return {
95+
serviceAccountProviderId,
96+
serviceName: familyName ?? serviceName,
97+
serviceIcon: familyIcon ?? serviceIcon,
98+
label,
99+
hidden,
100+
}
75101
}, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden])
76102
}

apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx

Lines changed: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ import { createLogger } from '@sim/logger'
1616
import { getErrorMessage } from '@sim/utils/errors'
1717
import { useRouter } from 'next/navigation'
1818
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
19-
import { INTEGRATIONS, resolveOAuthServiceForIntegration } from '@/lib/integrations'
20-
import { getServiceConfigByProviderId } from '@/lib/oauth'
19+
import { resolveCredentialDisplay } from '@/lib/integrations'
2120
import {
2221
AddPeopleModal,
2322
CredentialDetailHeading,
@@ -97,27 +96,18 @@ export function ConnectedCredentialDetail({
9796
[oauthServiceNameByProviderId]
9897
)
9998

100-
const serviceConfig = useMemo(() => {
101-
if (!credential?.providerId) return null
102-
return getServiceConfigByProviderId(credential.providerId)
103-
}, [credential])
104-
10599
/**
106-
* Resolve the integration block type from the credential's OAuth service so
107-
* the header tile can render with the same brand background used by the rows
108-
* on the integrations list page. Several integrations can share one service
109-
* (e.g. Jira and Jira Service Management); the one named after the service
110-
* is preferred since it is the service's canonical integration.
100+
* Service, brand tile, and copy all come from the shared resolver so this
101+
* page, the integrations list, and the Cmd-K search agree on how a credential
102+
* is named and branded — a family service account reads as its family
103+
* ("Atlassian"), not as whichever product the provider walk happened to hit.
111104
*/
112-
const integrationBlockType = useMemo(() => {
113-
if (!serviceConfig) return ''
114-
const candidates = INTEGRATIONS.filter(
115-
(i) => resolveOAuthServiceForIntegration(i)?.providerId === serviceConfig.providerId
116-
)
117-
const serviceName = serviceConfig.name.toLowerCase()
118-
const canonical = candidates.find((i) => i.name.toLowerCase() === serviceName)
119-
return (canonical ?? candidates[0])?.type ?? ''
120-
}, [serviceConfig])
105+
const display = useMemo(
106+
() => (credential ? resolveCredentialDisplay(credential) : null),
107+
[credential]
108+
)
109+
const serviceConfig = display?.service ?? null
110+
const integrationBlockType = display?.blockType ?? ''
121111

122112
const handleReconnectOAuth = async () => {
123113
if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return
@@ -206,7 +196,7 @@ export function ConnectedCredentialDetail({
206196
: handleReconnectOAuth
207197
}
208198
disabled={connectOAuthService.isPending}
209-
leftIcon={serviceConfig?.icon}
199+
leftIcon={display?.icon ?? undefined}
210200
>
211201
Reconnect
212202
</Chip>
@@ -242,19 +232,16 @@ export function ConnectedCredentialDetail({
242232
)
243233
}
244234

245-
const serviceLabel =
246-
serviceConfig?.name || resolveProviderLabel(credential.providerId) || 'Unknown service'
235+
const headingTitle =
236+
display?.detailTitle || resolveProviderLabel(credential.providerId) || 'Unknown service'
247237

248238
return (
249239
<>
250240
<CredentialDetailLayout back={back} actions={actions}>
251241
<CredentialDetailHeading
252242
leading={
253-
serviceConfig ? (
254-
<IntegrationTile
255-
blockType={integrationBlockType}
256-
icon={serviceConfig.icon as ComponentType<{ className?: string }>}
257-
/>
243+
display?.icon ? (
244+
<IntegrationTile blockType={integrationBlockType} icon={display.icon} />
258245
) : (
259246
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
260247
<span className='font-medium text-[var(--text-tertiary)] text-small'>
@@ -263,8 +250,8 @@ export function ConnectedCredentialDetail({
263250
</div>
264251
)
265252
}
266-
title={serviceLabel}
267-
subtitle={serviceConfig?.description || 'Connected service'}
253+
title={headingTitle}
254+
subtitle={display?.detailSubtitle ?? 'Connected service'}
268255
/>
269256

270257
<DetailSection title='Credential ID'>
@@ -335,8 +322,8 @@ export function ConnectedCredentialDetail({
335322
onOpenChange={setReconnectOpen}
336323
workspaceId={workspaceId}
337324
serviceAccountProviderId={credential.providerId as ServiceAccountProviderId}
338-
serviceName={serviceConfig?.name || credential.displayName}
339-
serviceIcon={serviceConfig?.icon as ComponentType<{ className?: string }>}
325+
serviceName={display?.familyName || serviceConfig?.name || credential.displayName}
326+
serviceIcon={display?.icon as ComponentType<{ className?: string }>}
340327
credentialId={credential.id}
341328
credentialDisplayName={credential.displayName}
342329
credentialDescription={credential.description ?? undefined}

apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import {
2020
formatIntegrationType,
2121
INTEGRATIONS,
2222
type Integration,
23+
resolveCredentialDisplay,
2324
} from '@/lib/integrations'
24-
import { getServiceConfigByProviderId } from '@/lib/oauth'
2525
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
2626
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
2727
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
@@ -53,11 +53,6 @@ const FEATURED_INTEGRATIONS: readonly Integration[] = (() => {
5353
)
5454
})()
5555

56-
/** Lookup integration metadata by OAuth service display name (case-insensitive). */
57-
const INTEGRATION_BY_LOWER_NAME: ReadonlyMap<string, Integration> = new Map(
58-
INTEGRATIONS.map((i) => [i.name.toLowerCase(), i])
59-
)
60-
6156
const ALL_CATEGORY_SECTIONS: readonly { label: string; integrations: Integration[] }[] = (() => {
6257
const grouped = new Map<string, Integration[]>()
6358
for (const integration of INTEGRATIONS) {
@@ -105,7 +100,12 @@ interface ConnectedDisplayItem {
105100
credential: WorkspaceCredential
106101
name: string
107102
description: string
108-
serviceName: string
103+
/**
104+
* Extra haystack for the search box: the service name plus every integration
105+
* the credential authenticates, so searching "jira" surfaces an Atlassian
106+
* service account even when the user has replaced its description.
107+
*/
108+
searchText: string
109109
integrationType: string | null
110110
blockType: string
111111
slug: string
@@ -165,20 +165,24 @@ export function Integrations() {
165165

166166
const connectedItems = useMemo<ConnectedDisplayItem[]>(() => {
167167
return oauthCredentials.flatMap((credential) => {
168-
if (!credential.providerId) return []
169-
const service = getServiceConfigByProviderId(credential.providerId)
170-
if (!service) return []
171-
const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
168+
const display = resolveCredentialDisplay(credential)
169+
if (!display.service || !display.icon) return []
172170
return [
173171
{
174172
credential,
175173
name: credential.displayName,
176-
description: credential.description || `${service.name} integration`,
177-
serviceName: service.name,
178-
integrationType: integration?.integrationType ?? null,
179-
blockType: integration?.type ?? '',
180-
slug: integration?.slug ?? '',
181-
icon: service.icon as ComponentType<{ className?: string }>,
174+
description: credential.description || display.subtitle,
175+
searchText: [
176+
display.familyName,
177+
display.service.name,
178+
...display.coveredIntegrations.map((i) => i.name),
179+
]
180+
.filter(Boolean)
181+
.join(' '),
182+
integrationType: display.integration?.integrationType ?? null,
183+
blockType: display.blockType,
184+
slug: display.integration?.slug ?? '',
185+
icon: display.icon,
182186
},
183187
]
184188
})
@@ -264,7 +268,7 @@ export function Integrations() {
264268
return (
265269
item.name.toLowerCase().includes(normalizedSearch) ||
266270
item.description.toLowerCase().includes(normalizedSearch) ||
267-
item.serviceName.toLowerCase().includes(normalizedSearch)
271+
item.searchText.toLowerCase().includes(normalizedSearch)
268272
)
269273
})
270274
}, [

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/integration-search-items.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { ComponentType } from 'react'
2-
import { blockTypeToIconMap, INTEGRATIONS } from '@/lib/integrations'
3-
import { getServiceConfigByProviderId } from '@/lib/oauth'
2+
import { blockTypeToIconMap, INTEGRATIONS, resolveCredentialDisplay } from '@/lib/integrations'
43
import {
54
CONNECT_MODE,
65
CONNECT_QUERY_PARAM,
@@ -11,12 +10,6 @@ import type { WorkspaceCredential } from '@/hooks/queries/credentials'
1110
/** Fallback brand color for credentials whose integration metadata cannot be resolved. */
1211
const FALLBACK_BG_COLOR = '#6B7280'
1312

14-
/**
15-
* Module-level lookup of integration metadata by OAuth service display name
16-
* (case-insensitive). Mirrors the same map in `integrations.tsx`.
17-
*/
18-
const INTEGRATION_BY_LOWER_NAME = new Map(INTEGRATIONS.map((i) => [i.name.toLowerCase(), i]))
19-
2013
/**
2114
* Module-level base array of resolvable integrations (entries without a
2215
* registered icon are dropped, matching the catalog's `if (!Icon) return null`
@@ -76,19 +69,16 @@ export function buildConnectedAccountSearchItems(
7669
): IntegrationSearchItem[] {
7770
return credentials.flatMap((credential) => {
7871
if (credential.type !== 'oauth' && credential.type !== 'service_account') return []
79-
if (!credential.providerId) return []
80-
81-
const service = getServiceConfigByProviderId(credential.providerId)
82-
if (!service) return []
8372

84-
const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
73+
const display = resolveCredentialDisplay(credential)
74+
if (!display.service || !display.icon) return []
8575

8676
return [
8777
{
8878
id: credential.id,
8979
name: credential.displayName,
90-
icon: service.icon as ComponentType<{ className?: string }>,
91-
bgColor: integration?.bgColor ?? FALLBACK_BG_COLOR,
80+
icon: display.icon,
81+
bgColor: display.integration?.bgColor ?? FALLBACK_BG_COLOR,
9282
href: `/workspace/${workspaceId}/integrations/connected/${credential.id}`,
9383
},
9484
]

0 commit comments

Comments
 (0)