Skip to content

Commit 6e11707

Browse files
feat(credentials): connect requested keys from integrations
1 parent 0e4c37a commit 6e11707

10 files changed

Lines changed: 482 additions & 12 deletions

File tree

‎apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const mocks = vi.hoisted(() => ({
2828
organizationAccounts: vi.fn(),
2929
connectOrganizationAccount: vi.fn(),
3030
reconnectOrganizationAccount: vi.fn(),
31+
saveApiKey: vi.fn(),
32+
disconnectApiKey: vi.fn(),
3133
refetchAccounts: vi.fn(),
3234
}))
3335
vi.mock('@/hooks/queries/organization-accounts', () => ({
@@ -41,6 +43,16 @@ vi.mock('@/hooks/queries/organization-accounts', () => ({
4143
mutate: mocks.reconnectOrganizationAccount,
4244
isPending: false,
4345
}),
46+
useSaveOrganizationAccountApiKey: () => ({
47+
mutate: mocks.saveApiKey,
48+
isPending: false,
49+
error: null,
50+
}),
51+
useDisconnectPersonalOrganizationAccount: () => ({
52+
mutate: mocks.disconnectApiKey,
53+
isPending: false,
54+
error: null,
55+
}),
4456
}))
4557
vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({
4658
SlackSearchActions: () => <span>Return to Slack</span>,
@@ -299,6 +311,7 @@ describe('GitHub member account inventory', () => {
299311
id: 'accounts-group',
300312
status: 'active',
301313
options: [{ id: 'github-option', provider: 'github-repositories', status: 'active' }],
314+
apiKeyOptions: [],
302315
}
303316

304317
beforeEach(() => {
@@ -501,6 +514,81 @@ describe('GitHub member account inventory', () => {
501514
})
502515
})
503516

517+
describe('requested API key integrations', () => {
518+
beforeEach(() => {
519+
mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false })
520+
mocks.integrations.mockReturnValue({ data: [], isPending: false })
521+
mocks.organizationAccounts.mockReturnValue({
522+
data: {
523+
credentialGroup: {
524+
id: 'accounts-group',
525+
status: 'active',
526+
options: [],
527+
apiKeyOptions: [
528+
{
529+
id: 'search-option',
530+
name: 'Search API key',
531+
description: 'Get a key from the provider.',
532+
},
533+
],
534+
},
535+
viewerAccounts: [],
536+
viewerApiKeys: [],
537+
},
538+
isPending: false,
539+
isError: false,
540+
})
541+
})
542+
543+
it('shows a named integration and opens its description and masked key field on Connect', async () => {
544+
await render()
545+
expect(container.textContent).toContain('Search API key')
546+
expect(container.textContent).toContain('Not connected')
547+
expect(container.textContent).not.toContain('Get a key from the provider.')
548+
expect(buttons('Connect')).toHaveLength(1)
549+
await act(async () => buttons('Connect')[0].click())
550+
const modal = document.querySelector<HTMLElement>('[role="dialog"]')!
551+
expect(modal.textContent).toContain('Get a key from the provider.')
552+
expect(modal.textContent).toContain('Enter at least 8 characters to connect.')
553+
const input = modal.querySelector<HTMLInputElement>('input[placeholder="Enter your API key"]')!
554+
const connect = [...modal.querySelectorAll<HTMLButtonElement>('button')].find(
555+
(button) => button.textContent?.trim() === 'Connect'
556+
)!
557+
expect(connect.disabled).toBe(true)
558+
await act(async () => {
559+
input.focus()
560+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(
561+
input,
562+
'synthetic-test-key'
563+
)
564+
input.dispatchEvent(new Event('input', { bubbles: true }))
565+
})
566+
expect(connect.disabled).toBe(false)
567+
await act(async () => connect.click())
568+
expect(mocks.saveApiKey).toHaveBeenCalledExactlyOnceWith(
569+
{ value: 'synthetic-test-key' },
570+
expect.any(Object)
571+
)
572+
})
573+
574+
it('shows Replace and Disconnect when the viewer has supplied a key', async () => {
575+
const inventory = mocks.organizationAccounts()
576+
mocks.organizationAccounts.mockReturnValue({
577+
...inventory,
578+
data: {
579+
...inventory.data,
580+
viewerApiKeys: [{ optionId: 'search-option', credentialId: 'personal-key' }],
581+
},
582+
})
583+
await render()
584+
expect(container.textContent).toContain('Connected')
585+
expect(buttons('Replace')).toHaveLength(1)
586+
await openMenu('Search API key')
587+
await act(async () => menuItem('Disconnect').click())
588+
expect(mocks.disconnectApiKey).toHaveBeenCalledExactlyOnceWith('personal-key')
589+
})
590+
})
591+
504592
describe('grouped member integrations', () => {
505593
it('renders one provider row and loads bounded pages per configured provider', async () => {
506594
mocks.overview.mockReturnValue({

‎apps/sim/app/o/[organizationId]/integrations/integrations.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function OrganizationIntegrations({
2929
return (
3030
<OrganizationPage
3131
title='Integrations'
32-
description='Connect your accounts for Sim Search'
32+
description='Connect your accounts and API keys for Sim Search, workflows, and Chat'
3333
searchMode='expanded'
3434
searchPlaceholder='Search integrations'
3535
action={

‎apps/sim/app/o/[organizationId]/integrations/member-integrations-list.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@/lib/sim-search/connectors'
1313
import { GitHubMemberIntegration } from '@/app/o/[organizationId]/integrations/github-member-integration'
1414
import { MemberIntegrationRow } from '@/app/o/[organizationId]/integrations/member-integration-row'
15+
import { OrganizationApiKeyIntegration } from '@/app/o/[organizationId]/integrations/organization-api-key-integration'
1516
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
1617
import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal'
1718
import {
@@ -101,6 +102,9 @@ export function MemberIntegrationsList({
101102
(provider.type === 'github' &&
102103
githubAccounts.some((account) => account.displayName.toLowerCase().includes(query)))
103104
)
105+
const apiKeyGroup = organizationAccounts.data?.credentialGroup
106+
const visibleApiKeys =
107+
apiKeyGroup?.apiKeyOptions.filter((option) => option.name.toLowerCase().includes(query)) ?? []
104108
const githubProvider = visible.find((provider) => provider.type === 'github')
105109
const githubRow =
106110
githubProvider && usesGitHubInventory ? (
@@ -171,8 +175,23 @@ export function MemberIntegrationsList({
171175
)}
172176
</div>
173177
))}
178+
{visibleApiKeys.map((option) => (
179+
<OrganizationApiKeyIntegration
180+
key={option.id}
181+
organizationId={organization.id}
182+
option={option}
183+
credentialId={
184+
organizationAccounts.data?.viewerApiKeys?.find(
185+
(entry) => entry.optionId === option.id
186+
)?.credentialId
187+
}
188+
available={apiKeyGroup?.status === 'active'}
189+
/>
190+
))}
174191
{showEmpty &&
175192
visible.length === 0 &&
193+
visibleApiKeys.length === 0 &&
194+
!organizationAccounts.isPending &&
176195
!availability.integrationAvailabilityError &&
177196
!showSlackSetupError && (
178197
<SettingsEmptyState variant='inline'>
@@ -187,6 +206,15 @@ export function MemberIntegrationsList({
187206
</>
188207
)}
189208
</div>
209+
{organizationAccounts.isError && (
210+
<SettingsQueryErrorState
211+
error={organizationAccounts.error}
212+
fallback='Could not load API key requests'
213+
isRetrying={organizationAccounts.isFetching}
214+
onRetry={() => void organizationAccounts.refetch()}
215+
variant='inline'
216+
/>
217+
)}
190218
</>
191219
)
192220
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import {
5+
Chip,
6+
ChipModal,
7+
ChipModalBody,
8+
ChipModalError,
9+
ChipModalField,
10+
ChipModalFooter,
11+
ChipModalHeader,
12+
} from '@sim/emcn'
13+
import { Key } from '@sim/emcn/icons'
14+
import type { CredentialGroupApiKeyOption } from '@/lib/api/contracts/credential-groups'
15+
import {
16+
CREDENTIAL_GROUP_API_KEY_MAX_LENGTH,
17+
CREDENTIAL_GROUP_API_KEY_MIN_LENGTH,
18+
} from '@/lib/credential-groups/api-key-constants'
19+
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
20+
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
21+
import {
22+
useDisconnectPersonalOrganizationAccount,
23+
useSaveOrganizationAccountApiKey,
24+
} from '@/hooks/queries/organization-accounts'
25+
26+
interface OrganizationApiKeyIntegrationProps {
27+
organizationId: string
28+
option: CredentialGroupApiKeyOption
29+
credentialId?: string
30+
available: boolean
31+
}
32+
33+
/** A requested personal key appears alongside the organization's other integrations. */
34+
export function OrganizationApiKeyIntegration({
35+
organizationId,
36+
option,
37+
credentialId,
38+
available,
39+
}: OrganizationApiKeyIntegrationProps) {
40+
const [modalOpen, setModalOpen] = useState(false)
41+
const disconnect = useDisconnectPersonalOrganizationAccount(organizationId)
42+
return (
43+
<>
44+
<SettingsResourceRow
45+
icon={<Key />}
46+
title={option.name}
47+
description={credentialId ? 'Connected' : available ? 'Not connected' : 'Unavailable'}
48+
trailing={
49+
<div className='flex items-center gap-2'>
50+
{credentialId && (
51+
<RowActionsMenu
52+
label={`${option.name} integration actions`}
53+
actions={[
54+
{
55+
label: 'Disconnect',
56+
destructive: true,
57+
disabled: disconnect.isPending,
58+
onSelect: () => disconnect.mutate(credentialId),
59+
},
60+
]}
61+
/>
62+
)}
63+
{available && (
64+
<Chip
65+
variant='primary'
66+
disabled={disconnect.isPending}
67+
onClick={() => setModalOpen(true)}
68+
>
69+
{credentialId ? 'Replace' : 'Connect'}
70+
</Chip>
71+
)}
72+
</div>
73+
}
74+
/>
75+
{disconnect.error && (
76+
<p role='alert' className='px-4 text-[var(--text-error)] text-caption'>
77+
{disconnect.error.message}
78+
</p>
79+
)}
80+
{modalOpen && (
81+
<ConnectApiKeyModal
82+
key={option.id}
83+
organizationId={organizationId}
84+
option={option}
85+
replacing={Boolean(credentialId)}
86+
onClose={() => setModalOpen(false)}
87+
/>
88+
)}
89+
</>
90+
)
91+
}
92+
93+
interface ConnectApiKeyModalProps {
94+
organizationId: string
95+
option: CredentialGroupApiKeyOption
96+
replacing: boolean
97+
onClose(): void
98+
}
99+
100+
function ConnectApiKeyModal({
101+
organizationId,
102+
option,
103+
replacing,
104+
onClose,
105+
}: ConnectApiKeyModalProps) {
106+
const [value, setValue] = useState('')
107+
const save = useSaveOrganizationAccountApiKey(organizationId, option.id)
108+
const title = `${replacing ? 'Replace' : 'Connect'} ${option.name}`
109+
const submit = () => {
110+
if (save.isPending || value.length < CREDENTIAL_GROUP_API_KEY_MIN_LENGTH) return
111+
save.mutate({ value }, { onSuccess: onClose })
112+
}
113+
return (
114+
<ChipModal
115+
open
116+
size='sm'
117+
srTitle={title}
118+
dismissDisabled={save.isPending}
119+
onOpenChange={(open) => {
120+
if (!open) onClose()
121+
}}
122+
>
123+
<ChipModalHeader onClose={onClose} closeDisabled={save.isPending}>
124+
{title}
125+
</ChipModalHeader>
126+
<ChipModalBody>
127+
{option.description && (
128+
<p className='px-2 text-[var(--text-muted)] text-sm'>{option.description}</p>
129+
)}
130+
<ChipModalField
131+
type='input'
132+
inputType='password'
133+
title='API key'
134+
value={value}
135+
onChange={setValue}
136+
placeholder='Enter your API key'
137+
hint={`Enter at least ${CREDENTIAL_GROUP_API_KEY_MIN_LENGTH} characters to connect.`}
138+
autoComplete='new-password'
139+
maxLength={CREDENTIAL_GROUP_API_KEY_MAX_LENGTH}
140+
required
141+
disabled={save.isPending}
142+
/>
143+
<ChipModalError>{save.error?.message}</ChipModalError>
144+
</ChipModalBody>
145+
<ChipModalFooter
146+
onCancel={onClose}
147+
cancelDisabled={save.isPending}
148+
primaryAction={{
149+
label: save.isPending ? 'Saving…' : replacing ? 'Replace' : 'Connect',
150+
disabled: save.isPending || value.length < CREDENTIAL_GROUP_API_KEY_MIN_LENGTH,
151+
onClick: submit,
152+
}}
153+
/>
154+
</ChipModal>
155+
)
156+
}

0 commit comments

Comments
 (0)