Skip to content

Commit b5e415d

Browse files
committed
improvement(integrations): show the sites an Atlassian account reaches
Adding a site to an Atlassian login refreshes the existing account, so the page looked unchanged after a successful add. Record the sites a grant reaches when it connects, list them on the account, count them in the accounts list, and confirm a successful connect or reconnect with a toast.
1 parent 1922fb8 commit b5e415d

9 files changed

Lines changed: 232 additions & 34 deletions

File tree

‎apps/sim/app/o/[organizationId]/integrations/integration-detail.tsx‎

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ interface IntegrationDetailProps {
3333
children?: ReactNode
3434
}
3535

36+
type IntegrationAccount = MemberIntegration['accounts'][number]
37+
38+
/** Sites recorded for an account whose one grant reaches several, such as Confluence or Jira. */
39+
function sitesOf(account: IntegrationAccount) {
40+
return 'sites' in account ? account.sites : []
41+
}
42+
43+
function siteHost(url: string) {
44+
return url.replace(/^https:\/\//, '')
45+
}
46+
47+
function accountSummary(account: IntegrationAccount) {
48+
const status = account.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'
49+
const count = sitesOf(account).length
50+
return count > 0 ? `${status} · ${count} ${count === 1 ? 'site' : 'sites'}` : status
51+
}
52+
3653
function savedProviderIdFor(provider: string) {
3754
const providerId = liveSearchSavedMemberProvider(provider)
3855
return providerId === 'jama-service-account' || providerId === 'flow-service-account'
@@ -81,7 +98,11 @@ export function IntegrationDetail({
8198
? undefined
8299
: 'savedProviderId' in target
83100
? () => savedProvider && setSavedConnection({ providerId: savedProvider })
84-
: () => connect.mutate({ organizationId, ...target }, { onError })
101+
: () =>
102+
connect.mutate(
103+
{ organizationId, ...target },
104+
{ onError, onSuccess: () => toast.success(`${integration.name} connected`) }
105+
)
85106
}
86107
onReconnect={() => {
87108
if (!selected) return
@@ -91,7 +112,11 @@ export function IntegrationDetail({
91112
credentialId: selected.credentialId,
92113
displayName: selected.displayName,
93114
})
94-
else reconnect.mutate(selected.credentialId, { onError })
115+
else
116+
reconnect.mutate(selected.credentialId, {
117+
onError,
118+
onSuccess: () => toast.success(`${integration.name} reconnected`),
119+
})
95120
}}
96121
connectionPending={connect.isPending || reconnect.isPending}
97122
canConnect={integration.ready}
@@ -117,19 +142,33 @@ export function IntegrationDetail({
117142
This account is no longer connected.
118143
</SettingsEmptyState>
119144
) : selected ? (
120-
<SettingsField
121-
label={selected.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'}
122-
breakAll
123-
>
124-
{selected.displayName}
125-
</SettingsField>
145+
<div className='flex flex-col gap-4'>
146+
<SettingsField
147+
label={selected.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'}
148+
breakAll
149+
>
150+
{selected.displayName}
151+
</SettingsField>
152+
{sitesOf(selected).length > 0 && (
153+
<SettingsField label='Sites'>
154+
<ul className='flex flex-col gap-1'>
155+
{sitesOf(selected).map((site) => (
156+
<li key={site.url} className='break-all text-[var(--text-body)] text-sm'>
157+
{site.name}{' '}
158+
<span className='text-[var(--text-muted)]'>{siteHost(site.url)}</span>
159+
</li>
160+
))}
161+
</ul>
162+
</SettingsField>
163+
)}
164+
</div>
126165
) : integration.accounts.length ? (
127166
<div className={RESOURCE_LIST_STACK}>
128167
{integration.accounts.map((account) => (
129168
<SettingsResourceRow
130169
key={account.credentialId}
131170
title={account.displayName}
132-
description={account.status === 'needs_reauth' ? 'Reconnect needed' : 'Connected'}
171+
description={accountSummary(account)}
133172
href={accountHref(account.credentialId)}
134173
clickLabel={`Open ${account.displayName} account`}
135174
navigable

‎apps/sim/lib/api/contracts/organization-accounts.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ export const getOrganizationAccountsContract = defineRouteContract({
7979
groupId: z.string().min(1).max(128),
8080
optionId: z.string().min(1).max(128),
8181
status: z.enum(['active', 'needs_reauth']),
82+
sites: z
83+
.array(z.object({ name: z.string().max(512), url: z.string().max(2048) }))
84+
.max(50),
8285
})
8386
)
8487
.max(ORGANIZATION_VIEWER_ACCOUNT_LIMIT)

‎apps/sim/lib/auth/connectors/managed-oauth.test.ts‎

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,19 @@ const ATLASSIAN_SCOPES = ['read:me', 'read:jira-work', 'offline_access']
99

1010
describe('Atlassian managed OAuth connector', () => {
1111
it('uses the existing connector callback contract and verifies the current account', async () => {
12-
const fetchMock = vi.fn().mockResolvedValue(
13-
new Response(
14-
JSON.stringify({
15-
account_type: 'atlassian',
16-
account_id: 'account-1',
17-
email: 'person@example.com',
18-
name: 'Person',
19-
picture: 'https://example.com/avatar.png',
20-
account_status: 'active',
21-
}),
22-
{ status: 200, headers: { 'Content-Type': 'application/json' } }
23-
)
12+
const fetchMock = vi.fn().mockImplementation(
13+
async () =>
14+
new Response(
15+
JSON.stringify({
16+
account_type: 'atlassian',
17+
account_id: 'account-1',
18+
email: 'person@example.com',
19+
name: 'Person',
20+
picture: 'https://example.com/avatar.png',
21+
account_status: 'active',
22+
}),
23+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
24+
)
2425
)
2526
vi.stubGlobal('fetch', fetchMock)
2627
const connector = createAtlassianManagedOAuthConnector('jira')
@@ -60,18 +61,76 @@ describe('Atlassian managed OAuth connector', () => {
6061
})
6162
})
6263

64+
const ATLASSIAN_PROFILE = {
65+
account_type: 'atlassian',
66+
account_id: 'account-1',
67+
email: 'person@example.com',
68+
account_status: 'active',
69+
}
70+
const json = (body: unknown, status = 200) =>
71+
new Response(JSON.stringify(body), {
72+
status,
73+
headers: { 'Content-Type': 'application/json' },
74+
})
75+
function stubAtlassian(sites: () => Response) {
76+
vi.stubGlobal(
77+
'fetch',
78+
vi.fn(async (url: string) =>
79+
url === 'https://api.atlassian.com/me' ? json(ATLASSIAN_PROFILE) : sites()
80+
)
81+
)
82+
}
83+
const verify = (provider: 'jira' | 'confluence' = 'confluence') =>
84+
createAtlassianManagedOAuthConnector(provider).verifyIdentity({
85+
tokens: { tokenType: 'Bearer', accessToken: 'access-1', scopes: ATLASSIAN_SCOPES },
86+
clientId: 'client-1',
87+
})
88+
89+
it('records the sites the grant reaches, so an added site is visible', async () => {
90+
stubAtlassian(() =>
91+
json([
92+
{ id: 'cloud-1', name: 'Acme', url: 'https://acme.example.net', scopes: [] },
93+
{ id: 'cloud-2', name: 'Beta', url: 'https://beta.example.net/', scopes: [] },
94+
{ id: 'cloud-3', name: 'Insecure', url: 'http://insecure.example.net' },
95+
{ id: 'cloud-4', url: 'https://unnamed.example.net' },
96+
])
97+
)
98+
99+
expect((await verify()).sites).toEqual([
100+
{ name: 'Acme', url: 'https://acme.example.net' },
101+
{ name: 'Beta', url: 'https://beta.example.net' },
102+
])
103+
})
104+
105+
it.each([
106+
['an error status', () => json({ message: 'unavailable' }, 503)],
107+
[
108+
'a network failure',
109+
() => {
110+
throw new TypeError('fetch failed')
111+
},
112+
],
113+
['a malformed body', () => json({ sites: 'nope' })],
114+
])('still connects without a site list after %s', async (_name, sites) => {
115+
stubAtlassian(sites)
116+
const identity = await verify('jira')
117+
expect(identity).toMatchObject({ providerSubjectId: 'account-1' })
118+
expect(identity.sites).toBeUndefined()
119+
})
120+
63121
it('fails closed for an inactive or incomplete Atlassian identity', async () => {
64122
vi.stubGlobal(
65123
'fetch',
66-
vi.fn().mockResolvedValue(
67-
new Response(
68-
JSON.stringify({
69-
account_type: 'atlassian',
70-
account_id: 'account-1',
71-
account_status: 'inactive',
72-
}),
73-
{ status: 200, headers: { 'Content-Type': 'application/json' } }
74-
)
124+
vi.fn().mockImplementation(
125+
async () =>
126+
new Response(
127+
JSON.stringify({
128+
account_type: 'atlassian',
129+
account_id: 'account-1',
130+
account_status: 'inactive',
131+
}),
132+
{ status: 200, headers: { 'Content-Type': 'application/json' } }
133+
)
75134
)
76135
)
77136
const connector = createAtlassianManagedOAuthConnector('confluence')

‎apps/sim/lib/auth/connectors/managed-oauth.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash } from 'node:crypto'
2+
import type { ManagedOAuthSite } from '@sim/db/schema'
23
import { isRecordLike, toRecord } from '@sim/utils/object'
34
import type { OAuth2Tokens } from 'better-auth/oauth2'
45
import type { GenericOAuthConfig } from 'better-auth/plugins'
@@ -24,6 +25,8 @@ const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
2425
const ATLASSIAN_USER_INFO_URL = 'https://api.atlassian.com/me'
2526
const ATLASSIAN_USER_INFO_MAX_BYTES = 256 * 1024
2627
const ATLASSIAN_USER_INFO_TIMEOUT_MS = 10_000
28+
const ATLASSIAN_SITES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'
29+
const ATLASSIAN_SITE_LIMIT = 50
2730
const MICROSOFT_JWKS_URL = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'
2831
const MICROSOFT_OIDC_USER_INFO_URL = 'https://graph.microsoft.com/oidc/userinfo'
2932
const MICROSOFT_OIDC_USER_INFO_MAX_BYTES = 256 * 1024
@@ -49,6 +52,7 @@ export interface ManagedOAuthConnectorIdentity {
4952
avatarUrl?: string
5053
nonce?: string
5154
grantedScopes: string[]
55+
sites?: ManagedOAuthSite[]
5256
}
5357

5458
export interface ManagedOAuthConnectorConfig {
@@ -187,6 +191,44 @@ function requireAtlassianUserProfile(value: AtlassianUserProfile): {
187191
}
188192
}
189193

194+
function httpsOrigin(value: string): string | null {
195+
try {
196+
const url = new URL(value)
197+
return url.protocol === 'https:' ? url.origin : null
198+
} catch {
199+
return null
200+
}
201+
}
202+
203+
/**
204+
* The sites one Atlassian grant reaches, shown so a person can see a site they added. Display only:
205+
* Search lists sites again on every request, so a missing or stale list never changes access, and
206+
* a failed listing never fails the connection.
207+
*/
208+
async function listAtlassianSites(accessToken: string): Promise<ManagedOAuthSite[] | undefined> {
209+
try {
210+
const response = await fetch(ATLASSIAN_SITES_URL, {
211+
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
212+
signal: AbortSignal.timeout(ATLASSIAN_USER_INFO_TIMEOUT_MS),
213+
})
214+
const body = await readResponseJsonWithLimit<unknown>(response, {
215+
maxBytes: ATLASSIAN_USER_INFO_MAX_BYTES,
216+
label: 'Atlassian accessible resources response',
217+
})
218+
if (!response.ok || !Array.isArray(body)) return undefined
219+
return body
220+
.flatMap((entry) => {
221+
const site = toRecord(entry)
222+
const name = typeof site.name === 'string' ? site.name.trim() : ''
223+
const url = typeof site.url === 'string' ? httpsOrigin(site.url) : null
224+
return name && url ? [{ name, url }] : []
225+
})
226+
.slice(0, ATLASSIAN_SITE_LIMIT)
227+
} catch {
228+
return undefined
229+
}
230+
}
231+
190232
/** Managed enrollment policy for Atlassian's Jira and Confluence 3LO clients. */
191233
export function createAtlassianManagedOAuthConnector(
192234
providerId: AtlassianManagedOAuthProviderId
@@ -205,6 +247,7 @@ export function createAtlassianManagedOAuthConnector(
205247
if (!tokens.accessToken || !tokens.scopes?.length) {
206248
throw new Error('Atlassian returned an incomplete authorization')
207249
}
250+
const sites = listAtlassianSites(tokens.accessToken)
208251
const response = await fetch(ATLASSIAN_USER_INFO_URL, {
209252
headers: {
210253
Accept: 'application/json',
@@ -228,6 +271,7 @@ export function createAtlassianManagedOAuthConnector(
228271
...(identity.name ? { displayName: identity.name } : {}),
229272
...(identity.picture ? { avatarUrl: identity.picture } : {}),
230273
grantedScopes: [...new Set(tokens.scopes)],
274+
...(await sites.then((listed) => (listed ? { sites: listed } : {}))),
231275
}
232276
},
233277
hasRequiredScopes(grantedScopes, requiredScopes) {

‎apps/sim/lib/credential-groups/__integration__/multiple-provider-accounts.integration.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '@sim/db/schema'
1616
import { sha256Hex } from '@sim/security/hash'
1717
import { generateId } from '@sim/utils/id'
18-
import { eq } from 'drizzle-orm'
18+
import { eq, sql } from 'drizzle-orm'
1919
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2020
import type { CredentialGroupOAuthContext } from '@/lib/credential-groups/enrollments'
2121
import { listCredentialGroupEnrollments } from '@/lib/credential-groups/enrollments'
@@ -24,6 +24,7 @@ import { completeCredentialGroupOAuth } from '@/lib/credential-groups/oauth'
2424
import type { CredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
2525
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
2626
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
27+
import { listViewerOrganizationAccounts } from '@/lib/credential-groups/viewer-accounts'
2728
import { persistManagedMcpCredential } from '@/lib/credentials/managed-mcp'
2829
import { decryptManagedOAuthTokenSet } from '@/lib/credentials/managed-oauth'
2930
import { resolveSlackSearchMember } from '@/lib/knowledge/application/slack-search/identity'
@@ -357,6 +358,31 @@ describe('adding another site to an Atlassian login', () => {
357358
expect(await saved()).not.toBe(firstToken)
358359
})
359360

361+
it('shows the sites recorded for the account, ignoring anything unusable', async () => {
362+
const account = await connect('person@atlassian', { kind: 'create' }, CONFLUENCE)
363+
const viewer = async () =>
364+
(await listViewerOrganizationAccounts({ organizationId, userId })).find(
365+
(row) => row.credentialId === account.credentialId
366+
)
367+
expect((await viewer())?.sites).toEqual([])
368+
369+
await db
370+
.update(credential)
371+
.set({
372+
providerMetadata: sql`${JSON.stringify({
373+
email: 'person@atlassian',
374+
sites: [
375+
{ name: 'Acme', url: 'https://acme.example.net' },
376+
{ name: 42, url: 'https://broken.example.net' },
377+
'not a site',
378+
],
379+
})}::jsonb`,
380+
})
381+
.where(eq(credential.id, account.credentialId))
382+
383+
expect((await viewer())?.sites).toEqual([{ name: 'Acme', url: 'https://acme.example.net' }])
384+
})
385+
360386
it('still adds a different Atlassian login as its own account', async () => {
361387
await connect('person@atlassian', { kind: 'create' }, CONFLUENCE)
362388
await connect('other@atlassian', { kind: 'create' }, CONFLUENCE)

‎apps/sim/lib/credential-groups/application/organization-access.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ describe('organization workspace sharing administration', () => {
194194
principal,
195195
input: { organizationId: 'org-1' },
196196
})
197-
expect(result.viewerAccounts).toEqual([account])
197+
expect(result.viewerAccounts).toEqual([{ ...account, sites: [] }])
198198
expect(result.canManage).toBe(false)
199199
expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroup.id, 'group-1')
200200
expect(eq).toHaveBeenCalledWith(schemaMock.credentialGroupEnrollment.userId, 'admin-user')

‎apps/sim/lib/credential-groups/standard-oauth-provider.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ export function createStandardOAuthCredentialGroupProviderAdapter(
363363
email,
364364
...(identity.displayName ? { displayName: identity.displayName } : {}),
365365
...(identity.avatarUrl ? { avatarUrl: identity.avatarUrl } : {}),
366+
...(identity.sites ? { sites: identity.sites } : {}),
366367
},
367368
accessToken: tokens.accessToken,
368369
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),

0 commit comments

Comments
 (0)