feat(envvars): use cache for envvar dropdown key names, prevent autofill & suggestions in the settings (#1769)

* feat(envvars): use cache for envvar dropdown key names, prevent autofill & suggestions in the settings

* add the same prevention for autocomplete and suggestions to sso and webhook
This commit is contained in:
Waleed
2025-10-30 00:23:35 -07:00
committed by GitHub
parent 8b0079b834
commit 61725c2d15
9 changed files with 192 additions and 51 deletions

View File

@@ -505,7 +505,32 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
return (
<Dialog open={open} onOpenChange={handleCloseModal}>
<DialogContent className='flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[800px]'>
<DialogContent className='relative flex h-[70vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[800px]'>
{/* Hidden dummy inputs to prevent browser password manager autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<DialogTitle className='font-medium text-lg'>Webhook Notifications</DialogTitle>
</DialogHeader>
@@ -817,7 +842,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
<div className='relative'>
<Input
id='secret'
type={showSecret ? 'text' : 'password'}
type='text'
placeholder='Webhook secret for signature verification'
value={newWebhook.secret}
onChange={(e) => {
@@ -825,11 +850,16 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
setFieldErrors({ ...fieldErrors, general: undefined })
}}
className='h-9 rounded-[8px] pr-32'
autoComplete='new-password'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
data-form-type='other'
style={
!showSecret
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
: undefined
}
/>
<div className='absolute top-0.5 right-0.5 flex h-8 items-center gap-1 pr-1'>
<Tooltip>

View File

@@ -464,6 +464,7 @@ export function ShortInput({
setShowEnvVars(false)
setSearchTerm('')
}}
maxHeight='192px'
/>
<TagDropdown
visible={showTags}

View File

@@ -44,6 +44,7 @@ import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
import { useCopilotStore } from '@/stores/copilot/store'
import { useExecutionStore } from '@/stores/execution/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { hasWorkflowsInitiallyLoaded, useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -1145,6 +1146,26 @@ const WorkflowContent = React.memo(() => {
setIsWorkflowReady(shouldBeReady)
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
// Preload workspace environment variables when workflow is ready
const loadWorkspaceEnvironment = useEnvironmentStore((state) => state.loadWorkspaceEnvironment)
const clearWorkspaceEnvCache = useEnvironmentStore((state) => state.clearWorkspaceEnvCache)
const prevWorkspaceIdRef = useRef<string | null>(null)
useEffect(() => {
// Only preload if workflow is ready and workspaceId is available
if (!isWorkflowReady || !workspaceId) return
// Clear cache if workspace changed
if (prevWorkspaceIdRef.current && prevWorkspaceIdRef.current !== workspaceId) {
clearWorkspaceEnvCache(prevWorkspaceIdRef.current)
}
// Preload workspace environment (will use cache if available)
void loadWorkspaceEnvironment(workspaceId)
prevWorkspaceIdRef.current = workspaceId
}, [isWorkflowReady, workspaceId, loadWorkspaceEnvironment, clearWorkspaceEnvCache])
// Handle navigation and validation
useEffect(() => {
const validateAndNavigate = async () => {

View File

@@ -407,7 +407,7 @@ export function EnvironmentVariables({
data-input-type='value'
value={envVar.value}
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
type={focusedValueIndex === originalIndex ? 'text' : 'password'}
type='text'
onFocus={(e) => {
if (!isConflict) {
e.target.removeAttribute('readOnly')
@@ -421,10 +421,15 @@ export function EnvironmentVariables({
disabled={isConflict}
aria-disabled={isConflict}
name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`}
autoComplete='new-password'
autoComplete='off'
autoCapitalize='off'
spellCheck='false'
readOnly={isConflict}
style={
focusedValueIndex !== originalIndex && !isConflict
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
: undefined
}
className={`allow-scroll h-9 rounded-[8px] border-none px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isConflict ? 'cursor-not-allowed border border-red-500 bg-[#F6D2D2] outline-none ring-0 disabled:bg-[#F6D2D2] disabled:opacity-100 dark:bg-[#442929] disabled:dark:bg-[#442929]' : 'bg-muted'}`}
/>
<div className='flex items-center justify-end gap-2'>
@@ -476,8 +481,31 @@ export function EnvironmentVariables({
return (
<div className='relative flex h-full flex-col'>
{/* Hidden dummy input to prevent autofill */}
<input type='text' name='hidden' style={{ display: 'none' }} autoComplete='false' />
{/* Hidden dummy inputs to prevent browser password manager autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}

View File

@@ -497,7 +497,32 @@ export function SSO() {
const showStatus = hasProviders && !showConfigForm
return (
<div className='flex h-full flex-col'>
<div className='relative flex h-full flex-col'>
{/* Hidden dummy inputs to prevent browser password manager autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='password'
name='fakepasswordremembered'
autoComplete='current-password'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<input
type='email'
name='fakeemailremembered'
autoComplete='email'
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
tabIndex={-1}
readOnly
/>
<div className='flex-1 overflow-y-auto px-6 pt-4 pb-4'>
<div className='space-y-6'>
{error && (
@@ -757,11 +782,11 @@ export function SSO() {
<div className='relative'>
<Input
id='client-secret'
type={showClientSecret ? 'text' : 'password'}
type='text'
placeholder='Enter Client Secret'
value={formData.clientSecret}
name='sso_client_key'
autoComplete='new-password'
autoComplete='off'
autoCapitalize='none'
spellCheck={false}
readOnly
@@ -771,6 +796,11 @@ export function SSO() {
}}
onBlurCapture={() => setShowClientSecret(false)}
onChange={(e) => handleInputChange('clientSecret', e.target.value)}
style={
!showClientSecret
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
: undefined
}
className={cn(
'rounded-[10px] pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100',
showErrors &&

View File

@@ -42,7 +42,6 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}>({ workspace: {}, personal: {}, conflicts: [] })
const [selectedIndex, setSelectedIndex] = useState(0)
// Load workspace environment variables when workspaceId changes
useEffect(() => {
if (workspaceId && visible) {
loadWorkspaceEnvironment(workspaceId).then((data) => {
@@ -51,36 +50,26 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}
}, [workspaceId, visible, loadWorkspaceEnvironment])
// Combine and organize environment variables
const envVarGroups: EnvVarGroup[] = []
if (workspaceId) {
// When workspaceId is provided, show both workspace and user env vars
const workspaceVars = Object.keys(workspaceEnvData.workspace)
const personalVars = Object.keys(workspaceEnvData.personal)
if (workspaceVars.length > 0) {
envVarGroups.push({ label: 'Workspace', variables: workspaceVars })
}
if (personalVars.length > 0) {
envVarGroups.push({ label: 'Personal', variables: personalVars })
}
envVarGroups.push({ label: 'Workspace', variables: workspaceVars })
envVarGroups.push({ label: 'Personal', variables: personalVars })
} else {
// Fallback to user env vars only
if (userEnvVars.length > 0) {
envVarGroups.push({ label: 'Personal', variables: userEnvVars })
}
}
// Flatten all variables for filtering and selection
const allEnvVars = envVarGroups.flatMap((group) => group.variables)
// Filter env vars based on search term
const filteredEnvVars = allEnvVars.filter((envVar) =>
envVar.toLowerCase().includes(searchTerm.toLowerCase())
)
// Create filtered groups for display
const filteredGroups = envVarGroups
.map((group) => ({
...group,
@@ -90,41 +79,30 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
}))
.filter((group) => group.variables.length > 0)
// Reset selection when filtered results change
useEffect(() => {
setSelectedIndex(0)
}, [searchTerm])
// Handle environment variable selection
const handleEnvVarSelect = (envVar: string) => {
const textBeforeCursor = inputValue.slice(0, cursorPosition)
const textAfterCursor = inputValue.slice(cursorPosition)
// Find the start of the env var syntax (last '{{' before cursor)
const lastOpenBraces = textBeforeCursor.lastIndexOf('{{')
// Check if we're in a standard env var context (with braces) or direct typing mode
const isStandardEnvVarContext = lastOpenBraces !== -1
if (isStandardEnvVarContext) {
// Standard behavior with {{ }} syntax
const startText = textBeforeCursor.slice(0, lastOpenBraces)
// Find the end of any existing env var syntax after cursor
const closeIndex = textAfterCursor.indexOf('}}')
const endText = closeIndex !== -1 ? textAfterCursor.slice(closeIndex + 2) : textAfterCursor
// Construct the new value with proper env var syntax
const newValue = `${startText}{{${envVar}}}${endText}`
onSelect(newValue)
} else {
// For direct typing mode (API key fields), check if we need to replace existing text
// This handles the case where user has already typed part of a variable name
if (inputValue.trim() !== '') {
// Replace the entire input with the selected env var
onSelect(`{{${envVar}}}`)
} else {
// Empty input, just insert the env var
onSelect(`{{${envVar}}}`)
}
}
@@ -132,7 +110,6 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
onClose?.()
}
// Add and remove keyboard event listener
useEffect(() => {
if (visible) {
const handleKeyboardEvent = (e: KeyboardEvent) => {
@@ -201,14 +178,14 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
</div>
) : (
<div
className={cn('py-1', maxHeight !== 'none' && 'overflow-y-auto')}
className={cn('py-1', maxHeight !== 'none' && 'allow-scroll max-h-48 overflow-y-auto')}
style={{
maxHeight: maxHeight !== 'none' ? maxHeight : undefined,
scrollbarWidth: maxHeight !== 'none' ? 'thin' : undefined,
}}
>
{filteredGroups.map((group) => (
<div key={group.label}>
{filteredGroups.length > 1 && (
{workspaceId && (
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
{group.label}
</div>
@@ -226,7 +203,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
)}
onMouseEnter={() => setSelectedIndex(globalIndex)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
e.preventDefault()
handleEnvVarSelect(envVar)
}}
>
@@ -242,21 +219,17 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
)
}
// Helper function to check for '{{' trigger and get search term
export const checkEnvVarTrigger = (
text: string,
cursorPosition: number
): { show: boolean; searchTerm: string } => {
if (cursorPosition >= 2) {
const textBeforeCursor = text.slice(0, cursorPosition)
// Look for {{ pattern followed by optional text
const match = textBeforeCursor.match(/\{\{(\w*)$/)
if (match) {
return { show: true, searchTerm: match[1] }
}
// Also check for exact {{ without any text after it
// This ensures all env vars show when user just types {{
if (textBeforeCursor.endsWith('{{')) {
return { show: true, searchTerm: '' }
}

View File

@@ -217,11 +217,7 @@ export const resetAllStores = () => {
})
useWorkflowStore.getState().clear()
useSubBlockStore.getState().clear()
useEnvironmentStore.setState({
variables: {},
isLoading: false,
error: null,
})
useEnvironmentStore.getState().reset()
useExecutionStore.getState().reset()
useConsoleStore.setState({ entries: [], isOpen: false })
useCopilotStore.setState({ messages: [], isSendingMessage: false, error: null })

View File

@@ -1,7 +1,11 @@
import { create } from 'zustand'
import { createLogger } from '@/lib/logs/console/logger'
import { API_ENDPOINTS } from '@/stores/constants'
import type { EnvironmentStore, EnvironmentVariable } from '@/stores/settings/environment/types'
import type {
CachedWorkspaceEnvData,
EnvironmentStore,
EnvironmentVariable,
} from '@/stores/settings/environment/types'
const logger = createLogger('EnvironmentStore')
@@ -9,6 +13,7 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
variables: {},
isLoading: false,
error: null,
workspaceEnvCache: new Map<string, CachedWorkspaceEnvData>(),
loadEnvironmentVariables: async () => {
try {
@@ -77,6 +82,8 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
}
set({ isLoading: false })
get().clearWorkspaceEnvCache()
} catch (error) {
logger.error('Error saving environment variables:', { error })
set({
@@ -89,6 +96,16 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
},
loadWorkspaceEnvironment: async (workspaceId: string) => {
// Check cache first
const cached = get().workspaceEnvCache.get(workspaceId)
if (cached) {
return {
workspace: cached.workspace,
personal: cached.personal,
conflicts: cached.conflicts,
}
}
try {
set({ isLoading: true, error: null })
@@ -98,12 +115,21 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
}
const { data } = await response.json()
set({ isLoading: false })
return data as {
const envData = data as {
workspace: Record<string, string>
personal: Record<string, string>
conflicts: string[]
}
// Cache the result
const cache = new Map(get().workspaceEnvCache)
cache.set(workspaceId, {
...envData,
cachedAt: Date.now(),
})
set({ workspaceEnvCache: cache, isLoading: false })
return envData
} catch (error) {
logger.error('Error loading workspace environment:', { error })
set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false })
@@ -123,6 +149,9 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
throw new Error(`Failed to update workspace environment: ${response.statusText}`)
}
set({ isLoading: false })
// Invalidate cache for this workspace
get().clearWorkspaceEnvCache(workspaceId)
} catch (error) {
logger.error('Error updating workspace environment:', { error })
set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false })
@@ -141,6 +170,9 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
throw new Error(`Failed to remove workspace environment keys: ${response.statusText}`)
}
set({ isLoading: false })
// Invalidate cache for this workspace
get().clearWorkspaceEnvCache(workspaceId)
} catch (error) {
logger.error('Error removing workspace environment keys:', { error })
set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false })
@@ -150,4 +182,24 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
getAllVariables: (): Record<string, EnvironmentVariable> => {
return get().variables
},
clearWorkspaceEnvCache: (workspaceId?: string) => {
const cache = new Map(get().workspaceEnvCache)
if (workspaceId) {
cache.delete(workspaceId)
set({ workspaceEnvCache: cache })
} else {
// Clear all caches
set({ workspaceEnvCache: new Map() })
}
},
reset: () => {
set({
variables: {},
isLoading: false,
error: null,
workspaceEnvCache: new Map(),
})
},
}))

View File

@@ -3,10 +3,18 @@ export interface EnvironmentVariable {
value: string
}
export interface CachedWorkspaceEnvData {
workspace: Record<string, string>
personal: Record<string, string>
conflicts: string[]
cachedAt: number
}
export interface EnvironmentState {
variables: Record<string, EnvironmentVariable>
isLoading: boolean
error: string | null
workspaceEnvCache: Map<string, CachedWorkspaceEnvData>
}
export interface EnvironmentStore extends EnvironmentState {
@@ -25,4 +33,6 @@ export interface EnvironmentStore extends EnvironmentState {
removeWorkspaceEnvironmentKeys: (workspaceId: string, keys: string[]) => Promise<void>
getAllVariables: () => Record<string, EnvironmentVariable>
clearWorkspaceEnvCache: (workspaceId?: string) => void
reset: () => void
}