Skip to content

Commit e3db48d

Browse files
committed
feat(mothership): set organization Generic Secrets in chat
1 parent 126cc14 commit e3db48d

6 files changed

Lines changed: 592 additions & 41 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
'use client'
2+
3+
import { createContext, type ReactNode, useContext } from 'react'
4+
import { ApiClientError } from '@/lib/api/client/errors'
5+
import { useSession } from '@/lib/auth/auth-client'
6+
import { organizationRoutes } from '@/lib/navigation/paths'
7+
import { useOptionalOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
8+
import {
9+
useOrganizationSecretSource,
10+
useSaveOrganizationSecrets,
11+
} from '@/hooks/queries/organization-secrets'
12+
13+
interface OrganizationSecretInputContextValue {
14+
isSaving: boolean
15+
save(variables: Record<string, string>): Promise<void>
16+
}
17+
18+
const OrganizationSecretInputContext = createContext<OrganizationSecretInputContextValue | null>(
19+
null
20+
)
21+
22+
export function useOrganizationSecretInput() {
23+
return useContext(OrganizationSecretInputContext)
24+
}
25+
26+
/** Only source metadata is loaded; existing secret values never enter the chat UI. */
27+
export function OrganizationSecretInputHost({
28+
organizationId,
29+
children,
30+
}: {
31+
organizationId: string
32+
children: ReactNode
33+
}) {
34+
const organizationContext = useOptionalOrganizationContext()
35+
const { data: session } = useSession()
36+
const matchesOrganization = organizationContext?.organization.id === organizationId
37+
const sourceQuery = useOrganizationSecretSource(matchesOrganization ? organizationId : '')
38+
const mutation = useSaveOrganizationSecrets(organizationId)
39+
40+
if (!organizationContext || !matchesOrganization || !session?.user?.id)
41+
return (
42+
<p role='status'>Open this request in an organization conversation to add Generic Secrets.</p>
43+
)
44+
if (sourceQuery.isPending) return <p role='status'>Loading Generic Secrets…</p>
45+
if (sourceQuery.isError)
46+
return (
47+
<p role='status'>
48+
Could not load Generic Secrets.{' '}
49+
<button type='button' className='underline' onClick={() => void sourceQuery.refetch()}>
50+
Retry
51+
</button>
52+
</p>
53+
)
54+
55+
const source = sourceQuery.data?.source
56+
if (!source)
57+
return (
58+
<p role='status'>
59+
{organizationContext.viewer.isAdmin ? (
60+
<>
61+
Enable Generic Secrets in{' '}
62+
<a
63+
className='underline'
64+
href={organizationRoutes(organizationId).settingsSection('integrations')}
65+
>
66+
organization Integrations settings
67+
</a>
68+
, then return here to enter the keys.
69+
</>
70+
) : (
71+
'Ask an organization admin to enable Generic Secrets in Integrations.'
72+
)}
73+
</p>
74+
)
75+
if (source.mode === 'organization' && !organizationContext.viewer.isAdmin)
76+
return <p role='status'>Ask an organization admin to add these shared Generic Secrets.</p>
77+
78+
const save = async (variables: Record<string, string>) => {
79+
try {
80+
await mutation.mutateAsync({
81+
sourceId: source.id,
82+
mode: source.mode,
83+
upsert: variables,
84+
remove: [],
85+
})
86+
} 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.
89+
if (error instanceof ApiClientError && (error.status === 409 || error.status === 404))
90+
await sourceQuery.refetch()
91+
throw error
92+
} finally {
93+
mutation.reset()
94+
}
95+
}
96+
97+
return (
98+
<OrganizationSecretInputContext.Provider
99+
key={`${organizationId}:${session.user.id}:${source.id}:${source.mode}`}
100+
value={{ save, isSaving: mutation.isPending }}
101+
>
102+
<p className='mb-2 text-[var(--text-muted)] text-sm'>
103+
{source.mode === 'organization'
104+
? `Generic Secrets are shared with ${organizationContext.organization.name}. Submitting replaces any existing keys with the same names.`
105+
: 'These Generic Secrets are private to you in this organization. Submitting replaces your existing keys with the same names.'}
106+
</p>
107+
{children}
108+
</OrganizationSecretInputContext.Provider>
109+
)
110+
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ function renderedText(segments: ContentSegment[]): string {
4343
}
4444

4545
describe('parseCredentialTagBody', () => {
46+
it('accepts organization secret inputs without model-supplied values or workspace targets', () => {
47+
const item = { type: 'secret_input', name: 'TOKEN', scope: 'organization' } as const
48+
expect(parseCredentialTagBody(JSON.stringify(item))).toEqual([item])
49+
expect(parseCredentialTagBody(JSON.stringify({ ...item, workspaceId: 'workspace' }))).toBeNull()
50+
expect(parseCredentialTagBody(JSON.stringify({ ...item, value: 'secret' }))).toBeNull()
51+
for (const name of ['API-KEY', ' KEY', '1KEY', 'x'.repeat(1025)])
52+
expect(parseCredentialTagBody(JSON.stringify({ ...item, name }))).toBeNull()
53+
expect(credentialTagHasVisibleCard([item], false, 'agent')).toBe(true)
54+
expect(credentialTagHasVisibleCard([item], false, 'plan')).toBe(true)
55+
expect(credentialTagHasVisibleCard([item], true, 'assistant')).toBe(false)
56+
})
4657
it('retains an explicit workspace target and rejects malformed targets', () => {
4758
const item = { type: 'secret_input', name: 'TOKEN', workspaceId: 'workspace-a' }
4859
expect(parseCredentialTagBody(JSON.stringify(item))).toEqual([item])

0 commit comments

Comments
 (0)