Skip to content

Commit ea9e85f

Browse files
authored
fix(knowledge): restore GitLab PAT setup and secret references (#8185)
* fix(knowledge): restore GitLab PAT setup and secret references * fix(knowledge): align secret reference styling and caret tracking * fix(knowledge): reuse shared secret reference highlighting
1 parent 420c0df commit ea9e85f

14 files changed

Lines changed: 672 additions & 38 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ jobs:
277277
lib/knowledge/__integration__/member-document-lifecycle.integration.ts
278278
lib/knowledge/__integration__/slack-empty-threads.integration.ts
279279
lib/knowledge/__integration__/kb-block-search.integration.ts
280+
lib/knowledge/__integration__/gitlab-workspace.integration.ts
280281
lib/knowledge/__integration__/unfilled-projection-source.integration.ts
281282
lib/knowledge/__integration__/purged-detach-reservation.integration.ts
282283
lib/core/outbox/service.integration.ts

apps/docs/content/docs/knowledgebase/connectors.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Other connectors use **API keys** or **personal access tokens** instead. The set
5656
| **Fireflies** | Generate from the Integrations page in your Fireflies account |
5757
| **Typeform** | Personal access token from your Typeform account settings |
5858
| **Azure DevOps** | Personal access token with Wiki (Read), Work Items (Read), and Code (Read) scopes |
59+
| **GitLab** | Personal access token with `read_api` scope and access to the selected project |
5960
| **YouTube** | YouTube Data API key from the Google Cloud Console |
6061
| **Amazon S3** | Secret Access Key (the Access Key ID, region, and bucket are entered as config fields) |
6162
| **Sentry** | Auth token with `project:read` and `event:read` scopes |
@@ -65,10 +66,14 @@ Other connectors use **API keys** or **personal access tokens** instead. The set
6566
| **Databricks** | Personal access token from your workspace's user settings (the workspace host is entered as a config field) |
6667
| **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) |
6768

69+
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.
70+
6871
<Callout type="info">
6972
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.
7073
</Callout>
7174

75+
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).
76+
7277
</Step>
7378
<Step>
7479

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

7984
- **Notion** — sync an entire workspace, a specific database, or a single page tree
8085
- **GitHub** — specify a repository, branch, and optional file extension filter
86+
- **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.
8187
- **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.
8288
- **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
8389
- **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

apps/sim/app/o/[organizationId]/settings/components/integrations/search-source-setup.test.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
66
import { createRoot, type Root } from 'react-dom/client'
77
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88

