Skip to content

Commit c2c5f80

Browse files
committed
feat(mship): secrets prompting
t# with '#' will be ignored, and an empty message aborts the commit.
1 parent 48c1b45 commit c2c5f80

1 file changed

Lines changed: 146 additions & 2 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags

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

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
'use client'
22

33
import { createElement, useMemo, useState } from 'react'
4-
import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, SecretReveal } from '@sim/emcn'
4+
import {
5+
ArrowRight,
6+
Button,
7+
ChevronDown,
8+
cn,
9+
Expandable,
10+
ExpandableContent,
11+
SecretInput,
12+
SecretReveal,
13+
Tooltip,
14+
toast,
15+
} from '@sim/emcn'
516
import { useParams } from 'next/navigation'
617
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
718
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
@@ -10,6 +21,13 @@ import type {
1021
ChatMessageContext,
1122
MothershipResource,
1223
} from '@/app/workspace/[workspaceId]/home/types'
24+
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
25+
import {
26+
usePersonalEnvironment,
27+
useSavePersonalEnvironment,
28+
useUpsertWorkspaceEnvironment,
29+
useWorkspaceEnvironment,
30+
} from '@/hooks/queries/environment'
1331
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
1432
import { useTablesList } from '@/hooks/queries/tables'
1533
import { useWorkflows } from '@/hooks/queries/workflows'
@@ -42,15 +60,27 @@ export const CREDENTIAL_TAG_TYPES = [
4260
'sim_key',
4361
'credential_id',
4462
'link',
63+
'secret_input',
4564
] as const
4665

4766
export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number]
4867

68+
export const SECRET_INPUT_SCOPES = ['personal', 'workspace'] as const
69+
70+
export type SecretInputScope = (typeof SECRET_INPUT_SCOPES)[number]
71+
4972
export interface CredentialTagData {
5073
value?: string
5174
type: CredentialTagType
5275
provider?: string
5376
redacted?: boolean
77+
/**
78+
* Env-var key name to save the pasted secret under (secret_input only),
79+
* e.g. "OPENAI_API_KEY".
80+
*/
81+
name?: string
82+
/** Where a secret_input value is persisted. Defaults to "workspace". */
83+
scope?: SecretInputScope
5484
}
5585

5686
export interface MothershipErrorTagData {
@@ -149,6 +179,17 @@ function isCredentialTagData(value: unknown): value is CredentialTagData {
149179
return false
150180
}
151181
if (value.provider !== undefined && typeof value.provider !== 'string') return false
182+
// secret_input is an empty input the user fills in — it carries a key name to
183+
// save under, not a value.
184+
if (value.type === 'secret_input') {
185+
if (
186+
value.scope !== undefined &&
187+
!(SECRET_INPUT_SCOPES as readonly string[]).includes(value.scope as string)
188+
) {
189+
return false
190+
}
191+
return typeof value.name === 'string' && value.name.trim().length > 0
192+
}
152193
if (value.redacted === true) return value.value === undefined || typeof value.value === 'string'
153194
return typeof value.value === 'string'
154195
}
@@ -612,9 +653,112 @@ const LockIcon = (props: { className?: string }) => (
612653
</svg>
613654
)
614655

