Skip to content

Commit 4d8f61a

Browse files
committed
fix(credentials): preserve unrelated setup controls
1 parent e315770 commit 4d8f61a

3 files changed

Lines changed: 279 additions & 30 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/organization-secret-input.tsx‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@/hooks/queries/organization-secrets'
1212

1313
interface OrganizationSecretInputContextValue {
14+
sourceKey: string
1415
isSaving: boolean
1516
save(variables: Record<string, string>): Promise<void>
1617
}
@@ -36,14 +37,28 @@ export function OrganizationSecretInputHost({
3637
const matchesOrganization = organizationContext?.organization.id === organizationId
3738
const sourceQuery = useOrganizationSecretSource(matchesOrganization ? organizationId : '')
3839
const mutation = useSaveOrganizationSecrets(organizationId)
40+
const renderContent = (value: OrganizationSecretInputContextValue | null, status?: ReactNode) => (
41+
<OrganizationSecretInputContext.Provider
42+
key={`${organizationId}:${session?.user?.id ?? ''}`}
43+
value={value}
44+
>
45+
<div className='space-y-3'>
46+
{status}
47+
{children}
48+
</div>
49+
</OrganizationSecretInputContext.Provider>
50+
)
3951

4052
if (!organizationContext || !matchesOrganization || !session?.user?.id)
41-
return (
53+
return renderContent(
54+
null,
4255
<p role='status'>Open this request in an organization conversation to add Generic Secrets.</p>
4356
)
44-
if (sourceQuery.isPending) return <p role='status'>Loading Generic Secrets…</p>
57+
if (sourceQuery.isPending)
58+
return renderContent(null, <p role='status'>Loading Generic Secrets…</p>)
4559
if (sourceQuery.isError)
46-
return (
60+
return renderContent(
61+
null,
4762
<p role='status'>
4863
Could not load Generic Secrets.{' '}
4964
<button type='button' className='underline' onClick={() => void sourceQuery.refetch()}>
@@ -54,7 +69,8 @@ export function OrganizationSecretInputHost({
5469

5570
const source = sourceQuery.data?.source
5671
if (!source)
57-
return (
72+
return renderContent(
73+
null,
5874
<p role='status'>
5975
{organizationContext.viewer.isAdmin ? (
6076
<>
@@ -73,7 +89,10 @@ export function OrganizationSecretInputHost({
7389
</p>
7490
)
7591
if (source.mode === 'organization' && !organizationContext.viewer.isAdmin)
76-
return <p role='status'>Ask an organization admin to add these shared Generic Secrets.</p>
92+
return renderContent(
93+
null,
94+
<p role='status'>Ask an organization admin to add these shared Generic Secrets.</p>
95+
)
7796

7897
const save = async (variables: Record<string, string>) => {
7998
try {
@@ -84,8 +103,7 @@ export function OrganizationSecretInputHost({
84103
remove: [],
85104
})
86105
} catch (error) {
87-
// The server compares this source under lock. Refreshing a changed source
88-
// remounts the form below, discarding drafts instead of redirecting them.
106+
/** The server compares this source under lock; a new source key invalidates its drafts. */
89107
if (error instanceof ApiClientError && (error.status === 409 || error.status === 404))
90108
await sourceQuery.refetch()
91109
throw error
@@ -94,12 +112,9 @@ export function OrganizationSecretInputHost({
94112
}
95113
}
96114

97-
return (
98-
<OrganizationSecretInputContext.Provider
99-
key={`${organizationId}:${session.user.id}:${source.id}:${source.mode}`}
100-
value={{ save, isSaving: mutation.isPending }}
101-
>
102-
{children}
103-
</OrganizationSecretInputContext.Provider>
104-
)
115+
return renderContent({
116+
sourceKey: `${organizationId}:${session.user.id}:${source.id}:${source.mode}`,
117+
save,
118+
isSaving: mutation.isPending,
119+
})
105120
}

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx‎

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
mockParams,
1717
mockCredentialHost,
1818
mockOrganizationContext,
19+
mockSession,
1920
mockRefetchPersonalEnvironment,
2021
mockRefetchWorkspaceCredentials,
2122
mockIsBrowserAgentAvailable,
@@ -29,6 +30,7 @@ const {
2930
} = vi.hoisted(() => ({
3031
mockParams: vi.fn(() => ({ workspaceId: 'workspace-1' })),
3132
mockOrganizationContext: vi.fn(() => null),
33+
mockSession: vi.fn(() => ({ data: { user: { id: 'person' } } })),
3234
mockCredentialHost: vi.fn(({ children }: { children: ReactNode }) => children),
3335
mockUpdateWorkspaceCredential: vi.fn(async () => undefined),
3436
mockRefetchPersonalEnvironment: vi.fn(async () => ({ data: {} })),
@@ -69,7 +71,7 @@ vi.mock('next/navigation', () => ({
6971
}))
7072

7173
vi.mock('@/lib/auth/auth-client', () => ({
72-
useSession: () => ({ data: { user: { id: 'person' } } }),
74+
useSession: mockSession,
7375
}))
7476
vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({
7577
useChatSurface: () => ({
@@ -151,6 +153,7 @@ describe('CredentialDisplay link tag', () => {
151153
vi.clearAllMocks()
152154
mockParams.mockReturnValue({ workspaceId: 'workspace-1' })
153155
mockOrganizationContext.mockReturnValue(null)
156+
mockSession.mockReturnValue({ data: { user: { id: 'person' } } })
154157
mockCredentialHost.mockImplementation(({ children }: { children: ReactNode }) => children)
155158
window.localStorage.clear()
156159
window.history.replaceState({}, '', '/workspace/workspace-1/chat/chat-1')
@@ -326,6 +329,170 @@ describe('CredentialDisplay link tag', () => {
326329
expect(fetch).not.toHaveBeenCalled()
327330
})
328331

332+
it.each(['restricted', 'missing'] as const)(
333+
'keeps personal credentials usable when organization secrets are %s',
334+
async (availability) => {
335+
setSource(availability === 'missing' ? null : { id: 'source', mode: 'organization' })
336+
mockOrganizationContext.mockReturnValue({
337+
organization: { id: 'org', name: 'Example' },
338+
viewer: { isAdmin: false },
339+
} as never)
340+
render([secret, { type: 'secret_input', name: 'PERSONAL_KEY', scope: 'personal' }])
341+
expect(container.textContent).toContain('Ask an organization admin')
342+
expect(container.querySelector('input[aria-label="SERVICE_API_KEY"]')).toBeNull()
343+
enter('PERSONAL_KEY', 'personal-only')
344+
await submit()
345+
expect(mockSavePersonalEnvironment).toHaveBeenCalledExactlyOnceWith({
346+
variables: { PERSONAL_KEY: 'personal-only' },
347+
})
348+
expect(fetch).not.toHaveBeenCalled()
349+
expect(onContinue).toHaveBeenCalledWith(
350+
'Credential setup submitted — {"integrations":[],"secrets":[{"name":"SERVICE_API_KEY","status":"skipped"},{"name":"PERSONAL_KEY","status":"saved"}]}'
351+
)
352+
}
353+
)
354+
355+
it('preserves personal drafts while loading and changing the organization source', async () => {
356+
vi.mocked(fetch).mockReturnValue(new Promise(() => {}))
357+
render([secret, { type: 'secret_input', name: 'PERSONAL_KEY', scope: 'personal' }])
358+
expect(container.textContent).toContain('Loading Generic Secrets')
359+
enter('PERSONAL_KEY', 'personal-only')
360+
await act(async () => {
361+
setSource({ id: 'source', mode: 'member' })
362+
await sleep(0)
363+
})
364+
expect(
365+
container.querySelector<HTMLInputElement>('input[aria-label="PERSONAL_KEY"]')?.value
366+
).toBe('personal-only')
367+
enter()
368+
await act(async () => {
369+
setSource({ id: 'replacement', mode: 'organization' })
370+
await sleep(0)
371+
})
372+
expect(
373+
container.querySelector<HTMLInputElement>('input[aria-label="SERVICE_API_KEY"]')?.value
374+
).toBe('')
375+
await submit()
376+
expect(mockSavePersonalEnvironment).toHaveBeenCalledExactlyOnceWith({
377+
variables: { PERSONAL_KEY: 'personal-only' },
378+
})
379+
expect(
380+
vi.mocked(fetch).mock.calls.filter(([, init]) => init?.method === 'PATCH')
381+
).toHaveLength(0)
382+
expect(onContinue.mock.calls[0][0]).toContain('"name":"SERVICE_API_KEY","status":"skipped"')
383+
expect(onContinue.mock.calls[0][0]).toContain('"name":"PERSONAL_KEY","status":"saved"')
384+
})
385+
386+
it('keeps personal and integration controls visible when organization metadata fails', async () => {
387+
vi.mocked(fetch).mockRejectedValue(new Error('Metadata unavailable'))
388+
mockUseUserPermissionsContext.mockReturnValue({ canEdit: true })
389+
render([
390+
secret,
391+
{ type: 'secret_input', name: 'PERSONAL_KEY', scope: 'personal' },
392+
{
393+
type: 'link',
394+
provider: 'slack',
395+
value: 'https://example.test/connect?workspaceId=target',
396+
workspaceId: 'target',
397+
scope: 'organization',
398+
},
399+
])
400+
await act(async () => {
401+
await queryClient.refetchQueries({ queryKey: organizationSecretKeys.source('org') })
402+
await sleep(0)
403+
})
404+
expect(container.textContent).toContain('Could not load Generic Secrets')
405+
expect(container.querySelector('input[aria-label="PERSONAL_KEY"]')).not.toBeNull()
406+
expect(container.querySelector('input[aria-label="SERVICE_API_KEY"]')).toBeNull()
407+
expect(
408+
container.querySelector('a[href="https://example.test/connect?workspaceId=target"]')
409+
).not.toBeNull()
410+
})
411+
412+
it('keeps organization recap rows after submission when no input host is mounted', () => {
413+
act(() =>
414+
root.render(
415+
<QueryClientProvider client={queryClient}>
416+
<SpecialTags
417+
segment={{ type: 'credential', data: [secret] }}
418+
requestMode='plan'
419+
credentialSubmission={{
420+
integrations: [],
421+
secrets: [{ name: 'SERVICE_API_KEY', status: 'saved' }],
422+
}}
423+
/>
424+
</QueryClientProvider>
425+
)
426+
)
427+
expect(container.textContent).toContain('SERVICE_API_KEY')
428+
expect(container.textContent).toContain('Added')
429+
expect(container.querySelector('input')).toBeNull()
430+
})
431+
432+
it('clears every draft when the authenticated user changes', () => {
433+
setSource({ id: 'source', mode: 'member' })
434+
const data: CredentialItemData[] = [
435+
secret,
436+
{ type: 'secret_input', name: 'PERSONAL_KEY', scope: 'personal' },
437+
]
438+
render(data)
439+
enter()
440+
enter('PERSONAL_KEY', 'personal-only')
441+
mockSession.mockReturnValue({ data: { user: { id: 'another-person' } } })
442+
render(data)
443+
expect(
444+
container.querySelector<HTMLInputElement>('input[aria-label="SERVICE_API_KEY"]')?.value
445+
).toBe('')
446+
expect(
447+
container.querySelector<HTMLInputElement>('input[aria-label="PERSONAL_KEY"]')?.value
448+
).toBe('')
449+
})
450+
451+
it('does not complete the new source with an old pending save or discard unrelated saved progress', async () => {
452+
let finishSave: (response: Response) => void = () => {
453+
throw new Error('Save not started')
454+
}
455+
vi.mocked(fetch).mockImplementationOnce(
456+
() =>
457+
new Promise<Response>((resolve) => {
458+
finishSave = resolve
459+
})
460+
)
461+
setSource({ id: 'source', mode: 'member' })
462+
render([secret, { type: 'secret_input', name: 'PERSONAL_KEY', scope: 'personal' }])
463+
enter()
464+
enter('PERSONAL_KEY', 'personal-only')
465+
act(() => {
466+
Array.from(container.querySelectorAll('button'))
467+
.find((button) => button.textContent === 'Submit')
468+
?.click()
469+
})
470+
await act(async () => {
471+
await sleep(0)
472+
setSource({ id: 'replacement', mode: 'organization' })
473+
await sleep(0)
474+
})
475+
await act(async () => {
476+
finishSave(new Response(JSON.stringify({ success: true }), { status: 200 }))
477+
await sleep(0)
478+
})
479+
expect(onContinue).not.toHaveBeenCalled()
480+
expect(
481+
container.querySelector<HTMLInputElement>('input[aria-label="SERVICE_API_KEY"]')?.value
482+
).toBe('')
483+
expect(container.querySelector('input[aria-label="PERSONAL_KEY"]')).toBeNull()
484+
expect(container.textContent).toContain('PERSONAL_KEY')
485+
expect(container.textContent).toContain('Added')
486+
enter('SERVICE_API_KEY', 'new-source-only')
487+
await submit()
488+
expect(mockSavePersonalEnvironment).toHaveBeenCalledOnce()
489+
expect(onContinue).toHaveBeenCalledOnce()
490+
expect(JSON.parse(String(vi.mocked(fetch).mock.calls.at(-1)?.[1]?.body))).toMatchObject({
491+
sourceId: 'replacement',
492+
upsert: { SERVICE_API_KEY: 'new-source-only' },
493+
})
494+
})
495+
329496
it('never renders organization inputs in Search mode or a workspace conversation', () => {
330497
setSource({ id: 'source', mode: 'organization' })
331498
render([secret], 'assistant')

0 commit comments

Comments
 (0)