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,94 @@ 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 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() + 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 refreshUntilConnected = async () => { + if (document.visibilityState !== 'visible') return + await refresh(knowledgeBaseId) + const connector = getCachedConnector(queryClient, knowledgeBaseId, connectorId) + if (connector?.viewerMembership === 'connected') controller.abort() + } + document.addEventListener('visibilitychange', () => void refreshUntilConnected(), { + signal: controller.signal, + }) + 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",