Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ jobs:
lib/knowledge/__integration__/member-document-lifecycle.integration.ts
lib/knowledge/__integration__/slack-empty-threads.integration.ts
lib/knowledge/__integration__/kb-block-search.integration.ts
lib/knowledge/__integration__/gitlab-workspace.integration.ts
lib/knowledge/__integration__/unfilled-projection-source.integration.ts
lib/core/outbox/service.integration.ts
lib/knowledge/__integration__/connector-upload.integration.ts
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/content/docs/knowledgebase/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Other connectors use **API keys** or **personal access tokens** instead. The set
| **Fireflies** | Generate from the Integrations page in your Fireflies account |
| **Typeform** | Personal access token from your Typeform account settings |
| **Azure DevOps** | Personal access token with Wiki (Read), Work Items (Read), and Code (Read) scopes |
| **GitLab** | Personal access token with `read_api` scope and access to the selected project |
| **YouTube** | YouTube Data API key from the Google Cloud Console |
| **Amazon S3** | Secret Access Key (the Access Key ID, region, and bucket are entered as config fields) |
| **Sentry** | Auth token with `project:read` and `event:read` scopes |
Expand All @@ -65,10 +66,14 @@ Other connectors use **API keys** or **personal access tokens** instead. The set
| **Databricks** | Personal access token from your workspace's user settings (the workspace host is entered as a config field) |
| **Workday Help** | Register an API client for integrations in your tenant, then enter the client secret and refresh token together as `clientSecret:refreshToken` (the client ID, tenant host, and tenant name are entered as config fields) |

Enter an API key directly, or type `{{` to select an accessible personal or workspace secret. Sim resolves the secret when you connect and stores an encrypted copy of the token. Later changes to the secret do not automatically update the connector.

<Callout type="info">
If you rotate an API key in the external service, update it in Sim as well — OAuth tokens refresh automatically, but API keys do not.
</Callout>

For GitLab in a regular knowledge base, a project-readable PAT is enough. Imported content uses the knowledge base's access rules; it does not mirror each person's GitLab permissions. The administrator-token and non-admin CSV setup paths apply when using [GitLab source permissions](/search/gitlab).

</Step>
<Step>

Expand All @@ -78,6 +83,7 @@ Each connector has source-specific fields that control what gets synced. Example

- **Notion** — sync an entire workspace, a specific database, or a single page tree
- **GitHub** — specify a repository, branch, and optional file extension filter
- **GitLab** — specify a project path or ID and your instance host (leave blank for GitLab.com), then choose repository files, wiki pages, issues, or merge requests. Each connector syncs one project; submodules need their own connectors.
- **Confluence** — enter your Atlassian domain and choose spaces, or **All** for all spaces accessible at each sync. Optionally filter by content type or label. PDF, Word (`.docx`, Word 97–2003 `.doc`), Excel (`.xlsx`), and PowerPoint (`.pptx`) attachments on matching pages and blog posts are included as separate documents.
- **Azure DevOps** — choose what to sync (wiki pages, work items, repository files, or all), with optional work item type/state filters, a custom WIQL query, and repository/branch/path filters
- **Amazon S3** — point at a bucket with an optional key prefix and a customizable file extension allowlist; S3-compatible stores (Cloudflare R2, MinIO) are supported via a custom endpoint
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/hooks/queries/environment', () => ({
usePersonalEnvironment: () => ({ data: {} }),
useWorkspaceEnvironment: () => ({ data: { workspace: {}, personal: {} } }),
}))

