mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(integrations): show family service accounts on every product they authenticate (#6102)
* 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.
* chore(icons): use Atlassian's gradient marks for Jira and Confluence
Replaces the flat #1868DB Jira and Confluence marks with Atlassian's gradient
versions, matching the Atlassian mark added alongside them.
- gradient ids go through `useId()` rather than the source SVGs' static ids,
which would collide wherever two of these icons render on one page — the
integrations list and the landing loops both do
- pads the Atlassian viewBox so its artwork fills ~78% of the box, matching the
inset Atlassian ships on the Jira and Confluence marks; without it the mark
renders ~30% heavier than its siblings in the same tile
Visual-only, but these marks render in ~60 files, so it is split from the
credential fix to stay independently revertable.
* fix(integrations): route the editor's service-account setup modal through the shared target
The workflow editor's credential selector passed the OAuth service's own name
and icon straight to ConnectServiceAccountModal, so opening the setup form from
a Jira block titled it "Add Jira service account" while the integrations page
and the chat — both of which already resolve through
`useServiceAccountConnectTarget` — titled the same form "Add Atlassian service
account".
That is the exact confusion this branch set out to remove, surviving on the one
surface that bypassed the shared resolver.
* docs(atlassian): correct the service-account setup path and cover all three products
The setup section could not be followed. It sent readers to a "Settings →
Integrations tab" that does not exist (Integrations is a top-level workspace
module) and told them to search the integrations list for "Atlassian Service
Account", which matches no catalog entry — the catalog lists Jira, Jira Service
Management, and Confluence.
The page also described the credential as covering "Jira and Confluence" while
listing Jira Service Management scopes, and the product now spells the coverage
out in the connect form.
- correct the path: Integrations -> Jira/JSM/Confluence -> Add to Sim -> Add
service account
- name all three products consistently, and state that one service account
covers them
- match the real button label ("Add service account")
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Atlassian Service Accounts
|
||||
description: Set up an Atlassian service account with a scoped API token to use Jira and Confluence in Sim workflows
|
||||
description: Set up an Atlassian service account with a scoped API token to use Jira, Jira Service Management, and Confluence in Sim workflows
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout'
|
||||
@@ -8,9 +8,11 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'
|
||||
import { Image } from '@/components/ui/image'
|
||||
import { FAQ } from '@/components/ui/faq'
|
||||
|
||||
Atlassian service accounts let your workflows authenticate to Jira and Confluence as a non-human bot user — independent of any individual employee's account. Each service account has its own email, its own permissions, and its own API tokens, all managed centrally in admin.atlassian.com.
|
||||
Atlassian service accounts let your workflows authenticate to Jira, Jira Service Management, and Confluence as a non-human bot user — independent of any individual employee's account. Each service account has its own email, its own permissions, and its own API tokens, all managed centrally in admin.atlassian.com.
|
||||
|
||||
This is the recommended way to use Jira and Confluence in production workflows: no one person's OAuth consent expires, the bot's permissions are auditable, and access can be revoked without touching anyone's personal account.
|
||||
This is the recommended way to use Atlassian products in production workflows: no one person's OAuth consent expires, the bot's permissions are auditable, and access can be revoked without touching anyone's personal account.
|
||||
|
||||
One service account covers all three products. You add it once, and it appears as a connected credential on the Jira, Jira Service Management, and Confluence integration pages alike — there is no separate credential to create per product.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -124,12 +126,17 @@ Your Atlassian site domain is the URL you use to access Jira or Confluence in yo
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Open your workspace **Settings** and go to the **Integrations** tab
|
||||
Open **Integrations** in your workspace sidebar
|
||||
</Step>
|
||||
<Step>
|
||||
Search for "Atlassian Service Account" and click it
|
||||
Open **Jira**, **Jira Service Management**, or **Confluence** — any of the three works, since they share one service account
|
||||
|
||||
{/* TODO(screenshot): Integrations page with "Atlassian Service Account" in the service list */}
|
||||
{/* TODO(screenshot): Integrations page with Jira in the list */}
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Add to Sim** and choose **Add service account**
|
||||
|
||||
{/* TODO(screenshot): Jira integration page with the "Add to Sim" dropdown open */}
|
||||
</Step>
|
||||
<Step>
|
||||
Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description
|
||||
@@ -145,15 +152,17 @@ Your Atlassian site domain is the URL you use to access Jira or Confluence in yo
|
||||
</div>
|
||||
</Step>
|
||||
<Step>
|
||||
Click **Add Service Account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong.
|
||||
Click **Add service account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The token, domain, and discovered cloudId are encrypted before being stored.
|
||||
|
||||
Once added, the credential is listed under **Connected** on all three Atlassian integration pages. It is named after the service account's own Atlassian display name, so several service accounts on the same site stay easy to tell apart.
|
||||
|
||||
## Using the Service Account in Workflows
|
||||
|
||||
Add a Jira or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
|
||||
Add a Jira, Jira Service Management, or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
@@ -170,7 +179,7 @@ The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/..
|
||||
<FAQ items={[
|
||||
{ question: "Why an API token instead of OAuth?", answer: "API tokens for service accounts don't have a 1-hour expiry and don't require any user to consent. They're issued by an org admin and are stable until you revoke them — which is what you want for an automated workflow." },
|
||||
{ question: "Can a regular user create a service account?", answer: "No. Service accounts are an Atlassian organization-level feature and only an organization admin can create them." },
|
||||
{ question: "Can the same service account work with both Jira and Confluence?", answer: "Yes — give the service account access to both products on your site, and include scopes for both when you create the API token. Then connect it once in Sim and use it from either Jira or Confluence blocks." },
|
||||
{ question: "Can the same service account work with Jira, Jira Service Management, and Confluence?", answer: "Yes — one service account covers all three. Give it access to each product you need on your site, include scopes for each when you create the API token, then connect it once in Sim. It appears as a connected credential on all three integration pages and can be selected from any of their blocks." },
|
||||
{ question: "What if my workflow needs different permissions than the token has?", answer: "Either widen the token's scopes (revoke it and create a new one with more scopes), or grant the service account higher project/space roles in Jira or Confluence. Scope failures look like 401/403 errors with descriptive messages." },
|
||||
{ question: "How do I rotate the API token?", answer: "Create a new token from the same service account in admin.atlassian.com, update the credential in Sim with the new token, and once it's working, revoke the old one." },
|
||||
{ question: "Does this work with Atlassian Data Center / on-prem?", answer: "No — this integration uses Atlassian Cloud's API gateway (`api.atlassian.com`). For Data Center, use the OAuth flow or set up a self-hosted bot user." },
|
||||
|
||||
+17
-8
@@ -9,9 +9,10 @@ import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
blockTypeToIconMap,
|
||||
type Integration,
|
||||
resolveCredentialDisplay,
|
||||
resolveOAuthServiceForIntegration,
|
||||
} from '@/lib/integrations'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
import { credentialProviderMatchesService } from '@/lib/oauth'
|
||||
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
|
||||
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
|
||||
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
|
||||
@@ -65,13 +66,21 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
|
||||
|
||||
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
|
||||
|
||||
/**
|
||||
* Credentials that authenticate this integration. Matching goes through
|
||||
* `credentialProviderMatchesService` so a family service account lists on
|
||||
* every product it powers — one Atlassian token covers Jira, Jira Service
|
||||
* Management, and Confluence. Comparing resolved `providerId`s instead would
|
||||
* hide it from all three, since `atlassian-service-account` resolves to its
|
||||
* own pseudo-service rather than to any product.
|
||||
*/
|
||||
const connectedCredentials = useMemo(() => {
|
||||
if (!oauthService) return []
|
||||
return credentials.filter(
|
||||
(c) =>
|
||||
(c.type === 'oauth' || c.type === 'service_account') &&
|
||||
c.providerId &&
|
||||
getServiceConfigByProviderId(c.providerId)?.providerId === oauthService.providerId
|
||||
credentialProviderMatchesService(c.providerId, oauthService)
|
||||
)
|
||||
}, [credentials, oauthService])
|
||||
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
|
||||
@@ -112,7 +121,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
|
||||
{
|
||||
value: CONNECT_MODE.serviceAccount,
|
||||
label: serviceAccountConnectLabel,
|
||||
icon: oauthService.serviceIcon,
|
||||
icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon,
|
||||
},
|
||||
]
|
||||
: []
|
||||
@@ -170,14 +179,14 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
|
||||
serviceIcon={oauthService.serviceIcon}
|
||||
/>
|
||||
)}
|
||||
{hasServiceAccount && oauthService?.serviceAccountProviderId && (
|
||||
{hasServiceAccount && serviceAccountTarget && (
|
||||
<ConnectServiceAccountModal
|
||||
open={serviceAccountOpen}
|
||||
onOpenChange={setServiceAccountOpen}
|
||||
workspaceId={workspaceId}
|
||||
serviceAccountProviderId={oauthService.serviceAccountProviderId}
|
||||
serviceName={oauthService.serviceName}
|
||||
serviceIcon={oauthService.serviceIcon}
|
||||
serviceAccountProviderId={serviceAccountTarget.serviceAccountProviderId}
|
||||
serviceName={serviceAccountTarget.serviceName}
|
||||
serviceIcon={serviceAccountTarget.serviceIcon}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
@@ -219,7 +228,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
|
||||
{credential.displayName}
|
||||
</span>
|
||||
<span className='truncate text-[12px] text-[var(--text-muted)]'>
|
||||
{credential.description || oauthService?.serviceName}
|
||||
{credential.description || resolveCredentialDisplay(credential).subtitle}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
|
||||
|
||||
+12
@@ -22,6 +22,7 @@ import {
|
||||
getTokenServiceAccountDescriptor,
|
||||
type TokenServiceAccountProviderId,
|
||||
} from '@/lib/credentials/token-service-accounts/descriptors'
|
||||
import { getServiceAccountCoverageSentence } from '@/lib/integrations/credential-display'
|
||||
import {
|
||||
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
|
||||
SLACK_CUSTOM_BOT_PROVIDER_ID,
|
||||
@@ -60,6 +61,16 @@ function openDocs(url: string): void {
|
||||
*/
|
||||
const ATLASSIAN_DOMAIN_HINT_REGEX = /^[a-z0-9-]+\.atlassian\.net$/i
|
||||
|
||||
/**
|
||||
* States the site-wide reach of the token up front. Users reaching this modal
|
||||
* from the Jira page were left unsure whether they had connected Jira or Jira
|
||||
* Service Management; the credential covers both, plus Confluence. Derived from
|
||||
* the catalog so it cannot drift as Atlassian integrations are added.
|
||||
*/
|
||||
const ATLASSIAN_COVERAGE_HINT = getServiceAccountCoverageSentence(
|
||||
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
|
||||
)
|
||||
|
||||
/**
|
||||
* Maps server `error.code` values returned by the Atlassian service-account
|
||||
* route to user-facing messages. Falls back to {@link FALLBACK_ERROR_MESSAGE}
|
||||
@@ -524,6 +535,7 @@ function AtlassianServiceAccountModal({
|
||||
? 'Atlassian sites usually look like your-team.atlassian.net.'
|
||||
: undefined
|
||||
}
|
||||
hint={ATLASSIAN_COVERAGE_HINT}
|
||||
/>
|
||||
|
||||
<ChipModalField
|
||||
|
||||
+27
-1
@@ -5,6 +5,16 @@ import {
|
||||
getServiceAccountConnectNoun,
|
||||
getServiceAccountGatingBlockType,
|
||||
} from '@/lib/credentials/service-account-provider-ids'
|
||||
/**
|
||||
* Imported from the module rather than the `@/lib/integrations` barrel: the
|
||||
* barrel builds `POPULAR_WORKFLOWS` by calling `getAllBlockMeta()` at module
|
||||
* load, so importing it from a leaf component drags the whole block registry
|
||||
* into that component's graph.
|
||||
*/
|
||||
import {
|
||||
getServiceAccountFamilyIcon,
|
||||
getServiceAccountFamilyName,
|
||||
} from '@/lib/integrations/credential-display'
|
||||
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
|
||||
import type { ServiceAccountProviderId } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
|
||||
import { getBlock } from '@/blocks'
|
||||
@@ -17,6 +27,13 @@ import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
|
||||
*/
|
||||
export interface ServiceAccountConnectTarget {
|
||||
serviceAccountProviderId: ServiceAccountProviderId
|
||||
/**
|
||||
* Name the setup surface is titled with. For a family service account this is
|
||||
* the vendor ("Atlassian"), not the product page you came from — one Atlassian
|
||||
* token authenticates Jira, Jira Service Management, and Confluence alike, so
|
||||
* calling it a "Jira service account" is what made users think they had
|
||||
* connected the wrong product.
|
||||
*/
|
||||
serviceName: string
|
||||
serviceIcon: ComponentType<{ className?: string }>
|
||||
/**
|
||||
@@ -71,6 +88,15 @@ export function useServiceAccountConnectTarget({
|
||||
? 'Set up a custom bot'
|
||||
: `Add ${getServiceAccountConnectNoun(serviceAccountProviderId)}`
|
||||
|
||||
return { serviceAccountProviderId, serviceName, serviceIcon, label, hidden }
|
||||
const familyName = getServiceAccountFamilyName(serviceAccountProviderId)
|
||||
const familyIcon = getServiceAccountFamilyIcon(serviceAccountProviderId)
|
||||
|
||||
return {
|
||||
serviceAccountProviderId,
|
||||
serviceName: familyName ?? serviceName,
|
||||
serviceIcon: familyIcon ?? serviceIcon,
|
||||
label,
|
||||
hidden,
|
||||
}
|
||||
}, [serviceAccountProviderId, serviceName, serviceIcon, isSlackBot, hidden])
|
||||
}
|
||||
|
||||
+20
-33
@@ -16,8 +16,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { INTEGRATIONS, resolveOAuthServiceForIntegration } from '@/lib/integrations'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
import { resolveCredentialDisplay } from '@/lib/integrations'
|
||||
import {
|
||||
AddPeopleModal,
|
||||
CredentialDetailHeading,
|
||||
@@ -97,27 +96,18 @@ export function ConnectedCredentialDetail({
|
||||
[oauthServiceNameByProviderId]
|
||||
)
|
||||
|
||||
const serviceConfig = useMemo(() => {
|
||||
if (!credential?.providerId) return null
|
||||
return getServiceConfigByProviderId(credential.providerId)
|
||||
}, [credential])
|
||||
|
||||
/**
|
||||
* Resolve the integration block type from the credential's OAuth service so
|
||||
* the header tile can render with the same brand background used by the rows
|
||||
* on the integrations list page. Several integrations can share one service
|
||||
* (e.g. Jira and Jira Service Management); the one named after the service
|
||||
* is preferred since it is the service's canonical integration.
|
||||
* Service, brand tile, and copy all come from the shared resolver so this
|
||||
* page, the integrations list, and the Cmd-K search agree on how a credential
|
||||
* is named and branded — a family service account reads as its family
|
||||
* ("Atlassian"), not as whichever product the provider walk happened to hit.
|
||||
*/
|
||||
const integrationBlockType = useMemo(() => {
|
||||
if (!serviceConfig) return ''
|
||||
const candidates = INTEGRATIONS.filter(
|
||||
(i) => resolveOAuthServiceForIntegration(i)?.providerId === serviceConfig.providerId
|
||||
)
|
||||
const serviceName = serviceConfig.name.toLowerCase()
|
||||
const canonical = candidates.find((i) => i.name.toLowerCase() === serviceName)
|
||||
return (canonical ?? candidates[0])?.type ?? ''
|
||||
}, [serviceConfig])
|
||||
const display = useMemo(
|
||||
() => (credential ? resolveCredentialDisplay(credential) : null),
|
||||
[credential]
|
||||
)
|
||||
const serviceConfig = display?.service ?? null
|
||||
const integrationBlockType = display?.blockType ?? ''
|
||||
|
||||
const handleReconnectOAuth = async () => {
|
||||
if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return
|
||||
@@ -206,7 +196,7 @@ export function ConnectedCredentialDetail({
|
||||
: handleReconnectOAuth
|
||||
}
|
||||
disabled={connectOAuthService.isPending}
|
||||
leftIcon={serviceConfig?.icon}
|
||||
leftIcon={display?.icon ?? undefined}
|
||||
>
|
||||
Reconnect
|
||||
</Chip>
|
||||
@@ -242,19 +232,16 @@ export function ConnectedCredentialDetail({
|
||||
)
|
||||
}
|
||||
|
||||
const serviceLabel =
|
||||
serviceConfig?.name || resolveProviderLabel(credential.providerId) || 'Unknown service'
|
||||
const headingTitle =
|
||||
display?.detailTitle || resolveProviderLabel(credential.providerId) || 'Unknown service'
|
||||
|
||||
return (
|
||||
<>
|
||||
<CredentialDetailLayout back={back} actions={actions}>
|
||||
<CredentialDetailHeading
|
||||
leading={
|
||||
serviceConfig ? (
|
||||
<IntegrationTile
|
||||
blockType={integrationBlockType}
|
||||
icon={serviceConfig.icon as ComponentType<{ className?: string }>}
|
||||
/>
|
||||
display?.icon ? (
|
||||
<IntegrationTile blockType={integrationBlockType} icon={display.icon} />
|
||||
) : (
|
||||
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
|
||||
<span className='font-medium text-[var(--text-tertiary)] text-small'>
|
||||
@@ -263,8 +250,8 @@ export function ConnectedCredentialDetail({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
title={serviceLabel}
|
||||
subtitle={serviceConfig?.description || 'Connected service'}
|
||||
title={headingTitle}
|
||||
subtitle={display?.detailSubtitle ?? 'Connected service'}
|
||||
/>
|
||||
|
||||
<DetailSection title='Credential ID'>
|
||||
@@ -335,8 +322,8 @@ export function ConnectedCredentialDetail({
|
||||
onOpenChange={setReconnectOpen}
|
||||
workspaceId={workspaceId}
|
||||
serviceAccountProviderId={credential.providerId as ServiceAccountProviderId}
|
||||
serviceName={serviceConfig?.name || credential.displayName}
|
||||
serviceIcon={serviceConfig?.icon as ComponentType<{ className?: string }>}
|
||||
serviceName={display?.familyName || serviceConfig?.name || credential.displayName}
|
||||
serviceIcon={display?.icon as ComponentType<{ className?: string }>}
|
||||
credentialId={credential.id}
|
||||
credentialDisplayName={credential.displayName}
|
||||
credentialDescription={credential.description ?? undefined}
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
formatIntegrationType,
|
||||
INTEGRATIONS,
|
||||
type Integration,
|
||||
resolveCredentialDisplay,
|
||||
} from '@/lib/integrations'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
|
||||
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
|
||||
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
|
||||
@@ -53,11 +53,6 @@ const FEATURED_INTEGRATIONS: readonly Integration[] = (() => {
|
||||
)
|
||||
})()
|
||||
|
||||
/** Lookup integration metadata by OAuth service display name (case-insensitive). */
|
||||
const INTEGRATION_BY_LOWER_NAME: ReadonlyMap<string, Integration> = new Map(
|
||||
INTEGRATIONS.map((i) => [i.name.toLowerCase(), i])
|
||||
)
|
||||
|
||||
const ALL_CATEGORY_SECTIONS: readonly { label: string; integrations: Integration[] }[] = (() => {
|
||||
const grouped = new Map<string, Integration[]>()
|
||||
for (const integration of INTEGRATIONS) {
|
||||
@@ -105,7 +100,12 @@ interface ConnectedDisplayItem {
|
||||
credential: WorkspaceCredential
|
||||
name: string
|
||||
description: string
|
||||
serviceName: string
|
||||
/**
|
||||
* Extra haystack for the search box: the service name plus every integration
|
||||
* the credential authenticates, so searching "jira" surfaces an Atlassian
|
||||
* service account even when the user has replaced its description.
|
||||
*/
|
||||
searchText: string
|
||||
integrationType: string | null
|
||||
blockType: string
|
||||
slug: string
|
||||
@@ -165,20 +165,24 @@ export function Integrations() {
|
||||
|
||||
const connectedItems = useMemo<ConnectedDisplayItem[]>(() => {
|
||||
return oauthCredentials.flatMap((credential) => {
|
||||
if (!credential.providerId) return []
|
||||
const service = getServiceConfigByProviderId(credential.providerId)
|
||||
if (!service) return []
|
||||
const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
|
||||
const display = resolveCredentialDisplay(credential)
|
||||
if (!display.service || !display.icon) return []
|
||||
return [
|
||||
{
|
||||
credential,
|
||||
name: credential.displayName,
|
||||
description: credential.description || `${service.name} integration`,
|
||||
serviceName: service.name,
|
||||
integrationType: integration?.integrationType ?? null,
|
||||
blockType: integration?.type ?? '',
|
||||
slug: integration?.slug ?? '',
|
||||
icon: service.icon as ComponentType<{ className?: string }>,
|
||||
description: credential.description || display.subtitle,
|
||||
searchText: [
|
||||
display.familyName,
|
||||
display.service.name,
|
||||
...display.coveredIntegrations.map((i) => i.name),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
integrationType: display.integration?.integrationType ?? null,
|
||||
blockType: display.blockType,
|
||||
slug: display.integration?.slug ?? '',
|
||||
icon: display.icon,
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -264,7 +268,7 @@ export function Integrations() {
|
||||
return (
|
||||
item.name.toLowerCase().includes(normalizedSearch) ||
|
||||
item.description.toLowerCase().includes(normalizedSearch) ||
|
||||
item.serviceName.toLowerCase().includes(normalizedSearch)
|
||||
item.searchText.toLowerCase().includes(normalizedSearch)
|
||||
)
|
||||
})
|
||||
}, [
|
||||
|
||||
+4
-6
@@ -511,16 +511,14 @@ export function CredentialSelector({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSetupModal && serviceAccountService?.serviceAccountProviderId && (
|
||||
{showSetupModal && serviceAccountTarget && (
|
||||
<ConnectServiceAccountModal
|
||||
open={showSetupModal}
|
||||
onOpenChange={setShowSetupModal}
|
||||
workspaceId={workspaceId}
|
||||
serviceAccountProviderId={
|
||||
serviceAccountService.serviceAccountProviderId as ServiceAccountProviderId
|
||||
}
|
||||
serviceName={serviceAccountService.name}
|
||||
serviceIcon={serviceAccountService.icon}
|
||||
serviceAccountProviderId={serviceAccountTarget.serviceAccountProviderId}
|
||||
serviceName={serviceAccountTarget.serviceName}
|
||||
serviceIcon={serviceAccountTarget.serviceIcon}
|
||||
onCreated={(newCredentialId) => {
|
||||
setStoreValue(newCredentialId)
|
||||
refetchCredentials()
|
||||
|
||||
+5
-15
@@ -1,6 +1,5 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { blockTypeToIconMap, INTEGRATIONS } from '@/lib/integrations'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
import { blockTypeToIconMap, INTEGRATIONS, resolveCredentialDisplay } from '@/lib/integrations'
|
||||
import {
|
||||
CONNECT_MODE,
|
||||
CONNECT_QUERY_PARAM,
|
||||
@@ -11,12 +10,6 @@ import type { WorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
/** Fallback brand color for credentials whose integration metadata cannot be resolved. */
|
||||
const FALLBACK_BG_COLOR = '#6B7280'
|
||||
|
||||
/**
|
||||
* Module-level lookup of integration metadata by OAuth service display name
|
||||
* (case-insensitive). Mirrors the same map in `integrations.tsx`.
|
||||
*/
|
||||
const INTEGRATION_BY_LOWER_NAME = new Map(INTEGRATIONS.map((i) => [i.name.toLowerCase(), i]))
|
||||
|
||||
/**
|
||||
* Module-level base array of resolvable integrations (entries without a
|
||||
* registered icon are dropped, matching the catalog's `if (!Icon) return null`
|
||||
@@ -76,19 +69,16 @@ export function buildConnectedAccountSearchItems(
|
||||
): IntegrationSearchItem[] {
|
||||
return credentials.flatMap((credential) => {
|
||||
if (credential.type !== 'oauth' && credential.type !== 'service_account') return []
|
||||
if (!credential.providerId) return []
|
||||
|
||||
const service = getServiceConfigByProviderId(credential.providerId)
|
||||
if (!service) return []
|
||||
|
||||
const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
|
||||
const display = resolveCredentialDisplay(credential)
|
||||
if (!display.service || !display.icon) return []
|
||||
|
||||
return [
|
||||
{
|
||||
id: credential.id,
|
||||
name: credential.displayName,
|
||||
icon: service.icon as ComponentType<{ className?: string }>,
|
||||
bgColor: integration?.bgColor ?? FALLBACK_BG_COLOR,
|
||||
icon: display.icon,
|
||||
bgColor: display.integration?.bgColor ?? FALLBACK_BG_COLOR,
|
||||
href: `/workspace/${workspaceId}/integrations/connected/${credential.id}`,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2221,21 +2221,108 @@ export function EyeIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfluenceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
/**
|
||||
* Corporate Atlassian mark, used for family-wide Atlassian credentials — one
|
||||
* API token authenticates Jira, Jira Service Management, and Confluence, so no
|
||||
* single product mark represents it. Individual products keep their own icons.
|
||||
*/
|
||||
export function AtlassianIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const gradientId = `atlassian_gradient_${id}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 3 21 24'
|
||||
/*
|
||||
* The mark's artwork spans ~66 units; the box is padded to 84.5 so it
|
||||
* fills ~78% of its viewBox, matching the inset Atlassian ships on the
|
||||
* Jira and Confluence marks (artwork 16→116 inside 128). Without the
|
||||
* padding this renders ~30% heavier than its siblings in the same tile.
|
||||
*/
|
||||
viewBox='-9.2 -8.9 84.5 84.5'
|
||||
focusable='false'
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='28.536019'
|
||||
y1='35.528544'
|
||||
x2='11.406018'
|
||||
y2='65.208544'
|
||||
>
|
||||
<stop offset='0' stopColor='#0052cc' />
|
||||
<stop offset='0.92' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill='#1868DB'
|
||||
d='M20.6 20.23c-6.58-3.18-8.51-3.66-11.28-3.66-3.25 0-6.03 1.36-8.51 5.16l-.407.62c-.333.51-.407.7-.407.92s.111.4.518.66l4.18 2.6c.221.15.406.22.59.22.22 0 .37-.11.59-.44l.666-1.02c1.03-1.57 1.96-2.09 3.14-2.09 1.03 0 2.26.293 3.77 1.02l4.37 2.05c.444.22.93.11 1.15-.403l2.07-4.54c.222-.512.07-.842-.444-1.1M1.41 12.22c6.58 3.18 8.51 3.66 11.28 3.66 3.26 0 6.03-1.35 8.51-5.16l.407-.622c.332-.512.41-.695.41-.915s-.11-.402-.518-.658L17.31 5.93c-.222-.147-.407-.22-.592-.22-.222 0-.37.11-.592.44l-.665 1.02c-1.04 1.57-1.96 2.09-3.14 2.09-1.04 0-2.26-.293-3.77-1.02L4.18 6.18c-.444-.22-.925-.11-1.15.402L.962 11.12c-.222.51-.74.84.444 1.1'
|
||||
fill={`url(#${gradientId})`}
|
||||
d='m 19.636018,30.518546 a 1.88,1.88 0 0 0 -3.2,0.35 l -16.2299998,32.46 a 1.94,1.94 0 0 0 1.73,2.81 H 24.536018 a 1.87,1.87 0 0 0 1.74,-1.1 c 4.87,-10 1.92,-25.37 -6.64,-34.52 z'
|
||||
/>
|
||||
<path
|
||||
fill='#2684ff'
|
||||
d='m 31.546018,1.038546 a 42.81,42.81 0 0 0 -2.5,42.27 l 10.95,21.73 a 1.94,1.94 0 0 0 1.73,1.08 h 22.6 a 2,2 0 0 0 1.67,-2.79 l -31.15,-62.29 a 1.83,1.83 0 0 0 -3.3,0 z'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfluenceIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const topGradientId = `confluence_top_${id}`
|
||||
const bottomGradientId = `confluence_bottom_${id}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='0 0 128 128'
|
||||
focusable='false'
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={bottomGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='26.791'
|
||||
y1='28.467'
|
||||
x2='11.792'
|
||||
y2='19.855'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0' stopColor='#0052cc' />
|
||||
<stop offset='0.918' stopColor='#2380fb' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={topGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='5.209'
|
||||
y1='2.523'
|
||||
x2='20.208'
|
||||
y2='11.136'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0' stopColor='#0052cc' />
|
||||
<stop offset='0.918' stopColor='#2380fb' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill={`url(#${bottomGradientId})`}
|
||||
d='M19.492 86.227a249.047 249.047 0 00-3.047 4.933c-.867 1.45-.433 3.336 1.016 4.207l19.863 12.188c1.45.87 3.332.433 4.203-1.016a139.349 139.349 0 012.899-4.934c7.832-12.91 15.804-11.46 30.011-4.64l19.72 9.281c1.593.727 3.335 0 4.058-1.45l9.426-21.323c.722-1.453 0-3.336-1.454-4.063-4.203-1.887-12.464-5.805-19.714-9.43-26.82-12.914-49.586-12.043-66.98 16.247zm0 0'
|
||||
/>
|
||||
<path
|
||||
fill={`url(#${topGradientId})`}
|
||||
d='M108.508 37.773a249.047 249.047 0 003.047-4.933c.87-1.45.433-3.336-1.016-4.207L90.676 16.445c-1.45-.87-3.332-.433-4.203 1.016a133.55 133.55 0 01-2.899 4.934c-7.832 12.91-15.804 11.46-30.011 4.64l-19.72-9.281c-1.593-.727-3.331 0-4.058 1.45l-9.422 21.323c-.726 1.453 0 3.34 1.45 4.063 4.203 1.887 12.468 5.805 19.714 9.43 26.825 12.77 49.586 12.042 66.98-16.247zm0 0'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -2768,19 +2855,58 @@ export function LinkupIcon(props: SVGProps<SVGSVGElement>) {
|
||||
}
|
||||
|
||||
export function JiraIcon(props: SVGProps<SVGSVGElement>) {
|
||||
const id = useId()
|
||||
const middleGradientId = `jira_middle_${id}`
|
||||
const bottomGradientId = `jira_bottom_${id}`
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 30 30'
|
||||
viewBox='0 0 128 128'
|
||||
width='24'
|
||||
height='24'
|
||||
focusable='false'
|
||||
fill='none'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={middleGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='22.034'
|
||||
y1='9.773'
|
||||
x2='17.118'
|
||||
y2='14.842'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0.176' stopColor='#0052cc' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={bottomGradientId}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1='16.641'
|
||||
y1='15.564'
|
||||
x2='10.957'
|
||||
y2='21.094'
|
||||
gradientTransform='scale(4)'
|
||||
>
|
||||
<stop offset='0.176' stopColor='#0052cc' />
|
||||
<stop offset='1' stopColor='#2684ff' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill='#1868DB'
|
||||
d='M11.03 21.99h-2.22c-3.35 0-5.75-2.05-5.75-5.05h11.93c.619 0 1.02.44 1.02 1.06v12.01c-2.98 0-4.98-2.42-4.98-5.78zm5.89-5.97h-2.22c-3.35 0-5.75-2.01-5.75-5.01h11.93c.618 0 1.06.402 1.06 1.02V24.04c-2.98 0-5.02-2.42-5.02-5.78zm5.93-5.93h-2.22c-3.35 0-5.75-2.05-5.75-5.05h11.93c.618 0 1.02.439 1.02 1.02v12.01c-2.98 0-4.98-2.42-4.98-5.78z'
|
||||
fill='#2684ff'
|
||||
d='M108.023 16H61.805c0 11.52 9.324 20.848 20.847 20.848h8.5v8.226c0 11.52 9.328 20.848 20.848 20.848V19.977A3.98 3.98 0 00108.023 16zm0 0'
|
||||
/>
|
||||
<path
|
||||
fill={`url(#${middleGradientId})`}
|
||||
d='M85.121 39.04H38.902c0 11.519 9.325 20.847 20.844 20.847h8.504v8.226c0 11.52 9.328 20.848 20.848 20.848V43.016a3.983 3.983 0 00-3.977-3.977zm0 0'
|
||||
/>
|
||||
<path
|
||||
fill={`url(#${bottomGradientId})`}
|
||||
d='M62.219 62.078H16c0 11.524 9.324 20.848 20.848 20.848h8.5v8.23c0 11.52 9.328 20.844 20.847 20.844V66.059a3.984 3.984 0 00-3.976-3.98zm0 0'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getIntegrationsForCredentialProvider,
|
||||
getServiceAccountCoverageSentence,
|
||||
getServiceAccountFamilyName,
|
||||
isFamilyServiceAccount,
|
||||
resolveCredentialDisplay,
|
||||
} from '@/lib/integrations/credential-display'
|
||||
import integrationsJson from '@/lib/integrations/integrations.json'
|
||||
import { resolveOAuthServiceForIntegration } from '@/lib/integrations/oauth-service'
|
||||
import type { Integration } from '@/lib/integrations/types'
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
|
||||
import { credentialProviderMatchesService } from '@/lib/oauth/utils'
|
||||
|
||||
const INTEGRATIONS = integrationsJson.integrations as readonly Integration[]
|
||||
|
||||
/**
|
||||
* Every catalog integration each service-account provider id authenticates.
|
||||
*
|
||||
* This table is the regression guard for the family-credential fix. Credential
|
||||
* display resolves through `OAUTH_PROVIDERS`, which is walked in declaration
|
||||
* order — reordering it, or adding a provider that claims an existing
|
||||
* service-account id, silently changes which product pages a credential appears
|
||||
* on. Two entries here are the fix itself:
|
||||
*
|
||||
* - `atlassian-service-account` previously matched **nothing** (it resolves to
|
||||
* the `Atlassian Service Account` pseudo-service, whose providerId equals no
|
||||
* product's), so a service account added from the Jira page vanished from
|
||||
* Jira, Jira Service Management, and Confluence alike.
|
||||
* - `google-service-account` previously matched Gmail only, because Gmail is the
|
||||
* first Google service declared.
|
||||
*
|
||||
* Every other row must stay exactly as it was before the fix.
|
||||
*/
|
||||
const EXPECTED_COVERAGE: Record<string, string[]> = {
|
||||
'airtable-service-account': ['airtable'],
|
||||
'asana-service-account': ['asana'],
|
||||
'atlassian-service-account': ['confluence', 'jira', 'jira-service-management'],
|
||||
'attio-service-account': ['attio'],
|
||||
'box-service-account': ['box'],
|
||||
'calcom-service-account': ['cal-com'],
|
||||
'claude-platform-service-account': [],
|
||||
'clickup-service-account': ['clickup'],
|
||||
'google-service-account': [
|
||||
'gmail',
|
||||
'google-bigquery',
|
||||
'google-calendar',
|
||||
'google-contacts',
|
||||
'google-docs',
|
||||
'google-drive',
|
||||
'google-forms',
|
||||
'google-groups',
|
||||
'google-meet',
|
||||
'google-sheets',
|
||||
'google-slides',
|
||||
'google-tasks',
|
||||
'google-vault',
|
||||
],
|
||||
'hubspot-service-account': ['hubspot'],
|
||||
'linear-service-account': ['linear'],
|
||||
'monday-service-account': ['monday'],
|
||||
'notion-service-account': ['notion'],
|
||||
'pipedrive-service-account': ['pipedrive'],
|
||||
'salesforce-service-account': ['salesforce'],
|
||||
'shopify-service-account': ['shopify'],
|
||||
'slack-custom-bot': ['slack'],
|
||||
'trello-service-account': ['trello'],
|
||||
'wealthbox-service-account': ['wealthbox'],
|
||||
'webflow-service-account': ['webflow'],
|
||||
'zoom-service-account': ['zoom'],
|
||||
}
|
||||
|
||||
/** Every provider id some service designates as its service-account id. */
|
||||
const REGISTERED_SERVICE_ACCOUNT_IDS = [
|
||||
...new Set(
|
||||
Object.values(OAUTH_PROVIDERS).flatMap((provider) =>
|
||||
Object.values(provider.services).flatMap((service) =>
|
||||
service.serviceAccountProviderId ? [service.serviceAccountProviderId] : []
|
||||
)
|
||||
)
|
||||
),
|
||||
].sort()
|
||||
|
||||
const serviceAccount = (providerId: string) => ({
|
||||
type: 'service_account',
|
||||
displayName: 'Automation Bot',
|
||||
providerId,
|
||||
})
|
||||
|
||||
describe('service-account coverage', () => {
|
||||
it('pins the table to exactly the registered service-account provider ids', () => {
|
||||
expect(REGISTERED_SERVICE_ACCOUNT_IDS).toEqual(Object.keys(EXPECTED_COVERAGE).sort())
|
||||
})
|
||||
|
||||
it.each(Object.entries(EXPECTED_COVERAGE))(
|
||||
'%s authenticates the expected integrations',
|
||||
(providerId, expectedSlugs) => {
|
||||
const slugs = getIntegrationsForCredentialProvider(providerId)
|
||||
.map((i) => i.slug)
|
||||
.sort()
|
||||
expect(slugs).toEqual([...expectedSlugs].sort())
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The integration detail page filters its "Connected" list with the
|
||||
* predicate, not with this index, so the two must not drift. Without this
|
||||
* the index could be right while the page still hid the credential — the
|
||||
* original bug.
|
||||
*/
|
||||
it('agrees with the predicate the Connected list actually filters on', () => {
|
||||
for (const providerId of REGISTERED_SERVICE_ACCOUNT_IDS) {
|
||||
const covered = new Set(getIntegrationsForCredentialProvider(providerId).map((i) => i.slug))
|
||||
|
||||
for (const integration of INTEGRATIONS) {
|
||||
const service = resolveOAuthServiceForIntegration(integration)
|
||||
if (!service) continue
|
||||
expect(
|
||||
credentialProviderMatchesService(providerId, service),
|
||||
`${providerId} vs ${integration.slug}`
|
||||
).toBe(covered.has(integration.slug))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('treats only multi-integration service accounts as families', () => {
|
||||
const families = REGISTERED_SERVICE_ACCOUNT_IDS.filter(isFamilyServiceAccount)
|
||||
expect(families).toEqual(['atlassian-service-account', 'google-service-account'])
|
||||
})
|
||||
|
||||
it('names families after the vendor, not one of its products', () => {
|
||||
expect(getServiceAccountFamilyName('atlassian-service-account')).toBe('Atlassian')
|
||||
expect(getServiceAccountFamilyName('google-service-account')).toBe('Google')
|
||||
expect(getServiceAccountFamilyName('notion-service-account')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCredentialDisplay', () => {
|
||||
it('gives an Atlassian service account a vendor identity and a Jira brand tile', () => {
|
||||
const display = resolveCredentialDisplay(serviceAccount('atlassian-service-account'))
|
||||
|
||||
expect(display.familyName).toBe('Atlassian')
|
||||
expect(display.detailTitle).toBe('Automation Bot')
|
||||
expect(display.subtitle).toBe(
|
||||
'Atlassian service account · Confluence, Jira, and Jira Service Management'
|
||||
)
|
||||
// Without a catalog fallback the name lookup misses and the row loses both
|
||||
// its brand tile and its category filter membership.
|
||||
expect(display.blockType).toBe('jira')
|
||||
expect(display.integration?.integrationType).toBeTruthy()
|
||||
})
|
||||
|
||||
it('states a count rather than enumerating 13 Google integrations', () => {
|
||||
const display = resolveCredentialDisplay(serviceAccount('google-service-account'))
|
||||
|
||||
expect(display.familyName).toBe('Google')
|
||||
expect(display.subtitle).toBe('Google service account · all 13 Google integrations')
|
||||
})
|
||||
|
||||
it('uses each vendor own noun for non-family service accounts', () => {
|
||||
expect(resolveCredentialDisplay(serviceAccount('slack-custom-bot')).subtitle).toBe(
|
||||
'Slack custom bot'
|
||||
)
|
||||
expect(resolveCredentialDisplay(serviceAccount('notion-service-account')).subtitle).toBe(
|
||||
'Notion integration secret'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves OAuth credentials titled by their service', () => {
|
||||
const display = resolveCredentialDisplay({
|
||||
type: 'oauth',
|
||||
displayName: 'someone@example.com',
|
||||
providerId: 'jira',
|
||||
})
|
||||
|
||||
expect(display.familyName).toBeNull()
|
||||
expect(display.detailTitle).toBe('Jira')
|
||||
expect(display.subtitle).toBe('Jira integration')
|
||||
expect(display.blockType).toBe('jira')
|
||||
})
|
||||
|
||||
/**
|
||||
* The detail page has always subtitled with the service's own description.
|
||||
* Reusing the list subtitle there would restate the title ("Jira" over "Jira
|
||||
* integration") and throw away the richer copy, so only family service
|
||||
* accounts — which genuinely need their reach spelled out — diverge.
|
||||
*/
|
||||
it('keeps the service description as the detail subtitle for non-family credentials', () => {
|
||||
const oauth = resolveCredentialDisplay({
|
||||
type: 'oauth',
|
||||
displayName: 'someone@example.com',
|
||||
providerId: 'jira',
|
||||
})
|
||||
expect(oauth.detailSubtitle).toBe('Access Jira projects, issues, and Service Management.')
|
||||
|
||||
const singleProductServiceAccount = resolveCredentialDisplay(
|
||||
serviceAccount('notion-service-account')
|
||||
)
|
||||
expect(singleProductServiceAccount.detailSubtitle).toBe(
|
||||
singleProductServiceAccount.service?.description
|
||||
)
|
||||
})
|
||||
|
||||
it('spells out reach on the detail page only for family service accounts', () => {
|
||||
const display = resolveCredentialDisplay(serviceAccount('atlassian-service-account'))
|
||||
expect(display.detailSubtitle).toBe(display.subtitle)
|
||||
expect(display.detailSubtitle).toContain('Jira Service Management')
|
||||
})
|
||||
|
||||
it('degrades safely for a credential with no provider', () => {
|
||||
const display = resolveCredentialDisplay({
|
||||
type: 'service_account',
|
||||
displayName: 'Orphan',
|
||||
providerId: null,
|
||||
})
|
||||
|
||||
expect(display.service).toBeNull()
|
||||
expect(display.icon).toBeNull()
|
||||
expect(display.blockType).toBe('')
|
||||
expect(display.detailTitle).toBe('Orphan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getServiceAccountCoverageSentence', () => {
|
||||
it('tells the user up front that one Atlassian token spans all three products', () => {
|
||||
expect(getServiceAccountCoverageSentence('atlassian-service-account')).toBe(
|
||||
'One token works across Confluence, Jira, and Jira Service Management.'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for providers that map to a single integration', () => {
|
||||
expect(getServiceAccountCoverageSentence('notion-service-account')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Single source of truth for how a stored credential is presented: which
|
||||
* catalog integrations it powers, which mark and brand tile represent it, and
|
||||
* the sentence describing it.
|
||||
*
|
||||
* Before this module, three surfaces (the integrations list, the Cmd-K search
|
||||
* items, and the credential detail page) each re-derived this by looking the
|
||||
* catalog up by the OAuth service's *display name*. That silently failed for
|
||||
* family service accounts: `atlassian-service-account` resolves to a
|
||||
* pseudo-service named "Atlassian Service Account", which matches no catalog
|
||||
* integration, so the credential lost its brand tile and its category.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react'
|
||||
import { getServiceAccountConnectNoun } from '@/lib/credentials/service-account-provider-ids'
|
||||
import integrationsJson from '@/lib/integrations/integrations.json'
|
||||
import { CANONICAL_SERVICE_ACCOUNT_SLUGS } from '@/lib/integrations/oauth-service'
|
||||
import type { Integration } from '@/lib/integrations/types'
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
|
||||
import type { OAuthProvider, OAuthServiceConfig } from '@/lib/oauth/types'
|
||||
import {
|
||||
credentialProviderMatchesService,
|
||||
getServiceConfigByProviderId,
|
||||
getServiceConfigByServiceId,
|
||||
parseProvider,
|
||||
} from '@/lib/oauth/utils'
|
||||
|
||||
const INTEGRATIONS_DATA: readonly Integration[] =
|
||||
integrationsJson.integrations as readonly Integration[]
|
||||
|
||||
/**
|
||||
* Above this many covered integrations the subtitle states a count instead of
|
||||
* enumerating names — Google issues one service account for 13 integrations,
|
||||
* which does not fit on a list row.
|
||||
*/
|
||||
const MAX_ENUMERATED_INTEGRATIONS = 3
|
||||
|
||||
/**
|
||||
* Catalog indexes built once at module load. `resolveCredentialDisplay` runs
|
||||
* inline per credential per render on the integrations surfaces, so its lookups
|
||||
* must be O(1) rather than scanning the full catalog each time.
|
||||
*/
|
||||
const INTEGRATION_BY_SLUG: ReadonlyMap<string, Integration> = new Map(
|
||||
INTEGRATIONS_DATA.map((i) => [i.slug, i])
|
||||
)
|
||||
|
||||
/** Keyed by lowercased display name, matching how OAuth services are named. */
|
||||
const INTEGRATION_BY_LOWER_NAME: ReadonlyMap<string, Integration> = new Map(
|
||||
INTEGRATIONS_DATA.map((i) => [i.name.toLowerCase(), i])
|
||||
)
|
||||
|
||||
/** Every provider id that some service designates as its service-account id. */
|
||||
const SERVICE_ACCOUNT_PROVIDER_IDS: ReadonlySet<string> = new Set(
|
||||
Object.values(OAUTH_PROVIDERS).flatMap((provider) =>
|
||||
Object.values(provider.services).flatMap((service) =>
|
||||
service.serviceAccountProviderId ? [service.serviceAccountProviderId] : []
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* `credentialProviderId` → catalog integrations it authenticates, in catalog
|
||||
* order. Built once at module load: resolving a service per integration walks
|
||||
* `OAUTH_PROVIDERS`, which is wasted work to repeat per lookup (same reasoning
|
||||
* as `SERVICE_ACCOUNT_INTEGRATIONS` in `oauth-service.ts`).
|
||||
*
|
||||
* Indexing under both ids a service answers to is the predicate
|
||||
* {@link credentialProviderMatchesService} expressed as a map, so the two can
|
||||
* never disagree.
|
||||
*/
|
||||
const INTEGRATIONS_BY_CREDENTIAL_PROVIDER: ReadonlyMap<string, readonly Integration[]> = (() => {
|
||||
const index = new Map<string, Integration[]>()
|
||||
const add = (providerId: string | undefined, integration: Integration) => {
|
||||
if (!providerId) return
|
||||
const existing = index.get(providerId)
|
||||
if (existing) existing.push(integration)
|
||||
else index.set(providerId, [integration])
|
||||
}
|
||||
|
||||
for (const integration of INTEGRATIONS_DATA) {
|
||||
if (integration.authType !== 'oauth' || !integration.oauthServiceId) continue
|
||||
const service = getServiceConfigByServiceId(integration.oauthServiceId)
|
||||
if (!service) continue
|
||||
add(service.providerId, integration)
|
||||
add(service.serviceAccountProviderId, integration)
|
||||
}
|
||||
|
||||
return index
|
||||
})()
|
||||
|
||||
/** Catalog integrations a credential of this provider id can authenticate. */
|
||||
export function getIntegrationsForCredentialProvider(providerId: string): readonly Integration[] {
|
||||
return INTEGRATIONS_BY_CREDENTIAL_PROVIDER.get(providerId) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this provider id is a service-account id shared across more than one
|
||||
* catalog integration — one Atlassian API token covers Jira, Jira Service
|
||||
* Management, and Confluence; one Google JSON key covers every Google
|
||||
* integration. Derived from the catalog, so a new integration joining a family
|
||||
* is picked up with no edit here.
|
||||
*/
|
||||
export function isFamilyServiceAccount(providerId: string): boolean {
|
||||
return (
|
||||
SERVICE_ACCOUNT_PROVIDER_IDS.has(providerId) &&
|
||||
getIntegrationsForCredentialProvider(providerId).length > 1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base provider entry owning a family service-account id.
|
||||
*
|
||||
* `parseProvider` is the right lookup because it resolves an id to the service
|
||||
* that registers it as its own `providerId` before falling back — which is how
|
||||
* `atlassian-service-account` reaches the `atlassian` provider rather than
|
||||
* Confluence, the first service that merely *references* it.
|
||||
*/
|
||||
function getFamilyProvider(providerId: string) {
|
||||
if (!isFamilyServiceAccount(providerId)) return null
|
||||
const { baseProvider } = parseProvider(providerId as OAuthProvider)
|
||||
return OAUTH_PROVIDERS[baseProvider] ?? null
|
||||
}
|
||||
|
||||
/** Vendor name for a family service account ("Atlassian", "Google"), else null. */
|
||||
export function getServiceAccountFamilyName(providerId: string): string | null {
|
||||
return getFamilyProvider(providerId)?.name ?? null
|
||||
}
|
||||
|
||||
/** Corporate mark for a family service account, else null. */
|
||||
export function getServiceAccountFamilyIcon(
|
||||
providerId: string
|
||||
): ComponentType<{ className?: string }> | null {
|
||||
const icon = getFamilyProvider(providerId)?.icon
|
||||
return (icon as ComponentType<{ className?: string }> | undefined) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentence naming what a family service account reaches, for connect-time copy
|
||||
* and credential subtitles. Returns null for non-family providers.
|
||||
*/
|
||||
export function getServiceAccountCoverageSentence(providerId: string): string | null {
|
||||
const familyName = getServiceAccountFamilyName(providerId)
|
||||
if (!familyName) return null
|
||||
const covered = getIntegrationsForCredentialProvider(providerId)
|
||||
if (covered.length > MAX_ENUMERATED_INTEGRATIONS) {
|
||||
return `One token works across all ${covered.length} ${familyName} integrations.`
|
||||
}
|
||||
return `One token works across ${formatList(covered.map((i) => i.name))}.`
|
||||
}
|
||||
|
||||
/** Oxford-comma list: "Jira", "Jira and Confluence", "A, B, and C". */
|
||||
function formatList(names: readonly string[]): string {
|
||||
if (names.length <= 1) return names[0] ?? ''
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]}`
|
||||
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
|
||||
}
|
||||
|
||||
/** Minimal credential shape this module needs — structurally satisfied by `WorkspaceCredential`. */
|
||||
interface DisplayableCredential {
|
||||
type: string
|
||||
displayName: string
|
||||
providerId: string | null
|
||||
}
|
||||
|
||||
export interface CredentialDisplay {
|
||||
/** Resolved OAuth service config, or null when the provider is unknown. */
|
||||
service: OAuthServiceConfig | null
|
||||
/**
|
||||
* Catalog integration lending the brand tile and category. For a family
|
||||
* service account this is the family's canonical integration, since no single
|
||||
* product owns the credential.
|
||||
*/
|
||||
integration: Integration | null
|
||||
/** `integration.type`, or '' — drives the brand tile background. */
|
||||
blockType: string
|
||||
/** Mark to render: the family's corporate icon, else the service's own. */
|
||||
icon: ComponentType<{ className?: string }> | null
|
||||
/** Vendor name when this is a family service account, else null. */
|
||||
familyName: string | null
|
||||
/** Catalog integrations this credential authenticates, in catalog order. */
|
||||
coveredIntegrations: readonly Integration[]
|
||||
/**
|
||||
* Generated sentence describing the credential's service and reach. Never the
|
||||
* user's own description — list surfaces prefer `credential.description` and
|
||||
* fall back to this.
|
||||
*/
|
||||
subtitle: string
|
||||
/** Header title for the credential detail page. */
|
||||
detailTitle: string
|
||||
/**
|
||||
* Header subtitle for the credential detail page. Only a family service
|
||||
* account needs its reach spelled out there; every other credential keeps the
|
||||
* service's own richer description, which the page has always shown and which
|
||||
* beats restating the title ("Jira" over "Jira integration").
|
||||
*/
|
||||
detailSubtitle: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves everything a surface needs to render a credential. Pure and
|
||||
* server-safe apart from the icon components it passes through, so it may be
|
||||
* imported by server components as well as client ones.
|
||||
*/
|
||||
export function resolveCredentialDisplay(credential: DisplayableCredential): CredentialDisplay {
|
||||
const providerId = credential.providerId
|
||||
const isServiceAccount = credential.type === 'service_account'
|
||||
|
||||
if (!providerId) {
|
||||
return {
|
||||
service: null,
|
||||
integration: null,
|
||||
blockType: '',
|
||||
icon: null,
|
||||
familyName: null,
|
||||
coveredIntegrations: [],
|
||||
subtitle: 'Connected service',
|
||||
detailTitle: credential.displayName,
|
||||
detailSubtitle: 'Connected service',
|
||||
}
|
||||
}
|
||||
|
||||
const service = getServiceConfigByProviderId(providerId)
|
||||
const familyName = getServiceAccountFamilyName(providerId)
|
||||
const coveredIntegrations = getIntegrationsForCredentialProvider(providerId)
|
||||
const integration = resolveCatalogIntegration(providerId, service, Boolean(familyName))
|
||||
|
||||
const subtitle = buildSubtitle({
|
||||
providerId,
|
||||
service,
|
||||
familyName,
|
||||
coveredIntegrations,
|
||||
isServiceAccount,
|
||||
})
|
||||
|
||||
return {
|
||||
service,
|
||||
integration,
|
||||
blockType: integration?.type ?? '',
|
||||
icon:
|
||||
getServiceAccountFamilyIcon(providerId) ??
|
||||
(service?.icon as ComponentType<{ className?: string }> | undefined) ??
|
||||
null,
|
||||
familyName,
|
||||
coveredIntegrations,
|
||||
subtitle,
|
||||
detailTitle: isServiceAccount
|
||||
? credential.displayName
|
||||
: (service?.name ?? credential.displayName),
|
||||
detailSubtitle: familyName ? subtitle : (service?.description ?? subtitle),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog entry lending brand tile and category.
|
||||
*
|
||||
* A family service account goes straight to its canonical slug: resolving by
|
||||
* service name would land on whichever product the resolver happened to pick
|
||||
* (`google-service-account` → Gmail), which is arbitrary. Everything else keeps
|
||||
* the long-standing name lookup.
|
||||
*/
|
||||
function resolveCatalogIntegration(
|
||||
providerId: string,
|
||||
service: OAuthServiceConfig | null,
|
||||
isFamily: boolean
|
||||
): Integration | null {
|
||||
if (isFamily) {
|
||||
const slug = CANONICAL_SERVICE_ACCOUNT_SLUGS[providerId]
|
||||
const canonical = slug ? INTEGRATION_BY_SLUG.get(slug) : undefined
|
||||
if (canonical) return canonical
|
||||
}
|
||||
if (!service) return null
|
||||
const byName = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
|
||||
if (byName) return byName
|
||||
// Last resort: any integration this credential authenticates, so a credential
|
||||
// never loses its category filter membership.
|
||||
return getIntegrationsForCredentialProvider(providerId)[0] ?? null
|
||||
}
|
||||
|
||||
interface SubtitleArgs {
|
||||
providerId: string
|
||||
service: OAuthServiceConfig | null
|
||||
familyName: string | null
|
||||
coveredIntegrations: readonly Integration[]
|
||||
isServiceAccount: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Vendor-accurate nouns come from {@link getServiceAccountConnectNoun}, the
|
||||
* same source the connect controls use, so a Slack credential reads "custom
|
||||
* bot" here exactly as its setup button does.
|
||||
*/
|
||||
function buildSubtitle({
|
||||
providerId,
|
||||
service,
|
||||
familyName,
|
||||
coveredIntegrations,
|
||||
isServiceAccount,
|
||||
}: SubtitleArgs): string {
|
||||
if (familyName) {
|
||||
const scope =
|
||||
coveredIntegrations.length > MAX_ENUMERATED_INTEGRATIONS
|
||||
? `all ${coveredIntegrations.length} ${familyName} integrations`
|
||||
: formatList(coveredIntegrations.map((i) => i.name))
|
||||
return `${familyName} ${getServiceAccountConnectNoun(providerId)} · ${scope}`
|
||||
}
|
||||
if (!service) return 'Connected service'
|
||||
return isServiceAccount
|
||||
? `${service.name} ${getServiceAccountConnectNoun(providerId)}`
|
||||
: `${service.name} integration`
|
||||
}
|
||||
@@ -98,6 +98,15 @@ export function toIntegrationSummary(integration: Integration): IntegrationSumma
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
type CredentialDisplay,
|
||||
getIntegrationsForCredentialProvider,
|
||||
getServiceAccountCoverageSentence,
|
||||
getServiceAccountFamilyIcon,
|
||||
getServiceAccountFamilyName,
|
||||
isFamilyServiceAccount,
|
||||
resolveCredentialDisplay,
|
||||
} from '@/lib/integrations/credential-display'
|
||||
export { blockTypeToIconMap } from '@/lib/integrations/icon-mapping'
|
||||
export {
|
||||
type OAuthServiceMatch,
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface ServiceAccountIntegrationMatch {
|
||||
* both arbitrary and a poor landing page. A caller that names a specific
|
||||
* integration still gets that integration.
|
||||
*/
|
||||
const CANONICAL_SERVICE_ACCOUNT_SLUGS: Readonly<Record<string, string>> = {
|
||||
export const CANONICAL_SERVICE_ACCOUNT_SLUGS: Readonly<Record<string, string>> = {
|
||||
'google-service-account': 'google-drive',
|
||||
google: 'google-drive',
|
||||
'atlassian-service-account': 'jira',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { truncate } from '@sim/utils/string'
|
||||
import {
|
||||
AirtableIcon,
|
||||
AsanaIcon,
|
||||
AtlassianIcon,
|
||||
AttioIcon,
|
||||
AzureIcon,
|
||||
BoxCompanyIcon,
|
||||
@@ -509,15 +510,15 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
atlassian: {
|
||||
name: 'Atlassian',
|
||||
icon: JiraIcon,
|
||||
icon: AtlassianIcon,
|
||||
services: {
|
||||
'atlassian-service-account': {
|
||||
name: 'Atlassian Service Account',
|
||||
description:
|
||||
'Authenticate as an Atlassian service account using a scoped API token from admin.atlassian.com.',
|
||||
providerId: 'atlassian-service-account',
|
||||
icon: JiraIcon,
|
||||
baseProviderIcon: JiraIcon,
|
||||
icon: AtlassianIcon,
|
||||
baseProviderIcon: AtlassianIcon,
|
||||
scopes: [],
|
||||
authType: 'service_account',
|
||||
},
|
||||
|
||||
@@ -543,6 +543,42 @@ export function getServiceAccountProviderForProviderId(providerId: string): stri
|
||||
return serviceConfig?.serviceAccountProviderId
|
||||
}
|
||||
|
||||
/**
|
||||
* The two provider ids a service answers to. Structurally satisfied by both
|
||||
* `OAuthServiceConfig` and the lighter `OAuthServiceMatch` that catalog
|
||||
* resolution returns, so callers pass whichever they already hold.
|
||||
*/
|
||||
export interface ServiceProviderIdentity {
|
||||
providerId: string
|
||||
serviceAccountProviderId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stored credential's `providerId` authenticates the given service.
|
||||
*
|
||||
* A service is reachable by two ids: its own OAuth `providerId` (`jira`) and
|
||||
* the service-account provider its family issues (`atlassian-service-account`).
|
||||
* One Atlassian API token authenticates Jira, Jira Service Management, and
|
||||
* Confluence alike, so matching on the OAuth `providerId` alone hides a
|
||||
* service-account credential from every product page it actually powers.
|
||||
*
|
||||
* Prefer this over comparing `getServiceConfigByProviderId(id)?.providerId`
|
||||
* against a service: that resolver walks `OAUTH_PROVIDERS` in declaration
|
||||
* order and answers "which service owns this id", which for a family-wide
|
||||
* service-account id is an arbitrary single winner — `atlassian-service-account`
|
||||
* resolves to the `Atlassian Service Account` pseudo-service and
|
||||
* `google-service-account` to whichever Google service is declared first.
|
||||
*/
|
||||
export function credentialProviderMatchesService(
|
||||
credentialProviderId: string,
|
||||
service: ServiceProviderIdentity
|
||||
): boolean {
|
||||
return (
|
||||
service.providerId === credentialProviderId ||
|
||||
service.serviceAccountProviderId === credentialProviderId
|
||||
)
|
||||
}
|
||||
|
||||
export function getCanonicalScopesForProvider(providerId: string): string[] {
|
||||
const service = getServiceConfigByProviderId(providerId)
|
||||
return service?.scopes ? [...service.scopes] : []
|
||||
|
||||
Reference in New Issue
Block a user