diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index dd45bd23ccd..e8de183ad03 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,7 +1,18 @@ 'use client' import { createElement, useMemo, useState } from 'react' -import { ArrowRight, ChevronDown, cn, Expandable, ExpandableContent, SecretReveal } from '@sim/emcn' +import { + ArrowRight, + Button, + ChevronDown, + cn, + Expandable, + ExpandableContent, + SecretInput, + SecretReveal, + Tooltip, + toast, +} from '@sim/emcn' import { useParams } from 'next/navigation' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' @@ -10,6 +21,12 @@ import type { ChatMessageContext, MothershipResource, } from '@/app/workspace/[workspaceId]/home/types' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + usePersonalEnvironment, + useSavePersonalEnvironment, + useUpsertWorkspaceEnvironment, +} from '@/hooks/queries/environment' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' @@ -42,15 +59,27 @@ export const CREDENTIAL_TAG_TYPES = [ 'sim_key', 'credential_id', 'link', + 'secret_input', ] as const export type CredentialTagType = (typeof CREDENTIAL_TAG_TYPES)[number] +export const SECRET_INPUT_SCOPES = ['personal', 'workspace'] as const + +export type SecretInputScope = (typeof SECRET_INPUT_SCOPES)[number] + export interface CredentialTagData { value?: string type: CredentialTagType provider?: string redacted?: boolean + /** + * Env-var key name to save the pasted secret under (secret_input only), + * e.g. "OPENAI_API_KEY". + */ + name?: string + /** Where a secret_input value is persisted. Defaults to "workspace". */ + scope?: SecretInputScope } export interface MothershipErrorTagData { @@ -149,6 +178,17 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { return false } if (value.provider !== undefined && typeof value.provider !== 'string') return false + // secret_input is an empty input the user fills in — it carries a key name to + // save under, not a value. + if (value.type === 'secret_input') { + if ( + value.scope !== undefined && + !(SECRET_INPUT_SCOPES as readonly string[]).includes(value.scope as string) + ) { + return false + } + return typeof value.name === 'string' && value.name.trim().length > 0 + } if (value.redacted === true) return value.value === undefined || typeof value.value === 'string' return typeof value.value === 'string' } @@ -612,9 +652,108 @@ const LockIcon = (props: { className?: string }) => ( ) +/** + * Inline "paste a secret" widget rendered for + * `{"type":"secret_input","name":"OPENAI_API_KEY"}`. + * Reuses the shared emcn SecretInput; the pasted value is saved straight to + * workspace (default) or personal environment variables under `name` and never + * flows back through the chat transcript. + */ +function SecretInputDisplay({ data }: { data: CredentialTagData }) { + const { workspaceId } = useParams<{ workspaceId: string }>() + const secretName = (data.name ?? '').trim() + const scope: SecretInputScope = data.scope === 'personal' ? 'personal' : 'workspace' + + const [value, setValue] = useState('') + const [saved, setSaved] = useState(false) + + const upsertWorkspace = useUpsertWorkspaceEnvironment() + const savePersonal = useSavePersonalEnvironment() + const personalQuery = usePersonalEnvironment() + const personalEnv = personalQuery.data + const { canEdit } = useUserPermissionsContext() + + // Setting a workspace var needs write/admin (same gate as the secrets manager); + // personal vars are the user's own, so any member may set them. + const canManage = scope === 'personal' || canEdit + + const isSaving = upsertWorkspace.isPending || savePersonal.isPending + // Personal saves replace the whole map, so block until existing vars are loaded. + const personalReady = scope !== 'personal' || personalEnv !== undefined + const canSave = + canManage && secretName.length > 0 && value.trim().length > 0 && !isSaving && personalReady + + const handleSave = async () => { + if (!canSave) return + try { + if (scope === 'personal') { + // The personal POST replaces the whole map, so re-read the latest vars + // right before merging — a stale snapshot would drop keys saved elsewhere. + const { data: latest } = await personalQuery.refetch() + const merged: Record = {} + for (const [key, entry] of Object.entries(latest ?? personalEnv ?? {})) + merged[key] = entry.value + merged[secretName] = value + await savePersonal.mutateAsync({ variables: merged }) + } else { + await upsertWorkspace.mutateAsync({ workspaceId, variables: { [secretName]: value } }) + } + setValue('') + setSaved(true) + toast.success(`Saved ${secretName}`) + } catch { + toast.error(`Couldn't save ${secretName}. Please try again.`) + } + } + + if (!secretName) return null + // Only confirm after the user saves via THIS widget. A fresh prompt always shows + // the input so the user can set or override the key, even if it already exists. + if (saved) return + if (!canManage) return null + + return ( + { + if (e.key === 'Enter') { + e.preventDefault() + void handleSave() + } + }} + endAdornment={ + + + + + {isSaving ? 'Saving…' : 'Save'} + + } + /> + ) +} + function CredentialDisplay({ data }: { data: CredentialTagData }) { + const { canEdit } = useUserPermissionsContext() + + if (data.type === 'secret_input') { + return + } + if (data.type === 'link') { - if (!data.provider) return null + // Connecting a credential mutates the workspace — hide it from read-only members. + if (!data.provider || !canEdit) return null const Icon = getCredentialIcon(data.provider) ?? LockIcon return (