const mocks = vi.hoisted(() => ({
canAdmin: true,
hasMaxAccess: true,
Expand Down Expand Up @@ -269,6 +274,7 @@ async function chooseSyncFrequency(label: string) {
async function fill(placeholder: string, value: string) {
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)
expect(input, `Input ${placeholder}`).not.toBeNull()
await act(async () => input?.focus())
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
input?.dispatchEvent(new Event('input', { bubbles: true }))
Expand Down Expand Up @@ -897,16 +903,16 @@ describe('member content credentials in real add and edit dialogs', () => {
)
await click(card!)
expect(document.body.textContent).not.toContain('Connected members')
expect(button('Administrator token')).toHaveAttribute('aria-checked', 'true')
expect(document.body.textContent).not.toContain('Connection method')
expect(document.body.textContent).not.toContain('Administrator token')
expect(document.body.textContent).toContain('Everyone in this workspace')
await fill('Enter your GitLab PAT', 'new-pat')
await fill('gitlab.example.com', 'gitlab.example.test')
await fill('group/project or numeric ID', '1')
expect(button('Connect & Sync')).toBeEnabled()
await click(button('Connect & Sync'))
expect(mocks.create.mock.calls[1][0]).toMatchObject({
connectorType: 'gitlab',
accessMode: 'admin',
accessMode: 'workspace',
apiKey: 'new-pat',
})
expect(mocks.create.mock.calls[1][0].sourceConfig).not.toHaveProperty('excludeChannels')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ const mocks = vi.hoisted(() => ({
| 'ready',
}))

vi.mock('@/hooks/queries/environment', () => ({
usePersonalEnvironment: () => ({ data: {} }),
useWorkspaceEnvironment: () => ({ data: { workspace: { GITLAB_PAT: '***' }, personal: {} } }),
}))
vi.mock('@/hooks/use-settings-navigation', () => ({
useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }),
}))

vi.mock('next/navigation', () => ({
useParams: () => ({ workspaceId: 'workspace-1' }),
usePathname: () => '/o/org-1/settings/integrations',
Expand Down Expand Up @@ -790,6 +798,7 @@ describe('Search setup options', () => {
initialAccessMode: connectorType === 'gitlab' ? 'admin' : 'members',
})

if (connectorType === 'gitlab') expect(document.body.textContent).not.toContain('Sync using')
const primaryFields = configFieldsProps()
for (const fieldId of primary) expect(fieldVisible(primaryFields, fieldId)).toBe(true)
for (const fieldId of optional) expect(fieldVisible(primaryFields, fieldId)).toBe(false)
Expand Down Expand Up @@ -834,21 +843,122 @@ describe('Search setup options', () => {
}
)

it('uses GitLab service-account access and token tabs without an access selector in regular KBs', async () => {
it.each(['fixture-pat', '{{GITLAB_PAT}}'])(
'connects a regular GitLab KB with %s and workspace access',
async (apiKey) => {
mocks.memberAccess = false
mocks.mirroredAccess = false
const sourceConfig = { host: 'gitlab.example.com', project: 'group/project' }
mocks.resolveSourceConfig.mockReturnValue(sourceConfig)
await render({
initialConnectorType: 'gitlab',
initialAccessMode: 'workspace',
isSearchIndex: false,
})
expect(document.body.textContent).not.toContain('Administrator token')
expect(document.body.textContent).not.toContain('Non-admin token')
expect(document.body.textContent).not.toContain('User mapping')
expect(document.body.textContent).not.toContain('Project permissions')
expect(document.body.textContent).not.toContain('Connection method')
expect(document.body.textContent).toContain('Sync Frequency')
const input = document.querySelector<HTMLInputElement>(
'input[placeholder="Enter your GitLab PAT"]'
)!
await act(async () => input.focus())
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
input,
apiKey
)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => button('Connect & Sync').click())
expect(mocks.create).toHaveBeenCalledWith(
{
knowledgeBaseId: 'kb-search',
connectorType: 'gitlab',
apiKey,
sourceConfig,
syncIntervalMinutes: 1440,
accessMode: 'workspace',
},
expect.any(Object)
)
}
)

it('selects a saved secret with the shared picker without submitting on Enter', async () => {
await render({
initialConnectorType: 'gitlab',
initialAccessMode: 'workspace',
isSearchIndex: false,
})
expect(document.body.textContent).toContain('Administrator token')
expect(document.body.textContent).toContain('Non-admin token')
expect(document.body.textContent).not.toContain('Connection method')
expect(button('More options')).toHaveAttribute('aria-expanded', 'false')
expect(document.body.textContent).not.toContain('Sync Frequency')
await act(async () => button('More options').click())
expect(document.body.textContent).toContain('Sync Frequency')
const input = document.querySelector<HTMLInputElement>(
'input[placeholder="Enter your GitLab PAT"]'
)!
await act(async () => input.focus())
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
input,
'{{GIT'
)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(document.body.textContent).toContain('GITLAB_PAT')
expect(document.querySelector('span[class="text-[var(--brand-secondary)]"]')).toBeNull()
await act(async () => {
input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
input.setSelectionRange(0, 0)
input.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
})
expect(document.querySelector('[role="menuitem"]')).toBeNull()
expect(input.value).toBe('{{GIT')
await act(async () => {
input.setSelectionRange(input.value.length, input.value.length)
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'End', bubbles: true }))
})
expect(document.body.textContent).toContain('GITLAB_PAT')
await act(async () =>
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
)
expect(input.value).toBe('{{GITLAB_PAT}}')
expect(document.querySelector('span[class="text-[var(--brand-secondary)]"]')).toHaveTextContent(
'{{GITLAB_PAT}}'
)
expect(mocks.create).not.toHaveBeenCalled()
await act(async () => button('Connect & Sync').click())
expect(mocks.create).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: '{{GITLAB_PAT}}', accessMode: 'workspace' }),
expect.any(Object)
)
})