9+
vi.mock('@/hooks/queries/environment', () => ({
10+
usePersonalEnvironment: () => ({ data: {} }),
11+
useWorkspaceEnvironment: () => ({ data: { workspace: {}, personal: {} } }),
12+
}))
13+
914
const mocks = vi.hoisted(() => ({
1015
canAdmin: true,
1116
hasMaxAccess: true,
@@ -269,6 +274,7 @@ async function chooseSyncFrequency(label: string) {
269274
async function fill(placeholder: string, value: string) {
270275
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)
271276
expect(input, `Input ${placeholder}`).not.toBeNull()
277+
await act(async () => input?.focus())
272278
await act(async () => {
273279
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value)
274280
input?.dispatchEvent(new Event('input', { bubbles: true }))
@@ -897,16 +903,16 @@ describe('member content credentials in real add and edit dialogs', () => {
897903
)
898904
await click(card!)
899905
expect(document.body.textContent).not.toContain('Connected members')
900-
expect(button('Administrator token')).toHaveAttribute('aria-checked', 'true')
901-
expect(document.body.textContent).not.toContain('Connection method')
906+
expect(document.body.textContent).not.toContain('Administrator token')
907+
expect(document.body.textContent).toContain('Everyone in this workspace')
902908
await fill('Enter your GitLab PAT', 'new-pat')
903909
await fill('gitlab.example.com', 'gitlab.example.test')
904910
await fill('group/project or numeric ID', '1')
905911
expect(button('Connect & Sync')).toBeEnabled()
906912
await click(button('Connect & Sync'))
907913
expect(mocks.create.mock.calls[1][0]).toMatchObject({
908914
connectorType: 'gitlab',
909-
accessMode: 'admin',
915+
accessMode: 'workspace',
910916
apiKey: 'new-pat',
911917
})
912918
expect(mocks.create.mock.calls[1][0].sourceConfig).not.toHaveProperty('excludeChannels')

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.test.tsx

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ const mocks = vi.hoisted(() => ({
4343
| 'ready',
4444
}))
4545

46+
vi.mock('@/hooks/queries/environment', () => ({
47+
usePersonalEnvironment: () => ({ data: {} }),
48+
useWorkspaceEnvironment: () => ({ data: { workspace: { GITLAB_PAT: '***' }, personal: {} } }),
49+
}))
50+
vi.mock('@/hooks/use-settings-navigation', () => ({
51+
useSettingsNavigation: () => ({ navigateToSettings: vi.fn() }),
52+
}))
53+
4654
vi.mock('next/navigation', () => ({
4755
useParams: () => ({ workspaceId: 'workspace-1' }),
4856
usePathname: () => '/o/org-1/settings/integrations',
@@ -790,6 +798,7 @@ describe('Search setup options', () => {
790798
initialAccessMode: connectorType === 'gitlab' ? 'admin' : 'members',
791799
})
792800

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

837-
it('uses GitLab service-account access and token tabs without an access selector in regular KBs', async () => {
846+
it.each(['fixture-pat', '{{GITLAB_PAT}}'])(
847+
'connects a regular GitLab KB with %s and workspace access',
848+
async (apiKey) => {
849+
mocks.memberAccess = false
850+
mocks.mirroredAccess = false
851+
const sourceConfig = { host: 'gitlab.example.com', project: 'group/project' }
852+
mocks.resolveSourceConfig.mockReturnValue(sourceConfig)
853+
await render({
854+
initialConnectorType: 'gitlab',
855+
initialAccessMode: 'workspace',
856+
isSearchIndex: false,
857+
})
858+
expect(document.body.textContent).not.toContain('Administrator token')
859+
expect(document.body.textContent).not.toContain('Non-admin token')
860+
expect(document.body.textContent).not.toContain('User mapping')
861+
expect(document.body.textContent).not.toContain('Project permissions')
862+
expect(document.body.textContent).not.toContain('Connection method')
863+
expect(document.body.textContent).toContain('Sync Frequency')
864+
const input = document.querySelector<HTMLInputElement>(
865+
'input[placeholder="Enter your GitLab PAT"]'
866+
)!
867+
await act(async () => input.focus())
868+
await act(async () => {
869+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
870+
input,
871+
apiKey
872+
)
873+
input.dispatchEvent(new Event('input', { bubbles: true }))
874+
})
875+
await act(async () => button('Connect & Sync').click())
876+
expect(mocks.create).toHaveBeenCalledWith(
877+
{
878+
knowledgeBaseId: 'kb-search',
879+
connectorType: 'gitlab',
880+
apiKey,
881+
sourceConfig,
882+
syncIntervalMinutes: 1440,
883+
accessMode: 'workspace',
884+
},
885+
expect.any(Object)
886+
)
887+
}
888+
)
889+
890+
it('selects a saved secret with the shared picker without submitting on Enter', async () => {
838891
await render({
839892
initialConnectorType: 'gitlab',
840893
initialAccessMode: 'workspace',
841894
isSearchIndex: false,
842895
})
843-
expect(document.body.textContent).toContain('Administrator token')
844-
expect(document.body.textContent).toContain('Non-admin token')
845-
expect(document.body.textContent).not.toContain('Connection method')
846-
expect(button('More options')).toHaveAttribute('aria-expanded', 'false')
847-
expect(document.body.textContent).not.toContain('Sync Frequency')
848-
await act(async () => button('More options').click())
849-
expect(document.body.textContent).toContain('Sync Frequency')
896+
const input = document.querySelector<HTMLInputElement>(
897+
'input[placeholder="Enter your GitLab PAT"]'
898+
)!
899+
await act(async () => input.focus())
900+
await act(async () => {
901+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
902+
input,
903+
'{{GIT'
904+
)
905+
input.dispatchEvent(new Event('input', { bubbles: true }))
906+
})
907+
expect(document.body.textContent).toContain('GITLAB_PAT')
908+
expect(document.querySelector('span[class="text-[var(--brand-secondary)]"]')).toBeNull()
909+
await act(async () => {
910+
input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
911+
input.setSelectionRange(0, 0)
912+
input.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
913+
})
914+
expect(document.querySelector('[role="menuitem"]')).toBeNull()
915+
expect(input.value).toBe('{{GIT')
916+
await act(async () => {
917+
input.setSelectionRange(input.value.length, input.value.length)
918+
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'End', bubbles: true }))
919+
})
920+
expect(document.body.textContent).toContain('GITLAB_PAT')
921+
await act(async () =>
922+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
923+
)
924+
expect(input.value).toBe('{{GITLAB_PAT}}')
925+
expect(document.querySelector('span[class="text-[var(--brand-secondary)]"]')).toHaveTextContent(
926+
'{{GITLAB_PAT}}'
927+
)
928+
expect(mocks.create).not.toHaveBeenCalled()
929+
await act(async () => button('Connect & Sync').click())
930+
expect(mocks.create).toHaveBeenCalledWith(
931+
expect.objectContaining({ apiKey: '{{GITLAB_PAT}}', accessMode: 'workspace' }),
932+
expect.any(Object)
933+
)
850934
})
851935

936+
it.each(['{{', '{{MISSING_SECRET}}', 'literal-pat'])(
937+
'does not highlight unresolved or literal API-key text: %s',
938+
async (value) => {
939+
await render({
940+
initialConnectorType: 'gitlab',
941+
initialAccessMode: 'workspace',
942+
isSearchIndex: false,
943+
})
944+
const input = document.querySelector<HTMLInputElement>(
945+
'input[placeholder="Enter your GitLab PAT"]'
946+
)!
947+
await act(async () => input.focus())
948+
await act(async () => {
949+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
950+
input,
951+
value
952+
)
953+
input.dispatchEvent(new Event('input', { bubbles: true }))
954+
})
955+
expect(document.querySelector('span[class="text-[var(--brand-secondary)]"]')).toBeNull()
956+
await act(async () => input.blur())
957+
expect(input.value).toBe('•'.repeat(value.length))
958+
expect(document.body.textContent).not.toContain(value)
959+
}
960+
)
961+
852962
it('keeps administrator-required fields in the primary form even if metadata marks them optional', async () => {
853963
mocks.credentials = [{ id: 'service', name: 'Indexing account', type: 'service_account' }]
854964
await render({ initialConnectorType: 'google_drive', initialAccessMode: 'admin' })

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
useServiceAccountConnectTarget,
3939
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
4040
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
41+
import { ConnectorApiKeyInput } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/connector-api-key-input'
4142
import {
4243
derivedAclCapFieldIds,
4344
isConnectorFieldRequired,
@@ -167,7 +168,6 @@ export function AddConnectorModal({
167168
const gitlabPermissions = useGitLabPermissionForm()
168169
const [apiKeyValue, setApiKeyValue] = useState('')
169170
const [useApiKey, setUseApiKey] = useState(!isSearchIndex)
170-
const [apiKeyFocused, setApiKeyFocused] = useState(false)
171171
const [searchTerm, setSearchTerm] = useState('')
172172

173173
useOAuthReturnForKBConnectors(
@@ -502,7 +502,6 @@ export function AddConnectorModal({
502502
})
503503
setApiKeyValue('')
504504
setUseApiKey(!isSearchIndex)
505-
setApiKeyFocused(false)
506505
setDisabledTagIds(new Set())
507506
setShowMetadata(false)
508507
setCanonicalModes({})
@@ -734,13 +733,10 @@ export function AddConnectorModal({
734733
)}
735734
{isApiKeyMode ? (
736735
<ChipModalField type='custom' title={apiKeyConfig?.label || 'API Key'}>
737-
<ChipInput
738-
type={apiKeyFocused ? 'text' : 'password'}
739-
autoComplete='new-password'
736+
<ConnectorApiKeyInput
740737
value={apiKeyValue}
741-
onChange={(e) => setApiKeyValue(e.target.value)}
742-
onFocus={() => setApiKeyFocused(true)}
743-
onBlur={() => setApiKeyFocused(false)}
738+
onChange={setApiKeyValue}
739+
workspaceId={owner.workspaceId}
744740
placeholder={apiKeyConfig?.placeholder || 'Enter API key'}
745741
/>
746742
</ChipModalField>
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
'use client'
2+
3+
import { useRef, useState } from 'react'
4+
import { SecretInput } from '@sim/emcn'
5+
import {
6+
checkEnvVarTrigger,
7+
EnvVarDropdown,
8+
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
9+
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
10+
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
11+
12+
const NO_ENV_VARS = new Set<string>()
13+
14+
interface ConnectorApiKeyInputProps {
15+
value: string
16+
onChange: (value: string) => void
17+
placeholder?: string
18+
workspaceId?: string
19+
}
20+
21+
export function ConnectorApiKeyInput({
22+
value,
23+
onChange,
24+
placeholder,
25+
workspaceId,
26+
}: ConnectorApiKeyInputProps) {
27+
const inputRef = useRef<HTMLInputElement>(null)
28+
const overlayRef = useRef<HTMLDivElement>(null)
29+
const [isFocused, setIsFocused] = useState(false)
30+
const [cursorPosition, setCursorPosition] = useState(0)
31+
const [showSecrets, setShowSecrets] = useState(false)
32+
const availableEnvVars = useAvailableEnvVarKeys(workspaceId, { enabled: isFocused })
33+
const trigger = checkEnvVarTrigger(value, cursorPosition)
34+
const visible = showSecrets && trigger.show
35+
36+
return (
37+
<div className='relative' data-chip-modal-enter-owner={visible ? '' : undefined}>
38+
<SecretInput
39+
ref={inputRef}
40+
value={value}
41+
onChange={(next) => {
42+
onChange(next)
43+
setCursorPosition(inputRef.current?.selectionStart ?? next.length)
44+
setShowSecrets(true)
45+
}}
46+
onSelect={(event) => {
47+
setCursorPosition(event.currentTarget.selectionStart ?? value.length)
48+
}}
49+
onFocus={() => setIsFocused(true)}
50+
onBlur={() => setIsFocused(false)}
51+
onScroll={(event) => {
52+
if (overlayRef.current) {
53+
overlayRef.current.style.transform = `translateX(-${event.currentTarget.scrollLeft}px)`
54+
}
55+
}}
56+
inputClassName={isFocused ? 'text-transparent caret-[var(--text-primary)]' : undefined}
57+
placeholder={placeholder}
58+
/>
59+
{isFocused && (
60+
<div
61+
aria-hidden
62+
className='pointer-events-none absolute inset-0 flex items-center overflow-hidden px-2 text-[var(--text-body)] text-sm'
63+
>
64+
<div
65+
ref={(element) => {
66+
overlayRef.current = element
67+
if (element) {
68+
element.style.transform = `translateX(-${inputRef.current?.scrollLeft ?? 0}px)`
69+
}
70+
}}
71+
className='whitespace-pre'
72+
>
73+
{formatDisplayText(value, { availableEnvVars: availableEnvVars ?? NO_ENV_VARS })}
74+
</div>
75+
</div>
76+
)}
77+
{visible && (
78+
<EnvVarDropdown
79+
visible
80+
searchTerm={trigger.searchTerm}
81+
inputValue={value}
82+
cursorPosition={cursorPosition}
83+
workspaceId={workspaceId}
84+
inputRef={inputRef}
85+
onClose={() => setShowSecrets(false)}
86+
onSelect={(next, cursor) => {
87+
onChange(next)
88+
setCursorPosition(cursor)
89+
setShowSecrets(false)
90+
requestAnimationFrame(() => {
91+
inputRef.current?.focus()
92+
inputRef.current?.setSelectionRange(cursor, cursor)
93+
})
94+
}}
95+
/>
96+
)}
97+
</div>
98+
)
99+
}

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ export function ConnectorAccessField({
140140
for (const entry of modes)
141141
entry.allowed &&= supportsConnectorAccessMode(connectorConfig, entry.mode)
142142
if (
143-
connectorConfig.supportedAccessModes?.length === 1 &&
143+
connectorConfig.supportedAccessModes?.filter((mode) => allowWorkspace || mode !== 'workspace')
144+
.length === 1 &&
144145
modes.some((entry) => entry.mode === value.accessMode && entry.allowed)
145146
)
146147
return canAdmin && footer ? <div className='px-2'>{footer}</div> : null

apps/sim/connectors/gitlab/meta.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const gitlabConnectorMeta: ConnectorMeta = {
1010
'Sync repository files, wiki pages, issues, merge requests, and their non-internal comments from a GitLab project',
1111
version: '1.3.0',
1212
mirrorsSourceAcls: true,
13-
supportedAccessModes: ['admin'],
13+
supportedAccessModes: ['admin', 'workspace'],
1414
adminSetupHint:
1515
'Use an administrator token, or a non-admin token with CSV permissions. Both require read_api access and a self-managed GitLab host.',
1616
icon: GitLabIcon,

0 commit comments

Comments
 (0)