656+
/**
657+
* Inline "paste a secret" widget rendered for
658+
* `<credential>{"type":"secret_input","name":"OPENAI_API_KEY"}</credential>`.
659+
* Reuses the shared emcn SecretInput; the pasted value is saved straight to
660+
* workspace (default) or personal environment variables under `name` and never
661+
* flows back through the chat transcript.
662+
*/
663+
function SecretInputDisplay({ data }: { data: CredentialTagData }) {
664+
const { workspaceId } = useParams<{ workspaceId: string }>()
665+
const secretName = (data.name ?? '').trim()
666+
const scope: SecretInputScope = data.scope === 'personal' ? 'personal' : 'workspace'
667+
668+
const [value, setValue] = useState('')
669+
const [saved, setSaved] = useState(false)
670+
671+
const upsertWorkspace = useUpsertWorkspaceEnvironment()
672+
const savePersonal = useSavePersonalEnvironment()
673+
const { data: personalEnv } = usePersonalEnvironment()
674+
const { data: workspaceEnv } = useWorkspaceEnvironment(workspaceId)
675+
const { canEdit } = useUserPermissionsContext()
676+
677+
// Setting a workspace var needs write/admin (same gate as the secrets manager);
678+
// personal vars are the user's own, so any member may set them.
679+
const canManage = scope === 'personal' || canEdit
680+
681+
// Reflect persisted state so the widget still shows "saved" after navigating
682+
// away and back (local `saved` is lost on remount). Key presence is enough —
683+
// workspace values come back masked for non-admins.
684+
const alreadySaved =
685+
scope === 'personal'
686+
? personalEnv !== undefined && secretName in personalEnv
687+
: workspaceEnv !== undefined && secretName in workspaceEnv.workspace
688+
689+
const isSaving = upsertWorkspace.isPending || savePersonal.isPending
690+
// Personal saves replace the whole map, so block until existing vars are loaded.
691+
const personalReady = scope !== 'personal' || personalEnv !== undefined
692+
const canSave =
693+
canManage && secretName.length > 0 && value.trim().length > 0 && !isSaving && personalReady
694+
695+
const handleSave = async () => {
696+
if (!canSave) return
697+
try {
698+
if (scope === 'personal') {
699+
const merged: Record<string, string> = {}
700+
for (const [key, entry] of Object.entries(personalEnv ?? {})) merged[key] = entry.value
701+
merged[secretName] = value
702+
await savePersonal.mutateAsync({ variables: merged })
703+
} else {
704+
await upsertWorkspace.mutateAsync({ workspaceId, variables: { [secretName]: value } })
705+
}
706+
setValue('')
707+
setSaved(true)
708+
toast.success(`Saved ${secretName}`)
709+
} catch {
710+
toast.error(`Couldn't save ${secretName}. Please try again.`)
711+
}
712+
}
713+
714+
if (!secretName) return null
715+
// Already-set keys show a read-only "saved" indicator for everyone; the editable
716+
// input only renders for users who can actually set the key.
717+
if (saved || alreadySaved) return <SecretReveal redacted />
718+
if (!canManage) return null
719+
720+
return (
721+
<SecretInput
722+
value={value}
723+
onChange={setValue}
724+
placeholder={`Paste ${secretName}`}
725+
onKeyDown={(e) => {
726+
if (e.key === 'Enter') {
727+
e.preventDefault()
728+
void handleSave()
729+
}
730+
}}
731+
endAdornment={
732+
<Tooltip.Root>
733+
<Tooltip.Trigger asChild>
734+
<Button
735+
type='button'
736+
variant='quiet'
737+
className='size-[18px] rounded-sm p-0'
738+
onClick={() => void handleSave()}
739+
disabled={!canSave}
740+
aria-label='Save'
741+
>
742+
<ArrowRight className='size-[13px]' />
743+
</Button>
744+
</Tooltip.Trigger>
745+
<Tooltip.Content>{isSaving ? 'Saving…' : 'Save'}</Tooltip.Content>
746+
</Tooltip.Root>
747+
}
748+
/>
749+
)
750+
}
751+
615752
function CredentialDisplay({ data }: { data: CredentialTagData }) {
753+
const { canEdit } = useUserPermissionsContext()
754+
755+
if (data.type === 'secret_input') {
756+
return <SecretInputDisplay data={data} />
757+
}
758+
616759
if (data.type === 'link') {
617-
if (!data.provider) return null
760+
// Connecting a credential mutates the workspace — hide it from read-only members.
761+
if (!data.provider || !canEdit) return null
618762
const Icon = getCredentialIcon(data.provider) ?? LockIcon
619763
return (
620764
<a

0 commit comments

Comments
 (0)