Skip to content

Commit 7050b1a

Browse files
improvement(search): simplify personal integrations and source setup
1 parent 0e79b0c commit 7050b1a

48 files changed

Lines changed: 1878 additions & 439 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/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 }),
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.'}

apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ interface OrganizationPageProps {
3434
tabs?: readonly OrganizationPageTab[]
3535
/** The page's primary action, a chip. Omit for a page without one. */
3636
action?: ReactNode
37+
/** Keep the search field visible on pages whose main content is a searchable list. */
38+
searchMode?: 'collapsible' | 'expanded'
39+
searchPlaceholder?: string
3740
children?: ReactNode
3841
}
3942

@@ -53,6 +56,8 @@ export function OrganizationPage({
5356
description,
5457
tabs,
5558
action,
59+
searchMode = 'collapsible',
60+
searchPlaceholder = 'Search',
5661
children,
5762
}: OrganizationPageProps) {
5863
const scrollContainerRef = useRef<HTMLDivElement>(null)
@@ -71,7 +76,7 @@ export function OrganizationPage({
7176
* viewer opened and has not dismissed.
7277
*/
7378
const [searchOpened, setSearchOpened] = useState(false)
74-
const searchOpen = searchOpened || search.length > 0
79+
const searchOpen = searchMode === 'expanded' || searchOpened || search.length > 0
7580

7681
const closeSearch = () => {
7782
setSearch('')
@@ -99,7 +104,8 @@ export function OrganizationPage({
99104
ref={tabsRef}
100105
className={cn(
101106
scrollFadeXClass,
102-
'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
107+
'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
108+
searchMode === 'expanded' && !tabs?.length && 'hidden'
103109
)}
104110
{...scrollFadeAttributes(tabEdges)}
105111
>
@@ -119,32 +125,39 @@ export function OrganizationPage({
119125
)
120126
})}
121127
</div>
122-
<div className='flex shrink-0 items-center gap-1.5'>
128+
<div
129+
className={cn(
130+
'flex items-center gap-1.5',
131+
searchMode === 'expanded' ? 'min-w-0 flex-1' : 'shrink-0'
132+
)}
133+
>
123134
{searchOpen ? (
124135
<ChipInput
125-
autoFocus
136+
autoFocus={searchMode === 'collapsible'}
126137
icon={Search}
127138
value={search}
128-
placeholder='Search'
139+
placeholder={searchPlaceholder}
129140
aria-label='Search'
130141
spellCheck={false}
131142
autoComplete='off'
132-
className='w-[240px]'
143+
className={searchMode === 'expanded' ? 'w-full' : 'w-[240px]'}
133144
onChange={(event) => setSearch(event.target.value)}
134145
onKeyDown={(event) => {
135146
if (event.key === 'Escape') closeSearch()
136147
}}
137148
endAdornment={
138-
<Button
139-
type='button'
140-
variant='quiet'
141-
size='icon'
142-
className='-mr-1 shrink-0'
143-
aria-label='Close search'
144-
onClick={closeSearch}
145-
>
146-
<X className='size-[14px]' />
147-
</Button>
149+
(searchMode === 'collapsible' || search.length > 0) && (
150+
<Button
151+
type='button'
152+
variant='quiet'
153+
size='icon'
154+
className='-mr-1 shrink-0'
155+
aria-label={searchMode === 'expanded' ? 'Clear search' : 'Close search'}
156+
onClick={closeSearch}
157+
>
158+
<X className='size-[14px]' />
159+
</Button>
160+
)
148161
}
149162
/>
150163
) : (

0 commit comments

Comments
 (0)