11'use client'
22
33import { 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'
516import { useParams } from 'next/navigation'
617import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
718import { 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'
1331import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
1432import { useTablesList } from '@/hooks/queries/tables'
1533import { 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
4766export 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+
4972export 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
5686export 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+
615752function 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