Skip to content

Commit 0a769e9

Browse files
committed
fix(knowledge): let members connect their own account to a member-access connector
1 parent 395a394 commit 0a769e9

4 files changed

Lines changed: 156 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: 88 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,89 @@ 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 its refresh waits for the viewer to return to this tab.
487+
*/
468488
export function useStartConnectorMemberEnrollment() {
469489
const queryClient = useQueryClient()
490+
const pending = useRef<AbortController | null>(null)
491+
useEffect(() => () => pending.current?.abort(), [])
492+
const refresh = (knowledgeBaseId: string) =>
493+
Promise.all([
494+
queryClient.invalidateQueries({ queryKey: connectorKeys.lists(knowledgeBaseId) }),
495+
queryClient.invalidateQueries({ queryKey: credentialGroupKeys.details() }),
496+
queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }),
497+
queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }),
498+
])
470499
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-
]),
500+
mutationFn: async ({
501+
knowledgeBaseId,
502+
connectorId,
503+
directOAuth,
504+
}: ConnectMemberAccountParams) => {
505+
pending.current?.abort()
506+
const controller = new AbortController()
507+
pending.current = controller
508+
const params = { id: knowledgeBaseId, connectorId }
509+
const completionId = directOAuth ? generateId() : undefined
510+
if (isDesktopApp()) {
511+
await connectDesktopSource(
512+
{ kind: 'member-enrollment', params, completionId },
513+
controller.signal
514+
)
515+
return true
516+
}
517+
if (completionId) {
518+
await connectCredentialGroupInPopup(
519+
completionId,
520+
async (signal) => ({
521+
invitationLink: (
522+
await startConnectorMemberEnrollment({
523+
knowledgeBaseId,
524+
connectorId,
525+
oauthCompletionId: completionId,
526+
signal,
527+
})
528+
).url,
529+
}),
530+
controller.signal
531+
)
532+
return true
533+
}
534+
const tab = window.open('about:blank', '_blank')
535+
if (tab) tab.opener = null
536+
try {
537+
const { url } = await startConnectorMemberEnrollment({
538+
knowledgeBaseId,
539+
connectorId,
540+
signal: controller.signal,
541+
})
542+
if (tab) tab.location.replace(url)
543+
else window.location.assign(url)
544+
} catch (error) {
545+
tab?.close()
546+
throw error
547+
}
548+
const refreshOnReturn = () => {
549+
if (document.visibilityState !== 'visible') return
550+
document.removeEventListener('visibilitychange', refreshOnReturn)
551+
void refresh(knowledgeBaseId)
552+
}
553+
document.addEventListener('visibilitychange', refreshOnReturn)
554+
return false
555+
},
556+
onSettled: (completed, _error, { knowledgeBaseId }) =>
557+
completed === false ? undefined : refresh(knowledgeBaseId),
478558
})
479559
}
480560

‎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)