From 0a769e97255e1709bf06251531db58bb7c3c4912 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:41:51 -0800 Subject: [PATCH 1/2] fix(knowledge): let members connect their own account to a member-access connector --- .../connector-member-enrollment.tsx | 59 ++++++++++++ .../connectors-section/connectors-section.tsx | 9 ++ apps/sim/hooks/queries/kb/connectors.ts | 96 +++++++++++++++++-- scripts/check-unused-exports.baseline.json | 1 - 4 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment.tsx new file mode 100644 index 00000000000..dae1a7f0c73 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment.tsx @@ -0,0 +1,59 @@ +'use client' + +import { Chip } from '@sim/emcn' +import type { ViewerConnectorMembership } from '@/lib/api/contracts/knowledge/connectors' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useStartConnectorMemberEnrollment } from '@/hooks/queries/kb/connectors' + +type UnconnectedMembership = Exclude + +/** Revoked and unverified viewers get the server's refusal as the row, with no action to retry. */ +const MEMBERSHIP_STATES: Record = { + not_enrolled: { title: 'Your account is not connected', action: 'Connect your account' }, + invited: { title: 'Your account is not connected', action: 'Connect your account' }, + needs_reauth: { title: 'Your account needs to be reconnected', action: 'Reconnect' }, + revoked: { title: 'An admin removed your access to Connected accounts' }, + unverified_email: { title: 'Verify your email address before connecting an account' }, +} + +interface ConnectorMemberEnrollmentProps { + knowledgeBaseId: string + connectorId: string + isSearchIndex: boolean + membership: UnconnectedMembership +} + +export function ConnectorMemberEnrollment({ + knowledgeBaseId, + connectorId, + isSearchIndex, + membership, +}: ConnectorMemberEnrollmentProps) { + const enrollment = useStartConnectorMemberEnrollment() + const { title, action } = MEMBERSHIP_STATES[membership] + + return ( + <> + + enrollment.mutate({ knowledgeBaseId, connectorId, directOAuth: isSearchIndex }) + } + > + {action} + + ) : undefined + } + /> + {enrollment.error && ( +

+ {enrollment.error.message} +

+ )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx index b2848f37e64..5eec846a5e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connectors-section.tsx @@ -12,6 +12,7 @@ import { ConnectorActionFeedback, ConnectorActions, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-actions' +import { ConnectorMemberEnrollment } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-member-enrollment' import { ConnectorRecovery } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-recovery' import { ConnectorSyncHistory } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-history' import { getConnectorSyncState } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connectors-section/connector-sync-state' @@ -171,6 +172,14 @@ function ConnectorCard({ Updating access )} + {connector.viewerMembership && connector.viewerMembership !== 'connected' && ( + + )} { const response = await requestJson(startKnowledgeConnectorMemberEnrollmentContract, { params: { id: knowledgeBaseId, connectorId }, query: { oauthCompletionId }, + signal, }) return response.data } @@ -464,17 +472,89 @@ export function useSearchSources( }) } -/** Mints the viewer's enrollment link for a per-member connector; the caller navigates to it. */ +interface ConnectMemberAccountParams { + knowledgeBaseId: string + connectorId: string + /** Search sources only: the server opens provider OAuth directly and reports its completion. */ + directOAuth: boolean +} + +/** + * Connects the viewer's own account to a per-member connector. Desktop and + * direct OAuth settle when the connection completes. An ordinary knowledge + * base hands the viewer an invitation page in a new tab that reports nothing + * back, so its refresh waits for the viewer to return to this tab. + */ export function useStartConnectorMemberEnrollment() { const queryClient = useQueryClient() + const pending = useRef(null) + useEffect(() => () => pending.current?.abort(), []) + const refresh = (knowledgeBaseId: string) => + Promise.all([ + queryClient.invalidateQueries({ queryKey: connectorKeys.lists(knowledgeBaseId) }), + queryClient.invalidateQueries({ queryKey: credentialGroupKeys.details() }), + queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }), + queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }), + ]) return useMutation({ - mutationFn: startConnectorMemberEnrollment, - onSuccess: () => - Promise.all([ - queryClient.invalidateQueries({ queryKey: credentialGroupKeys.details() }), - queryClient.invalidateQueries({ queryKey: searchSourceKeys.lists() }), - queryClient.invalidateQueries({ queryKey: searchIntegrationKeys.lists() }), - ]), + mutationFn: async ({ + knowledgeBaseId, + connectorId, + directOAuth, + }: ConnectMemberAccountParams) => { + pending.current?.abort() + const controller = new AbortController() + pending.current = controller + const params = { id: knowledgeBaseId, connectorId } + const completionId = directOAuth ? generateId() : undefined + if (isDesktopApp()) { + await connectDesktopSource( + { kind: 'member-enrollment', params, completionId }, + controller.signal + ) + return true + } + if (completionId) { + await connectCredentialGroupInPopup( + completionId, + async (signal) => ({ + invitationLink: ( + await startConnectorMemberEnrollment({ + knowledgeBaseId, + connectorId, + oauthCompletionId: completionId, + signal, + }) + ).url, + }), + controller.signal + ) + return true + } + const tab = window.open('about:blank', '_blank') + if (tab) tab.opener = null + try { + const { url } = await startConnectorMemberEnrollment({ + knowledgeBaseId, + connectorId, + signal: controller.signal, + }) + if (tab) tab.location.replace(url) + else window.location.assign(url) + } catch (error) { + tab?.close() + throw error + } + const refreshOnReturn = () => { + if (document.visibilityState !== 'visible') return + document.removeEventListener('visibilitychange', refreshOnReturn) + void refresh(knowledgeBaseId) + } + document.addEventListener('visibilitychange', refreshOnReturn) + return false + }, + onSettled: (completed, _error, { knowledgeBaseId }) => + completed === false ? undefined : refresh(knowledgeBaseId), }) } diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index e4448b682b2..56105623f85 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1005,7 +1005,6 @@ "apps/sim/hooks/queries/kb/connectors.ts#ViewerConnectorMembership", "apps/sim/hooks/queries/kb/connectors.ts#connectorDocumentKeys", "apps/sim/hooks/queries/kb/connectors.ts#searchIndexKeys", - "apps/sim/hooks/queries/kb/connectors.ts#useStartConnectorMemberEnrollment", "apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_CHUNK_LIST_STALE_TIME", "apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_CHUNK_SEARCH_STALE_TIME", "apps/sim/hooks/queries/kb/knowledge.ts#KNOWLEDGE_DOCUMENT_DETAIL_STALE_TIME", From 5865b444b73d2f3aef5cb4d2e2bc55d570c12cc8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:58:51 -0800 Subject: [PATCH 2/2] fix(knowledge): keep watching member enrollment until it connects --- apps/sim/hooks/queries/kb/connectors.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/sim/hooks/queries/kb/connectors.ts b/apps/sim/hooks/queries/kb/connectors.ts index fd57db6f1f7..a8b017ff736 100644 --- a/apps/sim/hooks/queries/kb/connectors.ts +++ b/apps/sim/hooks/queries/kb/connectors.ts @@ -483,7 +483,9 @@ interface ConnectMemberAccountParams { * Connects the viewer's own account to a per-member connector. Desktop and * direct OAuth settle when the connection completes. An ordinary knowledge * base hands the viewer an invitation page in a new tab that reports nothing - * back, so its refresh waits for the viewer to return to this tab. + * back, so it refreshes each time the viewer returns to this tab until the + * viewer's membership reads `connected`. The listener belongs to the attempt's + * abort signal, so a newer attempt or unmount removes it. */ export function useStartConnectorMemberEnrollment() { const queryClient = useQueryClient() @@ -545,12 +547,15 @@ export function useStartConnectorMemberEnrollment() { tab?.close() throw error } - const refreshOnReturn = () => { + const refreshUntilConnected = async () => { if (document.visibilityState !== 'visible') return - document.removeEventListener('visibilitychange', refreshOnReturn) - void refresh(knowledgeBaseId) + await refresh(knowledgeBaseId) + const connector = getCachedConnector(queryClient, knowledgeBaseId, connectorId) + if (connector?.viewerMembership === 'connected') controller.abort() } - document.addEventListener('visibilitychange', refreshOnReturn) + document.addEventListener('visibilitychange', () => void refreshUntilConnected(), { + signal: controller.signal, + }) return false }, onSettled: (completed, _error, { knowledgeBaseId }) =>