Skip to content

Commit fe856cb

Browse files
committed
fix(search): admin-gated first connect on the surfaces, live indexing state, honest filters and loading
Route errors redact bound values from the outer message too; a source nobody connected is offered only to an admin and others see why; a first connect refreshes the base's connector list; member connectors poll while a source is indexing for the viewer; a filter from a shared link applies even when the controls would not appear; results kept from the previous query show as loading.
1 parent e15d551 commit fe856cb

8 files changed

Lines changed: 65 additions & 15 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/components/knowledge-search-results/knowledge-search-results.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ export function KnowledgeSearchResults({
190190
data: results,
191191
isPending,
192192
isFetching,
193+
isPlaceholderData,
193194
error,
194195
} = useWorkspaceKnowledgeSearch(workspaceId, knowledgeBaseIds, query)
195196
const { features } = useWorkspaceHostContext()
@@ -213,9 +214,12 @@ export function KnowledgeSearchResults({
213214
[documents]
214215
)
215216
const [filters, setFilters] = useQueryStates(searchFilterParsers, resourceUrlKeys)
216-
const showFilters = documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1
217+
const filtersActive = filters.source !== null || filters.updated !== 'any'
218+
/** The controls appear once the list is long and mixed, and stay while a filter from the link is active. */
219+
const showFilters =
220+
filtersActive || (documents.length >= FILTERS_MIN_RESULTS && sourceTypes.length > 1)
217221
const visible = useMemo(() => {
218-
if (!showFilters) return documents
222+
if (!filtersActive) return documents
219223
const window = UPDATED_WINDOWS.find((entry) => entry.id === filters.updated)
220224
const cutoff = window?.days ? Date.now() - window.days * DAY_MS : null
221225
return documents.filter((result) => {
@@ -226,7 +230,7 @@ export function KnowledgeSearchResults({
226230
}
227231
return true
228232
})
229-
}, [documents, showFilters, filters.source, filters.updated])
233+
}, [documents, filtersActive, filters.source, filters.updated])
230234

231235
const failure = basesError ?? error
232236
if (failure) {
@@ -239,7 +243,8 @@ export function KnowledgeSearchResults({
239243
</p>
240244
)
241245
}
242-
if (isPending || (isFetching && !results)) {
246+
/** Kept results belong to the previous query; a new query shows its own state. */
247+
if (isPending || isPlaceholderData || (isFetching && !results)) {
243248
return <p className='px-2 py-2 text-[var(--text-muted)] text-caption'>Searching…</p>
244249
}
245250

apps/sim/app/workspace/[workspaceId]/home/components/search-sources/search-sources.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
useWorkspaceMemberConnectors,
1919
type WorkspaceMemberConnector,
2020
} from '@/hooks/queries/kb/connectors'
21+
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
2122
import { CONNECTABLE_MEMBERSHIPS, useMemberEnrollment } from '@/hooks/use-member-enrollment'
2223
import { usePermissionConfig } from '@/hooks/use-permission-config'
2324

@@ -145,6 +146,9 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
145146
* off, a connect is refused, so the chips say so instead of offering one.
146147
*/
147148
const memberAccessAvailable = features?.knowledgeMemberAccess === true
149+
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId)
150+
/** The first connect of a source turns it on for the workspace, which takes an admin. */
151+
const canCreate = workspacePermissions?.viewer?.isAdmin ?? false
148152
const { data: memberConnectorRows } = useWorkspaceMemberConnectors(workspaceId, {
149153
enabled: memberAccessAvailable,
150154
})
@@ -198,7 +202,7 @@ export function SearchSources({ workspaceId }: SearchSourcesProps) {
198202
unavailableReason={searchConnectorUnavailableReason(
199203
connector,
200204
integrationAvailability,
201-
memberAccessAvailable
205+
{ memberAccessAvailable, hasConnection: connection !== undefined, canCreate }
202206
)}
203207
waiting={
204208
connection ? isAwaiting(connection.connectorId) : isAwaitingSource(connector.type)

apps/sim/app/workspace/[workspaceId]/search/search.test.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ vi.mock('nuqs', () => ({
2323
vi.mock('@/hooks/use-debounced-search-setter', () => ({
2424
useDebouncedSearchSetter: (write: (value: string) => void) => write,
2525
}))
26+
vi.mock('@/hooks/queries/workspace', () => ({
27+
useWorkspacePermissionsQuery: () => ({ data: { viewer: { isAdmin: true } } }),
28+
}))
2629
vi.mock('@/hooks/use-permission-config', () => ({
2730
usePermissionConfig: () => ({
2831
integrationAvailability: new Map([
@@ -77,13 +80,15 @@ vi.mock('@/lib/sim-search/connectors', () => {
7780
searchConnectorUnavailableReason: (
7881
candidate: { blockType: string; meta: { name: string } },
7982
availability: ReadonlyMap<string, { oauthAvailable: boolean }>,
80-
memberAccessAvailable: boolean
83+
context: { memberAccessAvailable: boolean; hasConnection: boolean; canCreate: boolean }
8184
) =>
8285
!isSearchConnectorAvailable(candidate, availability)
8386
? `${candidate.meta.name} is unavailable in this deployment`
84-
: memberAccessAvailable
85-
? null
86-
: 'Per-member access is not available in this workspace',
87+
: !context.memberAccessAvailable
88+
? 'Per-member access is not available in this workspace'
89+
: !context.hasConnection && !context.canCreate
90+
? `Ask a workspace admin to connect ${candidate.meta.name} first`
91+
: null,
8792
SEARCH_CONNECTORS: [
8893
connector('google_drive', 'Google Drive', 'Sync Drive files', true),
8994
connector('confluence', 'Confluence', 'Sync Confluence pages', false),

apps/sim/app/workspace/[workspaceId]/search/search.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
useWorkspaceMemberConnectors,
3232
type WorkspaceMemberConnector,
3333
} from '@/hooks/queries/kb/connectors'
34+
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
3435
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
3536
import {
3637
CONNECTABLE_MEMBERSHIPS,
@@ -129,6 +130,9 @@ export function Search() {
129130
* one and the memberships are not fetched.
130131
*/
131132
const memberAccessAvailable = features?.knowledgeMemberAccess === true
133+
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId)
134+
/** The first connect of a source turns it on for the workspace, which takes an admin. */
135+
const canCreate = workspacePermissions?.viewer?.isAdmin ?? false
132136

133137
const [searchTerm, setSearchTermParam] = useQueryState(connectorSearchParam.key, {
134138
...connectorSearchParam.parser,
@@ -242,7 +246,11 @@ export function Search() {
242246
unavailableReason={searchConnectorUnavailableReason(
243247
connector,
244248
integrationAvailability,
245-
memberAccessAvailable
249+
{
250+
memberAccessAvailable,
251+
hasConnection: connection !== undefined,
252+
canCreate,
253+
}
246254
)}
247255
waiting={
248256
connection

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,8 @@ export const memberConnectorKeys = {
372372
}
373373

374374
export const WORKSPACE_MEMBER_CONNECTORS_STALE_TIME = 30 * 1000
375+
/** While a connected source is still indexing for the viewer, its state is worth asking for again. */
376+
const WORKSPACE_MEMBER_CONNECTORS_INDEXING_POLL_MS = 5 * 1000
375377

376378
async function fetchWorkspaceMemberConnectors(
377379
workspaceId: string,
@@ -394,6 +396,14 @@ export function useWorkspaceMemberConnectors(
394396
queryFn: ({ signal }) => fetchWorkspaceMemberConnectors(workspaceId as string, signal),
395397
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
396398
staleTime: WORKSPACE_MEMBER_CONNECTORS_STALE_TIME,
399+
refetchInterval: (query) =>
400+
query.state.data?.some(
401+
(connector) =>
402+
connector.viewerMembership === 'connected' &&
403+
(connector.memberSyncStatus === 'pending' || connector.memberSyncStatus === 'running')
404+
)
405+
? WORKSPACE_MEMBER_CONNECTORS_INDEXING_POLL_MS
406+
: false,
397407
placeholderData: keepPreviousData,
398408
})
399409
}
@@ -703,6 +713,10 @@ export function useConnectSimSearchConnector() {
703713
const queryClient = useQueryClient()
704714
return useMutation({
705715
mutationFn: connectSimSearchConnector,
716+
onSuccess: (data) => {
717+
/** A first connect added a connector to the base; its own list is open on the settings page. */
718+
queryClient.invalidateQueries({ queryKey: connectorKeys.all(data.knowledgeBaseId) })
719+
},
706720
onSettled: () => {
707721
queryClient.invalidateQueries({ queryKey: memberConnectorKeys.lists() })
708722
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() })

apps/sim/lib/core/utils/with-route-handler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createLogger, runWithRequestContext } from '@sim/logger'
2-
import { describeError, getErrorMessage } from '@sim/utils/errors'
2+
import { describeError, getErrorMessage, redactBoundParameters } from '@sim/utils/errors'
33
import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
55
import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context'
@@ -133,7 +133,8 @@ export function withRouteHandler<T>(
133133
response = await withPermissionGroupScope(() => handler(request, context))
134134
} catch (error) {
135135
const duration = Date.now() - startTime
136-
const message = getErrorMessage(error, 'Unknown error')
136+
/** A query failure names its bound values in the message; they are user data. */
137+
const message = redactBoundParameters(getErrorMessage(error, 'Unknown error'))
137138
const detail = errorDetail(error)
138139
if (request.signal.aborted) {
139140
logger.info('Client closed request', { duration, status: 499 })

apps/sim/lib/sim-search/connectors.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,29 @@ export function connectorDisplayName(connectorType: string): string {
113113
return CONNECTOR_META_REGISTRY[connectorType]?.name ?? connectorType
114114
}
115115

116+
export interface SearchConnectorAvailabilityContext {
117+
/** Whether per-member access is on for the workspace. */
118+
memberAccessAvailable: boolean
119+
/** Whether someone already connected this source in the workspace. */
120+
hasConnection: boolean
121+
/** Whether the viewer may turn a source on for the workspace; the first connect needs an admin. */
122+
canCreate: boolean
123+
}
124+
116125
/** Why a source cannot be connected on this surface right now; null when it can. */
117126
export function searchConnectorUnavailableReason(
118127
connector: SearchConnector,
119128
integrationAvailability: ReadonlyMap<string, { oauthAvailable: boolean }>,
120-
memberAccessAvailable: boolean
129+
context: SearchConnectorAvailabilityContext
121130
): string | null {
122131
if (!isSearchConnectorAvailable(connector, integrationAvailability)) {
123132
return `${connector.meta.name} is unavailable in this deployment`
124133
}
125-
return memberAccessAvailable ? null : 'Per-member access is not available in this workspace'
134+
if (!context.memberAccessAvailable) return 'Per-member access is not available in this workspace'
135+
if (!context.hasConnection && !context.canCreate) {
136+
return `Ask a workspace admin to connect ${connector.meta.name} first`
137+
}
138+
return null
126139
}
127140

128141
/**

packages/utils/src/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export function describeError(error: unknown): DescribedError {
100100
}
101101

102102
/** Replaces a driver-appended `params: <values>` tail with a redaction marker. */
103-
function redactBoundParameters(message: string): string {
103+
export function redactBoundParameters(message: string): string {
104104
const index = message.indexOf('\nparams:')
105105
return index === -1 ? message : `${message.slice(0, index)}\nparams: [redacted]`
106106
}

0 commit comments

Comments
 (0)