it.each(['{{', '{{MISSING_SECRET}}', 'literal-pat'])(
'does not highlight unresolved or literal API-key text: %s',
async (value) => {
await render({
initialConnectorType: 'gitlab',
initialAccessMode: 'workspace',
isSearchIndex: false,
})
const input = document.querySelector<HTMLInputElement>(
'input[placeholder="Enter your GitLab PAT"]'
)!
await act(async () => input.focus())
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
input,
value
)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(document.querySelector('span[class="text-[var(--brand-secondary)]"]')).toBeNull()
await act(async () => input.blur())
expect(input.value).toBe('•'.repeat(value.length))
expect(document.body.textContent).not.toContain(value)
}
)

it('keeps administrator-required fields in the primary form even if metadata marks them optional', async () => {
mocks.credentials = [{ id: 'service', name: 'Indexing account', type: 'service_account' }]
await render({ initialConnectorType: 'google_drive', initialAccessMode: 'admin' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
useServiceAccountConnectTarget,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ConnectorApiKeyInput } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/connector-api-key-input'
import {
derivedAclCapFieldIds,
isConnectorFieldRequired,
Expand Down Expand Up @@ -167,7 +168,6 @@ export function AddConnectorModal({
const gitlabPermissions = useGitLabPermissionForm()
const [apiKeyValue, setApiKeyValue] = useState('')
const [useApiKey, setUseApiKey] = useState(!isSearchIndex)
const [apiKeyFocused, setApiKeyFocused] = useState(false)
const [searchTerm, setSearchTerm] = useState('')

useOAuthReturnForKBConnectors(
Expand Down Expand Up @@ -502,7 +502,6 @@ export function AddConnectorModal({
})
setApiKeyValue('')
setUseApiKey(!isSearchIndex)
setApiKeyFocused(false)
setDisabledTagIds(new Set())
setShowMetadata(false)
setCanonicalModes({})
Expand Down Expand Up @@ -734,13 +733,10 @@ export function AddConnectorModal({
)}
{isApiKeyMode ? (
<ChipModalField type='custom' title={apiKeyConfig?.label || 'API Key'}>
<ChipInput
type={apiKeyFocused ? 'text' : 'password'}
autoComplete='new-password'
<ConnectorApiKeyInput
value={apiKeyValue}
onChange={(e) => setApiKeyValue(e.target.value)}
onFocus={() => setApiKeyFocused(true)}
onBlur={() => setApiKeyFocused(false)}
onChange={setApiKeyValue}
workspaceId={owner.workspaceId}
placeholder={apiKeyConfig?.placeholder || 'Enter API key'}
/>
</ChipModalField>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
'use client'

import { useRef, useState } from 'react'
import { SecretInput } from '@sim/emcn'
import {
checkEnvVarTrigger,
EnvVarDropdown,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'

const NO_ENV_VARS = new Set<string>()

interface ConnectorApiKeyInputProps {
value: string
onChange: (value: string) => void
placeholder?: string
workspaceId?: string
}

export function ConnectorApiKeyInput({
value,
onChange,
placeholder,
workspaceId,
}: ConnectorApiKeyInputProps) {
const inputRef = useRef<HTMLInputElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
const [isFocused, setIsFocused] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [showSecrets, setShowSecrets] = useState(false)
const availableEnvVars = useAvailableEnvVarKeys(workspaceId, { enabled: isFocused })
const trigger = checkEnvVarTrigger(value, cursorPosition)
const visible = showSecrets && trigger.show

return (
<div className='relative' data-chip-modal-enter-owner={visible ? '' : undefined}>
<SecretInput
ref={inputRef}
value={value}
onChange={(next) => {
onChange(next)
setCursorPosition(inputRef.current?.selectionStart ?? next.length)
setShowSecrets(true)
}}
Comment thread
waleedlatif1 marked this conversation as resolved.
onSelect={(event) => {
setCursorPosition(event.currentTarget.selectionStart ?? value.length)
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onScroll={(event) => {
if (overlayRef.current) {
overlayRef.current.style.transform = `translateX(-${event.currentTarget.scrollLeft}px)`
}
}}
inputClassName={isFocused ? 'text-transparent caret-[var(--text-primary)]' : undefined}
placeholder={placeholder}
/>
{isFocused && (
<div
aria-hidden
className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-2 text-[var(--text-body)] text-sm'
>
<div
ref={(element) => {
overlayRef.current = element
if (element) {
element.style.transform = `translateX(-${inputRef.current?.scrollLeft ?? 0}px)`
}
}}
className='whitespace-pre'
>
{formatDisplayText(value, { availableEnvVars: availableEnvVars ?? NO_ENV_VARS })}
</div>
</div>
)}
{visible && (
<EnvVarDropdown
visible
searchTerm={trigger.searchTerm}
inputValue={value}
cursorPosition={cursorPosition}
workspaceId={workspaceId}
inputRef={inputRef}
onClose={() => setShowSecrets(false)}
onSelect={(next, cursor) => {
onChange(next)
setCursorPosition(cursor)
setShowSecrets(false)
requestAnimationFrame(() => {
inputRef.current?.focus()
inputRef.current?.setSelectionRange(cursor, cursor)
})
}}
/>
)}
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ export function ConnectorAccessField({
for (const entry of modes)
entry.allowed &&= supportsConnectorAccessMode(connectorConfig, entry.mode)
if (
connectorConfig.supportedAccessModes?.length === 1 &&
connectorConfig.supportedAccessModes?.filter((mode) => allowWorkspace || mode !== 'workspace')
.length === 1 &&
modes.some((entry) => entry.mode === value.accessMode && entry.allowed)
)
return canAdmin && footer ? <div className='px-2'>{footer}</div> : null
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/connectors/gitlab/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const gitlabConnectorMeta: ConnectorMeta = {
'Sync repository files, wiki pages, issues, merge requests, and their non-internal comments from a GitLab project',
version: '1.3.0',
mirrorsSourceAcls: true,
supportedAccessModes: ['admin'],
supportedAccessModes: ['admin', 'workspace'],
adminSetupHint:
'Use an administrator token, or a non-admin token with CSV permissions. Both require read_api access and a self-managed GitLab host.',
icon: GitLabIcon,
Expand Down
Loading
Loading