Skip to content

Commit 2bfb8b1

Browse files
improvement(search): simplify personal integrations and source setup (#7693)
* improvement(settings): remove personal connected accounts page * improvement(search): simplify personal integrations and source setup * fix(search): update source route test fixtures
1 parent c723a3a commit 2bfb8b1

57 files changed

Lines changed: 1926 additions & 734 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/account/settings/[section]/page.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ describe('account settings legacy links', () => {
5252
)
5353
})
5454

55-
it('still rejects unknown sections', async () => {
56-
await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND')
57-
})
55+
it.each(['unknown', 'connected-accounts'])(
56+
'rejects unavailable sections: %s',
57+
async (section) => {
58+
await expect(AccountSettingsSectionPage(pageProps(section))).rejects.toThrow('NEXT_NOT_FOUND')
59+
}
60+
)
5861
})

apps/sim/app/api/credential-groups/enrollment-redirect.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextResponse } from 'next/server'
2+
import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'
23

34
const NO_STORE_REDIRECT_HEADERS = {
45
'Cache-Control': 'no-store',
@@ -20,23 +21,17 @@ export function createCredentialGroupEnrollmentRedirect(
2021
})
2122
}
2223

23-
export type CredentialGroupOAuthFailure =
24-
| 'expired'
25-
| 'denied'
26-
| 'account_mismatch'
27-
| 'permissions_required'
28-
| 'configuration_changed'
29-
| 'rate_limited'
30-
| 'unavailable'
31-
| 'failed'
32-
3324
export function createCredentialGroupCompletionRedirect(
34-
oauth?: CredentialGroupOAuthFailure
25+
oauth?: CredentialGroupOAuthFailure,
26+
completionId?: string
3527
): NextResponse {
28+
const query = new URLSearchParams()
29+
if (oauth) query.set('oauth', oauth)
30+
if (completionId) query.set('completionId', completionId)
3631
return new NextResponse(null, {
3732
status: 303,
3833
headers: {
39-
Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`,
34+
Location: `/credential-groups/complete${query.size ? `?${query}` : ''}`,
4035
...NO_STORE_REDIRECT_HEADERS,
4136
},
4237
})

apps/sim/app/api/credential-groups/oauth-callback.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/cred
55
import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth'
66
import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment'
77
import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version'
8+
import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion'
89
import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state'
910
import {
1011
CredentialGroupInvitationUnavailableError,
1112
CredentialGroupOAuthError,
1213
} from '@/lib/credential-groups/provider-adapter'
1314
import type { CredentialGroupProvider } from '@/lib/credential-groups/providers'
1415
import {
15-
type CredentialGroupOAuthFailure,
1616
createCredentialGroupCompletionRedirect,
1717
createCredentialGroupEnrollmentRedirect,
1818
} from '@/app/api/credential-groups/enrollment-redirect'
@@ -54,7 +54,7 @@ export async function handleCredentialGroupOAuthCallback({
5454
: {}
5555
const failureRedirect = (oauth: CredentialGroupOAuthFailure) =>
5656
attempt.completionRedirect
57-
? createCredentialGroupCompletionRedirect(oauth)
57+
? createCredentialGroupCompletionRedirect(oauth, attempt.completionId)
5858
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth })
5959
if (limited) {
6060
return failureRedirect('rate_limited')
@@ -74,7 +74,7 @@ export async function handleCredentialGroupOAuthCallback({
7474
request,
7575
})
7676
return attempt.completionRedirect
77-
? createCredentialGroupCompletionRedirect()
77+
? createCredentialGroupCompletionRedirect(undefined, attempt.completionId)
7878
: createCredentialGroupEnrollmentRedirect(attempt.invitationToken, {
7979
...focus,
8080
connected: attempt.optionId,

apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ function request(query: string) {
5656
}
5757

5858
describe('credential group OAuth callback', () => {
59+
it.each([
60+
['code=code-1', undefined],
61+
['error=access_denied', 'denied'],
62+
])(
63+
'correlates direct OAuth completion without returning to enrollment: %s',
64+
async (query, failure) => {
65+
const completionId = '550e8400-e29b-41d4-a716-446655440000'
66+
mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true, completionId })
67+
const response = await GET(request(`state=state-1&${query}`), context)
68+
const location = new URL(response.headers.get('location')!, 'https://sim.test')
69+
expect(response.status).toBe(303)
70+
expect(location.pathname).toBe('/credential-groups/complete')
71+
expect(location.searchParams.get('completionId')).toBe(completionId)
72+
expect(location.searchParams.get('oauth')).toBe(failure ?? null)
73+
}
74+
)
5975
beforeEach(() => {
6076
vi.clearAllMocks()
6177
mocks.rateLimit.mockResolvedValue(null)

apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ export const POST = defineInternalJsonRoute({
1616
reason:
1717
'A member connecting their own account by hand; each call only re-issues their own invitation',
1818
}),
19-
errorPolicy: internalKnowledgeErrorPolicies.connectors,
20-
mapInput: ({ params }) => ({
19+
errorPolicy: internalKnowledgeErrorPolicies.connectAccount,
20+
mapInput: ({ params, query }) => ({
2121
connectorId: params.connectorId,
2222
knowledgeBaseId: params.id,
23+
oauthCompletionId: query.oauthCompletionId,
2324
}),
2425
useCase: startKnowledgeConnectorMemberEnrollment,
2526
present: ({ url }) => ({ success: true as const, data: { url } }),

apps/sim/app/api/knowledge/sim-search/connect/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const POST = defineInternalJsonRoute({
1313
auth: internalSessionAuth,
1414
operation: knowledgeOperations.simSearchConnect,
1515
rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }),
16-
errorPolicy: internalKnowledgeErrorPolicies.connectors,
16+
errorPolicy: internalKnowledgeErrorPolicies.connectAccount,
1717
mapInput: ({ body }) => body,
1818
useCase: connectSimSearchConnector,
1919
present: (result) => ({ success: true as const, data: result }),

apps/sim/app/api/knowledge/sim-search/sources/route.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
/** @vitest-environment node */
22
import { authMockFns, createMockRequest } from '@sim/testing'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import type {
5+
OrganizationSearchProviderSummary,
6+
SearchSourceSummary,
7+
} from '@/lib/api/contracts/knowledge/connectors'
48

59
const mocks = vi.hoisted(() => ({
610
execute: vi.fn(),
@@ -58,9 +62,10 @@ const source = {
5862
viewerDocumentCount: 0,
5963
viewerFailedDocumentCount: 0,
6064
viewerEmailVerified: true,
65+
viewerAccounts: [],
6166
connectionRequired: false,
6267
viewerMembership: null,
63-
}
68+
} satisfies SearchSourceSummary
6469

6570
beforeEach(() => {
6671
vi.clearAllMocks()
@@ -266,8 +271,9 @@ describe('organization administration overview boundary', () => {
266271
sourceCount: 1,
267272
approved: true,
268273
status: 'waiting_for_connections',
274+
issue: null,
269275
isSyncing: false,
270-
}
276+
} satisfies OrganizationSearchProviderSummary
271277
mocks.adminOverview.mockResolvedValue({
272278
providers: [{ ...provider, privateAccount: 'private' }],
273279
documentNames: ['private'],
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { createRoot } from 'react-dom/client'
4+
import { afterEach, describe, expect, it, vi } from 'vitest'
5+
import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff'
6+
7+
afterEach(() => {
8+
vi.restoreAllMocks()
9+
vi.unstubAllGlobals()
10+
})
11+
12+
describe('credential group OAuth completion', () => {
13+
it.each([undefined, 'denied', 'configuration_changed'] as const)(
14+
'publishes %s to only its initiating tab and closes',
15+
(failure) => {
16+
const postMessage = vi.fn()
17+
const closeChannel = vi.fn()
18+
const names: string[] = []
19+
vi.stubGlobal(
20+
'BroadcastChannel',
21+
class {
22+
postMessage = postMessage
23+
close = closeChannel
24+
constructor(name: string) {
25+
names.push(name)
26+
}
27+
}
28+
)
29+
const closeWindow = vi.spyOn(window, 'close').mockImplementation(() => {})
30+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
31+
const container = document.createElement('div')
32+
const root = createRoot(container)
33+
const completionId = '550e8400-e29b-41d4-a716-446655440000'
34+
try {
35+
act(() =>
36+
root.render(
37+
<CredentialGroupCompletionHandoff completionId={completionId} failure={failure} />
38+
)
39+
)
40+
expect(names).toEqual([`sim:credential-group-oauth:${completionId}`])
41+
expect(postMessage).toHaveBeenCalledExactlyOnceWith(failure ?? 'connected')
42+
expect(closeChannel).toHaveBeenCalledOnce()
43+
expect(closeWindow).toHaveBeenCalledOnce()
44+
} finally {
45+
act(() => root.unmount())
46+
}
47+
}
48+
)
49+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client'
2+
3+
import { useEffect } from 'react'
4+
import {
5+
type CredentialGroupOAuthFailure,
6+
credentialGroupOAuthCompletionChannel,
7+
} from '@/lib/credential-groups/oauth-completion'
8+
9+
interface CredentialGroupCompletionHandoffProps {
10+
completionId: string
11+
failure?: CredentialGroupOAuthFailure
12+
}
13+
14+
/** Notifies the originating tab even when provider navigation has removed window.opener. */
15+
export function CredentialGroupCompletionHandoff({
16+
completionId,
17+
failure,
18+
}: CredentialGroupCompletionHandoffProps) {
19+
useEffect(() => {
20+
const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId))
21+
channel.postMessage(failure ?? 'connected')
22+
channel.close()
23+
window.close()
24+
}, [completionId, failure])
25+
return null
26+
}

apps/sim/app/credential-groups/complete/page.tsx

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,33 @@
11
import { ChipLink } from '@sim/emcn'
2+
import { isValidUuid } from '@sim/utils/id'
23
import type { Metadata } from 'next'
4+
import {
5+
CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES,
6+
isCredentialGroupOAuthFailure,
7+
} from '@/lib/credential-groups/oauth-completion'
38
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
49
import { AuthHeader, AuthShell } from '@/app/(auth)/components'
10+
import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff'
511

612
export const metadata: Metadata = {
713
title: 'Accounts connected',
814
robots: { index: false, follow: false },
915
}
1016

11-
const OAUTH_FAILURE_MESSAGES = {
12-
expired: 'This connection attempt expired. Open Sim and start connecting your account again.',
13-
denied: 'Authorization was canceled. Open Sim to try again.',
14-
account_mismatch: 'Choose the account matching your Sim email address.',
15-
permissions_required: 'All requested permissions are required to connect this account.',
16-
configuration_changed: 'The connection settings changed. Open Sim to try again.',
17-
rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.',
18-
unavailable: 'This connection is unavailable. Open Sim to try again.',
19-
failed: 'Account authorization did not complete. Open Sim to try again.',
20-
} as const
21-
2217
export default async function CredentialGroupCompletePage({
2318
searchParams,
2419
}: {
25-
searchParams: Promise<{ oauth?: string | string[] }>
20+
searchParams: Promise<{ oauth?: string | string[]; completionId?: string | string[] }>
2621
}) {
27-
const { oauth } = await searchParams
28-
const error =
29-
typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth)
30-
? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES]
31-
: undefined
22+
const { oauth, completionId } = await searchParams
23+
const failure =
24+
oauth === undefined ? undefined : isCredentialGroupOAuthFailure(oauth) ? oauth : 'failed'
25+
const error = failure ? CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure] : undefined
3226
return (
3327
<AuthShell>
28+
{typeof completionId === 'string' && isValidUuid(completionId) && (
29+
<CredentialGroupCompletionHandoff completionId={completionId} failure={failure} />
30+
)}
3431
<AuthHeader
3532
title={error ? 'Account not connected' : 'Accounts connected'}
3633
description={error ?? 'Your accounts are ready to use — you can close this tab.'}

0 commit comments

Comments
 (0)