Skip to content

Commit dbdd3eb

Browse files
authored
improvement(integrations): show the sites an Atlassian account reaches (#9762)
* 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. * fix(integrations): bound recorded sites and keep them when a reconnect cannot list them
1 parent 2eb80dd commit dbdd3eb

12 files changed

Lines changed: 310 additions & 35 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/pri
1818
import { defineRouteContract } from '@/lib/api/contracts/types'
1919
import { ORGANIZATION_CREDENTIAL_TYPES } from '@/lib/credential-groups/credential-types'
2020
import {
21+
ACCOUNT_SITE_LIMIT,
22+
ACCOUNT_SITE_NAME_MAX_LENGTH,
23+
ACCOUNT_SITE_URL_MAX_LENGTH,
2124
ORGANIZATION_ACCOUNT_WORKSPACE_LIMIT,
2225
ORGANIZATION_VIEWER_ACCOUNT_LIMIT,
2326
} from '@/lib/credential-groups/limits'
@@ -79,6 +82,14 @@ export const getOrganizationAccountsContract = defineRouteContract({
7982
groupId: z.string().min(1).max(128),
8083
optionId: z.string().min(1).max(128),
8184
status: z.enum(['active', 'needs_reauth']),
85+
sites: z
86+
.array(
87+
z.object({
88+
name: z.string().max(ACCOUNT_SITE_NAME_MAX_LENGTH),
89+
url: z.string().max(ACCOUNT_SITE_URL_MAX_LENGTH),
90+
})
91+
)
92+
.max(ACCOUNT_SITE_LIMIT),
8293
})
8394
)
8495
.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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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'
56
import { OAuth2Client, type TokenPayload } from 'google-auth-library'
67
import { createRemoteJWKSet, type JWTPayload, jwtVerify } from 'jose'
78
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
89
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'
10+
import { boundedAccountSites } from '@/lib/credential-groups/account-sites'
911
import { getDocusignOAuthUrl } from '@/lib/oauth/docusign'
1012
import { verifyGitHubRepositoriesIdentity } from '@/lib/oauth/github-repositories'
1113
import { deriveMicrosoftEmailVerified, mapMicrosoftProfileToUser } from '@/lib/oauth/microsoft'
@@ -24,6 +26,7 @@ const GMAIL_LABELS_SCOPE = 'https://www.googleapis.com/auth/gmail.labels'
2426
const ATLASSIAN_USER_INFO_URL = 'https://api.atlassian.com/me'
2527
const ATLASSIAN_USER_INFO_MAX_BYTES = 256 * 1024
2628
const ATLASSIAN_USER_INFO_TIMEOUT_MS = 10_000
29+
const ATLASSIAN_SITES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources'
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,42 @@ 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 boundedAccountSites(
220+
body.map((entry) => {
221+
const site = toRecord(entry)
222+
return { name: site.name, url: typeof site.url === 'string' ? httpsOrigin(site.url) : null }
223+
})
224+
)
225+
} catch {
226+
return undefined
227+
}
228+
}
229+
190230
/** Managed enrollment policy for Atlassian's Jira and Confluence 3LO clients. */
191231
export function createAtlassianManagedOAuthConnector(
192232
providerId: AtlassianManagedOAuthProviderId
@@ -205,6 +245,7 @@ export function createAtlassianManagedOAuthConnector(
205245
if (!tokens.accessToken || !tokens.scopes?.length) {
206246
throw new Error('Atlassian returned an incomplete authorization')
207247
}
248+
const sites = listAtlassianSites(tokens.accessToken)
208249
const response = await fetch(ATLASSIAN_USER_INFO_URL, {
209250
headers: {
210251
Accept: 'application/json',
@@ -228,6 +269,7 @@ export function createAtlassianManagedOAuthConnector(
228269
...(identity.name ? { displayName: identity.name } : {}),
229270
...(identity.picture ? { avatarUrl: identity.picture } : {}),
230271
grantedScopes: [...new Set(tokens.scopes)],
272+
...(await sites.then((listed) => (listed ? { sites: listed } : {}))),
231273
}
232274
},
233275
hasRequiredScopes(grantedScopes, requiredScopes) {

0 commit comments

Comments
 (0)