Skip to content

Commit 133c671

Browse files
authored
fix(knowledge): let members connect their own account to a member-access connector (#9602)
* fix(knowledge): let members connect their own account to a member-access connector * fix(knowledge): keep watching member enrollment until it connects
1 parent 1b72781 commit 133c671

4 files changed

Lines changed: 161 additions & 9 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
3+
import { Chip } from '@sim/emcn'
4+
import type { ViewerConnectorMembership } from '@/lib/api/contracts/knowledge/connectors'
5+
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
6+
import { useStartConnectorMemberEnrollment } from '@/hooks/queries/kb/connectors'
7+
8+
type UnconnectedMembership = Exclude<ViewerConnectorMembership, 'connected'>
9+
10+
/** Revoked and unverified viewers get the server's refusal as the row, with no action to retry. */
11+
const MEMBERSHIP_STATES: Record<UnconnectedMembership, { title: string; action?: string }> = {
12+
not_enrolled: { title: 'Your account is not connected', action: 'Connect your account' },
13+
invited: { title: 'Your account is not connected', action: 'Connect your account' },
14+
needs_reauth: { title: 'Your account needs to be reconnected', action: 'Reconnect' },
15+
revoked: { title: 'An admin removed your access to Connected accounts' },
16+
unverified_email: { title: 'Verify your email address before connecting an account' },
17+
}
18+
19+
interface ConnectorMemberEnrollmentProps {
20+
knowledgeBaseId: string
21+
connectorId: string
22+
isSearchIndex: boolean
23+
membership: UnconnectedMembership
24+
}
25+
26+
export function ConnectorMemberEnrollment({
27+
knowledgeBaseId,
28+
connectorId,
29+
isSearchIndex,
30+
membership,
31+
}: ConnectorMemberEnrollmentProps) {
32+
const enrollment = useStartConnectorMemberEnrollment()
33+
const { title, action } = MEMBERSHIP_STATES[membership]
34+
35+
return (
36+
<>
37+
<SettingsResourceRow
38+
title={title}
39+
trailing={
40+
action ? (
41+
<Chip
42+
disabled={enrollment.isPending}
43+
onClick={() =>
44+
enrollment.mutate({ knowledgeBaseId, connectorId, directOAuth: isSearchIndex })
45+
}
46+
>
47+
{action}
48+
</Chip>
49+
) : undefined
50+
}
51+
/>
52+
{enrollment.error && (
53+
<p role='alert' className='text-[var(--text-error)] text-caption'>
54+
{enrollment.error.message}
55+
</p>
56+
)}
57+
</>
58+
)
59+
}

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ConnectorActionFeedback,
1313
ConnectorActions,
1414
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions'
15+
import { ConnectorMemberEnrollment } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment'
1516
import { ConnectorRecovery } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery'
1617
import { ConnectorSyncHistory } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history'
1718
import { getConnectorSyncState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-state'
@@ -171,6 +172,14 @@ function ConnectorCard({
171172
Updating access
172173
</span>
173174
)}
175+
{connector.viewerMembership && connector.viewerMembership !== 'connected' && (
176+
<ConnectorMemberEnrollment
177+
knowledgeBaseId={knowledgeBaseId}
178+
connectorId={connector.id}
179+
isSearchIndex={isSearchIndex}
180+
membership={connector.viewerMembership}
181+
/>
182+
)}
174183
<ConnectorRecovery
175184
connector={connector}
176185
scope={scope}

‎apps/sim/hooks/queries/kb/connectors.ts‎

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useEffect, useRef } from 'react'
2+
import { generateId } from '@sim/utils/id'
13
import {
24
keepPreviousData,
35
type QueryClient,
@@ -48,6 +50,9 @@ import {
4850
resourceScopeFromOwner,
4951
resourceScopeKey,
5052
} from '@/lib/core/resource-scope'
53+
import { connectCredentialGroupInPopup } from '@/lib/credential-groups/oauth-popup'
54+
import { isDesktopApp } from '@/lib/desktop'
55+
import { connectDesktopSource } from '@/lib/desktop/source-connect'
5156
import { MAX_KNOWLEDGE_CONNECTOR_DOCUMENT_PAGE_SIZE } from '@/lib/knowledge/constants'
5257
import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts'
5358
import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries'
@@ -388,16 +393,19 @@ async function updateConnectorAccess({
388393
interface StartConnectorMemberEnrollmentParams extends SearchConnectionOAuthQuery {
389394
knowledgeBaseId: string
390395
connectorId: string
396+
signal?: AbortSignal
391397
}
392398

393399
async function startConnectorMemberEnrollment({
394400
knowledgeBaseId,
395401
connectorId,
396402
oauthCompletionId,
403+
signal,
397404
}: StartConnectorMemberEnrollmentParams): Promise<StartKnowledgeConnectorMemberEnrollmentData> {
398405
const response = await requestJson(startKnowledgeConnectorMemberEnrollmentContract, {
399406
params: { id: knowledgeBaseId, connectorId },
400407
query: { oauthCompletionId },
408+
signal,
401409
})
402410
return response.data
403411
}
@@ -464,17 +472,94 @@ export function useSearchSources(
464472
})
465473
}
466474

467-
/** Mints the viewer's enrollment link for a per-member connector; the caller navigates to it. */
475+
interface ConnectMemberAccountParams {
476+
knowledgeBaseId: string
477+
connectorId: string
478+
/** Search sources only: the server opens provider OAuth directly and reports its completion. */
479+
directOAuth: boolean
480+
}
481+
482+
/**
483+
* Connects the viewer's own account to a per-member connector. Desktop and
484+
* direct OAuth settle when the connection completes. An ordinary knowledge
485+
* base hands the viewer an invitation page in a new tab that reports nothing
486+
* back, so it refreshes each time the viewer returns to this tab until the
487+
* viewer's membership reads `connected`. The listener belongs to the attempt's
488+
* abort signal, so a newer attempt or unmount removes it.
489+
*/
468490
export function useStartConnectorMemberEnrollment() {
469491
const queryClient = useQueryClient()
492+
const pending = useRef<AbortController | null>(null)
493+
useEffect(() => () => pending.current?.abort(), [])
494+
const refresh = (knowledgeBaseId: string) =>
495+
Promise.all([
496+
queryClient.invalidateQueries({ queryKey: connectorKeys.lists(knowledgeBaseId) }),
497+
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.details() }),
498+
queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }),
499+
queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }),
500+
])
470501
return useMutation({
471-
mutationFn: startConnectorMemberEnrollment,
472-
onSuccess: () =>
473-
Promise.all([
474-
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.details() }),
475-
queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }),
476-
queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }),
477-
]),
502+
mutationFn: async ({
503+
knowledgeBaseId,
504+
connectorId,
505+
directOAuth,
506+
}: ConnectMemberAccountParams) => {
507+
pending.current?.abort()
508+
const controller = new AbortController()
509+
pending.current = controller
510+
const params = { id: knowledgeBaseId, connectorId }
511+
const completionId = directOAuth ? generateId() : undefined
512+
if (isDesktopApp()) {
513+
await connectDesktopSource(
514+
{ kind: 'member-enrollment', params, completionId },
515+
controller.signal
516+
)
517+
return true
518+
}
519+
if (completionId) {
520+
await connectCredentialGroupInPopup(
521+
completionId,
522+
async (signal) => ({
523+
invitationLink: (
524+
await startConnectorMemberEnrollment({
525+
knowledgeBaseId,
526+
connectorId,
527+
oauthCompletionId: completionId,
528+
signal,
529+
})
530+
).url,
531+
}),
532+
controller.signal
533+
)
534+
return true
535+
}
536+
const tab = window.open('about:blank', '_blank')
537+
if (tab) tab.opener = null
538+
try {
539+
const { url } = await startConnectorMemberEnrollment({
540+
knowledgeBaseId,
541+
connectorId,
542+
signal: controller.signal,
543+
})
544+
if (tab) tab.location.replace(url)
545+
else window.location.assign(url)
546+
} catch (error) {
547+
tab?.close()
548+
throw error
549+
}
550+
const refreshUntilConnected = async () => {
551+
if (document.visibilityState !== 'visible') return
552+
await refresh(knowledgeBaseId)
553+
const connector = getCachedConnector(queryClient, knowledgeBaseId, connectorId)
554+
if (connector?.viewerMembership === 'connected') controller.abort()
555+
}
556+
document.addEventListener('visibilitychange', () => void refreshUntilConnected(), {
557+
signal: controller.signal,
558+
})
559+
return false
560+
},
561+
onSettled: (completed, _error, { knowledgeBaseId }) =>
562+
completed === false ? undefined : refresh(knowledgeBaseId),
478563
})
479564
}
480565

‎scripts/check-unused-exports.baseline.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1005,7 +1005,6 @@
10051005
"apps/sim/hooks/queries/kb/connectors.ts#ViewerConnectorMembership",
10061006
"apps/sim/hooks/queries/kb/connectors.ts#connectorDocumentKeys",
10071007
"apps/sim/hooks/queries/kb/connectors.ts#searchIndexKeys",
1008-
"apps/sim/hooks/queries/kb/connectors.ts#useStartConnectorMemberEnrollment",
10091008
"apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_CHUNK_LIST_STALE_TIME",
10101009
"apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_CHUNK_SEARCH_STALE_TIME",
10111010
"apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME",

0 commit comments

Comments
 (0)