mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-15 00:44:56 -05:00
remove unused code
This commit is contained in:
@@ -31,10 +31,9 @@ const logger = createLogger('ApiKeys')
|
||||
|
||||
interface ApiKeysProps {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
||||
}
|
||||
|
||||
export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id
|
||||
const params = useParams()
|
||||
@@ -118,12 +117,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
onOpenChange?.(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (registerCloseHandler) {
|
||||
registerCloseHandler(handleModalClose)
|
||||
}
|
||||
}, [registerCloseHandler])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
|
||||
@@ -4,8 +4,6 @@ import { CredentialsManager } from '@/app/workspace/[workspaceId]/w/components/s
|
||||
|
||||
interface CredentialsProps {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
||||
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
|
||||
}
|
||||
|
||||
export function Credentials(_props: CredentialsProps) {
|
||||
|
||||
@@ -1,864 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search, Share2, Undo2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Input as EmcnInput,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { isValidEnvVarName } from '@/executor/constants'
|
||||
import {
|
||||
usePersonalEnvironment,
|
||||
useRemoveWorkspaceEnvironment,
|
||||
useSavePersonalEnvironment,
|
||||
useUpsertWorkspaceEnvironment,
|
||||
useWorkspaceEnvironment,
|
||||
type WorkspaceEnvironmentData,
|
||||
} from '@/hooks/queries/environment'
|
||||
|
||||
const logger = createLogger('EnvironmentVariables')
|
||||
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr)_8px_minmax(0,1fr)_auto] items-center'
|
||||
|
||||
const generateRowId = (() => {
|
||||
let counter = 0
|
||||
return () => {
|
||||
counter += 1
|
||||
return Date.now() + counter
|
||||
}
|
||||
})()
|
||||
|
||||
const createEmptyEnvVar = (): UIEnvironmentVariable => ({
|
||||
key: '',
|
||||
value: '',
|
||||
id: generateRowId(),
|
||||
})
|
||||
|
||||
interface UIEnvironmentVariable {
|
||||
key: string
|
||||
value: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an environment variable key.
|
||||
* Returns an error message if invalid, undefined if valid.
|
||||
*/
|
||||
function validateEnvVarKey(key: string): string | undefined {
|
||||
if (!key) return undefined
|
||||
if (key.includes(' ')) return 'Spaces are not allowed'
|
||||
if (!isValidEnvVarName(key)) return 'Only letters, numbers, and underscores allowed'
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface EnvironmentVariablesProps {
|
||||
registerBeforeLeaveHandler?: (handler: (onProceed: () => void) => void) => void
|
||||
}
|
||||
|
||||
interface WorkspaceVariableRowProps {
|
||||
envKey: string
|
||||
value: string
|
||||
renamingKey: string | null
|
||||
pendingKeyValue: string
|
||||
isNewlyPromoted: boolean
|
||||
onRenameStart: (key: string) => void
|
||||
onPendingKeyChange: (value: string) => void
|
||||
onRenameEnd: (key: string, value: string) => void
|
||||
onDelete: (key: string) => void
|
||||
onDemote: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
function WorkspaceVariableRow({
|
||||
envKey,
|
||||
value,
|
||||
renamingKey,
|
||||
pendingKeyValue,
|
||||
isNewlyPromoted,
|
||||
onRenameStart,
|
||||
onPendingKeyChange,
|
||||
onRenameEnd,
|
||||
onDelete,
|
||||
onDemote,
|
||||
}: WorkspaceVariableRowProps) {
|
||||
return (
|
||||
<div className={GRID_COLS}>
|
||||
<EmcnInput
|
||||
value={renamingKey === envKey ? pendingKeyValue : envKey}
|
||||
onChange={(e) => {
|
||||
if (renamingKey !== envKey) onRenameStart(envKey)
|
||||
onPendingKeyChange(e.target.value)
|
||||
}}
|
||||
onBlur={() => onRenameEnd(envKey, value)}
|
||||
name={`workspace_env_key_${envKey}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
className='h-9'
|
||||
/>
|
||||
<div />
|
||||
<EmcnInput
|
||||
value={value ? '•'.repeat(value.length) : ''}
|
||||
readOnly
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
className='h-9'
|
||||
/>
|
||||
<div className='ml-[8px] flex'>
|
||||
{isNewlyPromoted && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' onClick={() => onDemote(envKey, value)} className='h-9 w-9'>
|
||||
<Undo2 className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Change to personal scope</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button variant='ghost' onClick={() => onDelete(envKey)} className='h-9 w-9'>
|
||||
<Trash />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete secret</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvironmentVariables({ registerBeforeLeaveHandler }: EnvironmentVariablesProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
|
||||
const { data: personalEnvData, isLoading: isPersonalLoading } = usePersonalEnvironment()
|
||||
const { data: workspaceEnvData, isLoading: isWorkspaceLoading } = useWorkspaceEnvironment(
|
||||
workspaceId,
|
||||
{
|
||||
select: useCallback(
|
||||
(data: WorkspaceEnvironmentData): WorkspaceEnvironmentData => ({
|
||||
workspace: data.workspace || {},
|
||||
personal: data.personal || {},
|
||||
conflicts: data.conflicts || [],
|
||||
}),
|
||||
[]
|
||||
),
|
||||
}
|
||||
)
|
||||
const savePersonalMutation = useSavePersonalEnvironment()
|
||||
const upsertWorkspaceMutation = useUpsertWorkspaceEnvironment()
|
||||
const removeWorkspaceMutation = useRemoveWorkspaceEnvironment()
|
||||
|
||||
const isLoading = isPersonalLoading || isWorkspaceLoading
|
||||
const variables = useMemo(() => personalEnvData || {}, [personalEnvData])
|
||||
|
||||
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
|
||||
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
|
||||
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
|
||||
const [workspaceVars, setWorkspaceVars] = useState<Record<string, string>>({})
|
||||
const [conflicts, setConflicts] = useState<string[]>([])
|
||||
const [renamingKey, setRenamingKey] = useState<string | null>(null)
|
||||
const [pendingKeyValue, setPendingKeyValue] = useState<string>('')
|
||||
const [changeToken, setChangeToken] = useState(0)
|
||||
|
||||
const initialWorkspaceVarsRef = useRef<Record<string, string>>({})
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const pendingProceedCallback = useRef<(() => void) | null>(null)
|
||||
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
|
||||
const hasChangesRef = useRef(false)
|
||||
const hasSavedRef = useRef(false)
|
||||
|
||||
const filteredEnvVars = useMemo(() => {
|
||||
const mapped = envVars.map((envVar, index) => ({ envVar, originalIndex: index }))
|
||||
if (!searchTerm.trim()) return mapped
|
||||
const term = searchTerm.toLowerCase()
|
||||
return mapped.filter(({ envVar }) => envVar.key.toLowerCase().includes(term))
|
||||
}, [envVars, searchTerm])
|
||||
|
||||
const filteredWorkspaceEntries = useMemo(() => {
|
||||
const entries = Object.entries(workspaceVars)
|
||||
if (!searchTerm.trim()) return entries
|
||||
const term = searchTerm.toLowerCase()
|
||||
return entries.filter(([key]) => key.toLowerCase().includes(term))
|
||||
}, [workspaceVars, searchTerm])
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
const initialVars = initialVarsRef.current.filter((v) => v.key || v.value)
|
||||
const currentVars = envVars.filter((v) => v.key || v.value)
|
||||
const initialMap = new Map(initialVars.map((v) => [v.key, v.value]))
|
||||
const currentMap = new Map(currentVars.map((v) => [v.key, v.value]))
|
||||
|
||||
if (initialMap.size !== currentMap.size) return true
|
||||
|
||||
for (const [key, value] of currentMap) {
|
||||
if (initialMap.get(key) !== value) return true
|
||||
}
|
||||
|
||||
for (const key of initialMap.keys()) {
|
||||
if (!currentMap.has(key)) return true
|
||||
}
|
||||
|
||||
const before = initialWorkspaceVarsRef.current
|
||||
const after = workspaceVars
|
||||
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)])
|
||||
|
||||
if (Object.keys(before).length !== Object.keys(after).length) return true
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (before[key] !== after[key]) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [envVars, workspaceVars, changeToken])
|
||||
|
||||
const hasConflicts = useMemo(() => {
|
||||
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
const hasInvalidKeys = useMemo(() => {
|
||||
return envVars.some((envVar) => !!envVar.key && validateEnvVarKey(envVar.key))
|
||||
}, [envVars])
|
||||
|
||||
useEffect(() => {
|
||||
hasChangesRef.current = hasChanges
|
||||
}, [hasChanges])
|
||||
|
||||
const handleBeforeLeave = useCallback((onProceed: () => void) => {
|
||||
if (hasChangesRef.current) {
|
||||
setShowUnsavedChanges(true)
|
||||
pendingProceedCallback.current = onProceed
|
||||
} else {
|
||||
onProceed()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSavedRef.current) return
|
||||
|
||||
const existingVars = Object.values(variables)
|
||||
const initialVars = existingVars.length
|
||||
? existingVars.map((envVar) => ({
|
||||
...envVar,
|
||||
id: generateRowId(),
|
||||
}))
|
||||
: [createEmptyEnvVar()]
|
||||
initialVarsRef.current = JSON.parse(JSON.stringify(initialVars))
|
||||
setEnvVars(JSON.parse(JSON.stringify(initialVars)))
|
||||
pendingProceedCallback.current = null
|
||||
}, [variables])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceEnvData) {
|
||||
if (hasSavedRef.current) {
|
||||
setConflicts(workspaceEnvData?.conflicts || [])
|
||||
hasSavedRef.current = false
|
||||
} else {
|
||||
setWorkspaceVars(workspaceEnvData?.workspace || {})
|
||||
initialWorkspaceVarsRef.current = workspaceEnvData?.workspace || {}
|
||||
setConflicts(workspaceEnvData?.conflicts || [])
|
||||
}
|
||||
}
|
||||
}, [workspaceEnvData])
|
||||
|
||||
useEffect(() => {
|
||||
if (registerBeforeLeaveHandler) {
|
||||
registerBeforeLeaveHandler(handleBeforeLeave)
|
||||
}
|
||||
}, [registerBeforeLeaveHandler, handleBeforeLeave])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
setShouldScrollToBottom(false)
|
||||
}
|
||||
}, [shouldScrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const personalKeys = envVars.map((envVar) => envVar.key.trim()).filter((key) => key.length > 0)
|
||||
|
||||
const uniquePersonalKeys = Array.from(new Set(personalKeys))
|
||||
|
||||
const computedConflicts = uniquePersonalKeys.filter((key) => Object.hasOwn(workspaceVars, key))
|
||||
|
||||
setConflicts((prev) => {
|
||||
if (prev.length === computedConflicts.length) {
|
||||
const sameKeys = prev.every((key) => computedConflicts.includes(key))
|
||||
if (sameKeys) return prev
|
||||
}
|
||||
return computedConflicts
|
||||
})
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
const handleWorkspaceKeyRename = useCallback(
|
||||
(currentKey: string, currentValue: string) => {
|
||||
const newKey = pendingKeyValue.trim()
|
||||
if (!renamingKey || renamingKey !== currentKey) return
|
||||
setRenamingKey(null)
|
||||
if (!newKey || newKey === currentKey) return
|
||||
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[currentKey]
|
||||
next[newKey] = currentValue
|
||||
return next
|
||||
})
|
||||
},
|
||||
[pendingKeyValue, renamingKey]
|
||||
)
|
||||
|
||||
const handleDeleteWorkspaceVar = useCallback((key: string) => {
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addEnvVar = useCallback(() => {
|
||||
setEnvVars((prev) => [...prev, createEmptyEnvVar()])
|
||||
setSearchTerm('')
|
||||
setShouldScrollToBottom(true)
|
||||
}, [])
|
||||
|
||||
const updateEnvVar = useCallback((index: number, field: 'key' | 'value', value: string) => {
|
||||
setEnvVars((prev) => {
|
||||
const newEnvVars = [...prev]
|
||||
newEnvVars[index][field] = value
|
||||
return newEnvVars
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeEnvVar = useCallback((index: number) => {
|
||||
setEnvVars((prev) => {
|
||||
const newEnvVars = prev.filter((_, i) => i !== index)
|
||||
return newEnvVars.length ? newEnvVars : [createEmptyEnvVar()]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleValueFocus = useCallback((index: number, e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocusedValueIndex(index)
|
||||
e.target.scrollLeft = 0
|
||||
}, [])
|
||||
|
||||
const handleValueClick = useCallback((e: React.MouseEvent<HTMLInputElement>) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.scrollLeft = 0
|
||||
}, [])
|
||||
|
||||
const parseEnvVarLine = useCallback((line: string): UIEnvironmentVariable | null => {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!trimmed || trimmed.startsWith('#')) return null
|
||||
|
||||
const withoutExport = trimmed.replace(/^export\s+/, '')
|
||||
|
||||
const equalIndex = withoutExport.indexOf('=')
|
||||
if (equalIndex === -1 || equalIndex === 0) return null
|
||||
|
||||
const potentialKey = withoutExport.substring(0, equalIndex).trim()
|
||||
if (!isValidEnvVarName(potentialKey)) return null
|
||||
|
||||
let value = withoutExport.substring(equalIndex + 1)
|
||||
|
||||
const looksLikeBase64Key = /^[A-Za-z0-9+/]+$/.test(potentialKey) && !potentialKey.includes('_')
|
||||
const valueIsJustPadding = /^=+$/.test(value.trim())
|
||||
if (looksLikeBase64Key && valueIsJustPadding && potentialKey.length > 20) {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmedValue = value.trim()
|
||||
if (
|
||||
!trimmedValue.startsWith('"') &&
|
||||
!trimmedValue.startsWith("'") &&
|
||||
!trimmedValue.startsWith('`')
|
||||
) {
|
||||
const commentIndex = value.search(/\s#/)
|
||||
if (commentIndex !== -1) {
|
||||
value = value.substring(0, commentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
value = value.trim()
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'")) ||
|
||||
(value.startsWith('`') && value.endsWith('`'))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
|
||||
return { key: potentialKey, value, id: generateRowId() }
|
||||
}, [])
|
||||
|
||||
const handleSingleValuePaste = useCallback(
|
||||
(text: string, index: number, inputType: 'key' | 'value') => {
|
||||
setEnvVars((prev) => {
|
||||
const newEnvVars = [...prev]
|
||||
newEnvVars[index][inputType] = text
|
||||
return newEnvVars
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleKeyValuePaste = useCallback(
|
||||
(lines: string[]) => {
|
||||
const parsedVars = lines
|
||||
.map(parseEnvVarLine)
|
||||
.filter((parsed): parsed is UIEnvironmentVariable => parsed !== null)
|
||||
.filter(({ key, value }) => key && value)
|
||||
|
||||
if (parsedVars.length > 0) {
|
||||
setEnvVars((prev) => {
|
||||
const existingVars = prev.filter((v) => v.key || v.value)
|
||||
return [...existingVars, ...parsedVars]
|
||||
})
|
||||
setShouldScrollToBottom(true)
|
||||
}
|
||||
},
|
||||
[parseEnvVarLine]
|
||||
)
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
if (!text) return
|
||||
|
||||
const lines = text.split('\n').filter((line) => line.trim())
|
||||
if (lines.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const inputType = (e.target as HTMLInputElement).getAttribute('data-input-type') as
|
||||
| 'key'
|
||||
| 'value'
|
||||
|
||||
if (inputType) {
|
||||
const hasValidEnvVarPattern = lines.some((line) => parseEnvVarLine(line) !== null)
|
||||
if (!hasValidEnvVarPattern) {
|
||||
handleSingleValuePaste(text, index, inputType)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyValuePaste(lines)
|
||||
},
|
||||
[parseEnvVarLine, handleSingleValuePaste, handleKeyValuePaste]
|
||||
)
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
|
||||
setWorkspaceVars({ ...initialWorkspaceVarsRef.current })
|
||||
setShowUnsavedChanges(false)
|
||||
|
||||
pendingProceedCallback.current?.()
|
||||
pendingProceedCallback.current = null
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const onProceed = pendingProceedCallback.current
|
||||
|
||||
const prevInitialVars = [...initialVarsRef.current]
|
||||
const prevInitialWorkspaceVars = { ...initialWorkspaceVarsRef.current }
|
||||
|
||||
try {
|
||||
setShowUnsavedChanges(false)
|
||||
hasSavedRef.current = true
|
||||
|
||||
initialWorkspaceVarsRef.current = { ...workspaceVars }
|
||||
initialVarsRef.current = JSON.parse(JSON.stringify(envVars.filter((v) => v.key && v.value)))
|
||||
|
||||
setChangeToken((prev) => prev + 1)
|
||||
|
||||
const validVariables = envVars
|
||||
.filter((v) => v.key && v.value)
|
||||
.reduce<Record<string, string>>((acc, { key, value }) => ({ ...acc, [key]: value }), {})
|
||||
|
||||
await savePersonalMutation.mutateAsync({ variables: validVariables })
|
||||
|
||||
const before = prevInitialWorkspaceVars
|
||||
const after = workspaceVars
|
||||
const toUpsert: Record<string, string> = {}
|
||||
const toDelete: string[] = []
|
||||
|
||||
for (const [k, v] of Object.entries(after)) {
|
||||
if (!(k in before) || before[k] !== v) {
|
||||
toUpsert[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of Object.keys(before)) {
|
||||
if (!(k in after)) toDelete.push(k)
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
if (Object.keys(toUpsert).length) {
|
||||
await upsertWorkspaceMutation.mutateAsync({ workspaceId, variables: toUpsert })
|
||||
}
|
||||
if (toDelete.length) {
|
||||
await removeWorkspaceMutation.mutateAsync({ workspaceId, keys: toDelete })
|
||||
}
|
||||
}
|
||||
|
||||
onProceed?.()
|
||||
pendingProceedCallback.current = null
|
||||
} catch (error) {
|
||||
hasSavedRef.current = false
|
||||
initialVarsRef.current = prevInitialVars
|
||||
initialWorkspaceVarsRef.current = prevInitialWorkspaceVars
|
||||
logger.error('Failed to save environment variables:', error)
|
||||
}
|
||||
}, [
|
||||
envVars,
|
||||
workspaceVars,
|
||||
workspaceId,
|
||||
savePersonalMutation,
|
||||
upsertWorkspaceMutation,
|
||||
removeWorkspaceMutation,
|
||||
])
|
||||
|
||||
const promoteToWorkspace = useCallback(
|
||||
(envVar: UIEnvironmentVariable) => {
|
||||
if (!envVar.key || !envVar.value || !workspaceId) return
|
||||
setWorkspaceVars((prev) => ({ ...prev, [envVar.key]: envVar.value }))
|
||||
setEnvVars((prev) => {
|
||||
const filtered = prev.filter((entry) => entry !== envVar)
|
||||
return filtered.length ? filtered : [createEmptyEnvVar()]
|
||||
})
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const demoteToPersonal = useCallback((key: string, value: string) => {
|
||||
if (!key) return
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setEnvVars((prev) => [...prev, { key, value, id: generateRowId() }])
|
||||
}, [])
|
||||
|
||||
const conflictClassName = 'border-[var(--text-error)] bg-[#F6D2D2] dark:bg-[#442929]'
|
||||
|
||||
const renderEnvVarRow = useCallback(
|
||||
(envVar: UIEnvironmentVariable, originalIndex: number) => {
|
||||
const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)
|
||||
const keyError = validateEnvVarKey(envVar.key)
|
||||
const maskedValueStyle =
|
||||
focusedValueIndex !== originalIndex && !isConflict
|
||||
? ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={GRID_COLS}>
|
||||
<EmcnInput
|
||||
data-input-type='key'
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder='API_KEY'
|
||||
name={`env_variable_name_${envVar.id || originalIndex}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
className={`h-9 ${isConflict ? conflictClassName : ''} ${keyError ? 'border-[var(--text-error)]' : ''}`}
|
||||
/>
|
||||
<div />
|
||||
<EmcnInput
|
||||
data-input-type='value'
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
|
||||
type='text'
|
||||
onFocus={(e) => {
|
||||
if (!isConflict) {
|
||||
e.target.removeAttribute('readOnly')
|
||||
handleValueFocus(originalIndex, e)
|
||||
}
|
||||
}}
|
||||
onClick={handleValueClick}
|
||||
onBlur={() => setFocusedValueIndex(null)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder={isConflict ? 'Workspace override active' : 'Enter value'}
|
||||
disabled={isConflict}
|
||||
aria-disabled={isConflict}
|
||||
name={`env_variable_value_${envVar.id || originalIndex}_${Math.random()}`}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly={isConflict}
|
||||
style={maskedValueStyle}
|
||||
className={`h-9 ${isConflict ? `cursor-not-allowed ${conflictClassName}` : ''}`}
|
||||
/>
|
||||
<div className='ml-[8px] flex items-center'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
disabled={!envVar.key || !envVar.value || isConflict || !workspaceId}
|
||||
onClick={() => promoteToWorkspace(envVar)}
|
||||
className='h-9 w-9'
|
||||
>
|
||||
<Share2 className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Change to workspace scope</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => removeEnvVar(originalIndex)}
|
||||
className='h-9 w-9'
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete secret</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
{keyError && (
|
||||
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{keyError}
|
||||
</div>
|
||||
)}
|
||||
{isConflict && !keyError && (
|
||||
<div className='col-span-3 mt-[4px] text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
Workspace variable with the same name overrides this. Rename your personal key to use
|
||||
it.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
[
|
||||
workspaceVars,
|
||||
workspaceId,
|
||||
focusedValueIndex,
|
||||
updateEnvVar,
|
||||
handlePaste,
|
||||
handleValueFocus,
|
||||
handleValueClick,
|
||||
promoteToWorkspace,
|
||||
removeEnvVar,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='hidden'>
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='password'
|
||||
name='fakepasswordremembered'
|
||||
autoComplete='current-password'
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='email'
|
||||
name='fakeemailremembered'
|
||||
autoComplete='email'
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Input
|
||||
placeholder='Search variables...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
name='env_search_field'
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
readOnly
|
||||
onFocus={(e) => e.target.removeAttribute('readOnly')}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addEnvVar} variant='tertiary' disabled={isLoading}>
|
||||
<Plus className='mr-[6px] h-[13px] w-[13px]' />
|
||||
Add
|
||||
</Button>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || !hasChanges || hasConflicts || hasInvalidKeys}
|
||||
variant='tertiary'
|
||||
className={`${hasConflicts || hasInvalidKeys ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{hasConflicts && <Tooltip.Content>Resolve all conflicts before saving</Tooltip.Content>}
|
||||
{hasInvalidKeys && !hasConflicts && (
|
||||
<Tooltip.Content>Fix invalid variable names before saving</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-5 w-[70px]' />
|
||||
<div className='text-[13px] text-[var(--text-muted)]'>
|
||||
<Skeleton className='h-5 w-[160px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-5 w-[55px]' />
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<div key={`personal-${i}`} className={GRID_COLS}>
|
||||
<Skeleton className='h-9 rounded-[6px]' />
|
||||
<div />
|
||||
<Skeleton className='h-9 rounded-[6px]' />
|
||||
<div className='ml-[8px] flex items-center gap-0'>
|
||||
<Skeleton className='h-9 w-9 rounded-[6px]' />
|
||||
<Skeleton className='h-9 w-9 rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(!searchTerm.trim() || filteredWorkspaceEntries.length > 0) && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Workspace
|
||||
</div>
|
||||
{!searchTerm.trim() && Object.keys(workspaceVars).length === 0 ? (
|
||||
<div className='text-[13px] text-[var(--text-muted)]'>
|
||||
No workspace variables yet
|
||||
</div>
|
||||
) : (
|
||||
(searchTerm.trim()
|
||||
? filteredWorkspaceEntries
|
||||
: Object.entries(workspaceVars)
|
||||
).map(([key, value]) => (
|
||||
<WorkspaceVariableRow
|
||||
key={key}
|
||||
envKey={key}
|
||||
value={value}
|
||||
renamingKey={renamingKey}
|
||||
pendingKeyValue={pendingKeyValue}
|
||||
isNewlyPromoted={!Object.hasOwn(initialWorkspaceVarsRef.current, key)}
|
||||
onRenameStart={setRenamingKey}
|
||||
onPendingKeyChange={setPendingKeyValue}
|
||||
onRenameEnd={handleWorkspaceKeyRename}
|
||||
onDelete={handleDeleteWorkspaceVar}
|
||||
onDemote={demoteToPersonal}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!searchTerm.trim() || filteredEnvVars.length > 0) && (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Personal
|
||||
</div>
|
||||
{filteredEnvVars.map(({ envVar, originalIndex }) => (
|
||||
<div key={envVar.id || originalIndex}>
|
||||
{renderEnvVarRow(envVar, originalIndex)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{searchTerm.trim() &&
|
||||
filteredEnvVars.length === 0 &&
|
||||
filteredWorkspaceEntries.length === 0 &&
|
||||
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No secrets found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showUnsavedChanges} onOpenChange={setShowUnsavedChanges}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{hasConflicts || hasInvalidKeys
|
||||
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
|
||||
: 'You have unsaved changes. Do you want to save them before closing?'}
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='destructive' onClick={handleCancel}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
{hasConflicts || hasInvalidKeys ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
disabled={true}
|
||||
variant='tertiary'
|
||||
className='cursor-not-allowed opacity-50'
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{hasConflicts
|
||||
? 'Resolve all conflicts before saving'
|
||||
: 'Fix invalid variable names before saving'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Button onClick={handleSave} variant='tertiary'>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,10 +5,8 @@ export { CredentialSets } from './credential-sets/credential-sets'
|
||||
export { Credentials } from './credentials/credentials'
|
||||
export { CustomTools } from './custom-tools/custom-tools'
|
||||
export { Debug } from './debug/debug'
|
||||
export { EnvironmentVariables } from './environment/environment'
|
||||
export { Files as FileUploads } from './files/files'
|
||||
export { General } from './general/general'
|
||||
export { Integrations } from './integrations/integrations'
|
||||
export { MCP } from './mcp/mcp'
|
||||
export { Skills } from './skills/skills'
|
||||
export { Subscription } from './subscription/subscription'
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createElement, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { OAUTH_PROVIDERS } from '@/lib/oauth'
|
||||
import {
|
||||
type ServiceInfo,
|
||||
useConnectOAuthService,
|
||||
useDisconnectOAuthService,
|
||||
useOAuthConnections,
|
||||
} from '@/hooks/queries/oauth-connections'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
|
||||
const logger = createLogger('Integrations')
|
||||
|
||||
/**
|
||||
* Static skeleton structure matching OAUTH_PROVIDERS layout
|
||||
* Each entry: [providerName, serviceCount]
|
||||
*/
|
||||
const SKELETON_STRUCTURE: [string, number][] = [
|
||||
['Google', 7],
|
||||
['Microsoft', 6],
|
||||
['GitHub', 1],
|
||||
['X', 1],
|
||||
['Confluence', 1],
|
||||
['Jira', 1],
|
||||
['Airtable', 1],
|
||||
['Notion', 1],
|
||||
['Linear', 1],
|
||||
['Slack', 1],
|
||||
['Reddit', 1],
|
||||
['Wealthbox', 1],
|
||||
['Webflow', 1],
|
||||
['Trello', 1],
|
||||
['Asana', 1],
|
||||
['Pipedrive', 1],
|
||||
['HubSpot', 1],
|
||||
['Salesforce', 1],
|
||||
]
|
||||
|
||||
function IntegrationsSkeleton() {
|
||||
return (
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex w-full items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<Input
|
||||
placeholder='Search integrations...'
|
||||
disabled
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{SKELETON_STRUCTURE.map(([providerName, serviceCount]) => (
|
||||
<div key={providerName} className='flex flex-col gap-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[60px]' />
|
||||
{Array.from({ length: serviceCount }).map((_, index) => (
|
||||
<div key={index} className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='h-9 w-9 flex-shrink-0 rounded-[6px]' />
|
||||
<div className='flex flex-col justify-center gap-[1px]'>
|
||||
<Skeleton className='h-[14px] w-[100px]' />
|
||||
<Skeleton className='h-[13px] w-[200px]' />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className='h-[32px] w-[72px] rounded-[6px]' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface IntegrationsProps {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
||||
}
|
||||
|
||||
export function Integrations({ onOpenChange, registerCloseHandler }: IntegrationsProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pendingServiceRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: services = [], isPending } = useOAuthConnections()
|
||||
const connectService = useConnectOAuthService()
|
||||
const disconnectService = useDisconnectOAuthService()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [pendingService, setPendingService] = useState<string | null>(null)
|
||||
const [authSuccess, setAuthSuccess] = useState(false)
|
||||
const [showActionRequired, setShowActionRequired] = useState(false)
|
||||
const prevConnectedIdsRef = useRef<Set<string>>(new Set())
|
||||
const connectionAddedRef = useRef<boolean>(false)
|
||||
|
||||
// Disconnect confirmation dialog state
|
||||
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false)
|
||||
const [serviceToDisconnect, setServiceToDisconnect] = useState<{
|
||||
service: ServiceInfo
|
||||
accountId: string
|
||||
} | null>(null)
|
||||
|
||||
// Check for OAuth callback - just show success message
|
||||
useEffect(() => {
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
if (code && state) {
|
||||
logger.info('OAuth callback successful')
|
||||
setAuthSuccess(true)
|
||||
|
||||
// Clear URL parameters without changing the page
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete('code')
|
||||
url.searchParams.delete('state')
|
||||
router.replace(url.pathname + url.search)
|
||||
} else if (error) {
|
||||
logger.error('OAuth error:', { error })
|
||||
}
|
||||
}, [searchParams, router])
|
||||
|
||||
// Track when a new connection is added compared to previous render
|
||||
useEffect(() => {
|
||||
try {
|
||||
const currentConnected = new Set<string>()
|
||||
services.forEach((svc) => {
|
||||
if (svc.isConnected) currentConnected.add(svc.id)
|
||||
})
|
||||
// Detect new connections by comparing to previous connected set
|
||||
for (const id of currentConnected) {
|
||||
if (!prevConnectedIdsRef.current.has(id)) {
|
||||
connectionAddedRef.current = true
|
||||
break
|
||||
}
|
||||
}
|
||||
prevConnectedIdsRef.current = currentConnected
|
||||
} catch {}
|
||||
}, [services])
|
||||
|
||||
// On mount, register a close handler so the parent modal can delegate close events here
|
||||
useEffect(() => {
|
||||
if (!registerCloseHandler) return
|
||||
const handle = (open: boolean) => {
|
||||
if (open) return
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('oauth-integration-closed', {
|
||||
detail: { success: connectionAddedRef.current === true },
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch {}
|
||||
onOpenChange?.(open)
|
||||
}
|
||||
registerCloseHandler(handle)
|
||||
}, [registerCloseHandler, onOpenChange])
|
||||
|
||||
// Handle connect button click
|
||||
const handleConnect = async (service: ServiceInfo) => {
|
||||
try {
|
||||
logger.info('Connecting service:', {
|
||||
serviceId: service.id,
|
||||
providerId: service.providerId,
|
||||
scopes: service.scopes,
|
||||
})
|
||||
|
||||
// better-auth will automatically redirect back to this URL after OAuth
|
||||
await connectService.mutateAsync({
|
||||
providerId: service.providerId,
|
||||
callbackURL: window.location.href,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('OAuth connection error:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the disconnect confirmation dialog for a service.
|
||||
*/
|
||||
const handleDisconnect = (service: ServiceInfo, accountId: string) => {
|
||||
setServiceToDisconnect({ service, accountId })
|
||||
setShowDisconnectDialog(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms and executes the service disconnection.
|
||||
*/
|
||||
const confirmDisconnect = async () => {
|
||||
if (!serviceToDisconnect) return
|
||||
|
||||
setShowDisconnectDialog(false)
|
||||
const { service, accountId } = serviceToDisconnect
|
||||
setServiceToDisconnect(null)
|
||||
|
||||
try {
|
||||
await disconnectService.mutateAsync({
|
||||
provider: service.providerId.split('-')[0],
|
||||
providerId: service.providerId,
|
||||
serviceId: service.id,
|
||||
accountId,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error disconnecting service:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Group services by provider, filtering by permission config
|
||||
const groupedServices = services.reduce(
|
||||
(acc, service) => {
|
||||
// Filter based on allowedIntegrations
|
||||
if (
|
||||
permissionConfig.allowedIntegrations !== null &&
|
||||
!permissionConfig.allowedIntegrations.includes(service.id)
|
||||
) {
|
||||
return acc
|
||||
}
|
||||
|
||||
// Find the provider for this service
|
||||
const providerKey =
|
||||
Object.keys(OAUTH_PROVIDERS).find((key) =>
|
||||
Object.keys(OAUTH_PROVIDERS[key].services).includes(service.id)
|
||||
) || 'other'
|
||||
|
||||
if (!acc[providerKey]) {
|
||||
acc[providerKey] = []
|
||||
}
|
||||
|
||||
acc[providerKey].push(service)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ServiceInfo[]>
|
||||
)
|
||||
|
||||
// Filter services based on search term
|
||||
const filteredGroupedServices = Object.entries(groupedServices).reduce(
|
||||
(acc, [providerKey, providerServices]) => {
|
||||
const filteredServices = providerServices.filter(
|
||||
(service) =>
|
||||
service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
service.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
if (filteredServices.length > 0) {
|
||||
acc[providerKey] = filteredServices
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ServiceInfo[]>
|
||||
)
|
||||
|
||||
const scrollToHighlightedService = () => {
|
||||
if (pendingServiceRef.current) {
|
||||
pendingServiceRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <IntegrationsSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex w-full items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<Search className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||
<Input
|
||||
placeholder='Search services...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{authSuccess && (
|
||||
<div className='flex items-center gap-[12px] rounded-[8px] border border-green-200 bg-green-50 p-[12px]'>
|
||||
<Check className='h-4 w-4 flex-shrink-0 text-green-500' />
|
||||
<p className='font-medium text-[13px] text-green-800'>
|
||||
Account connected successfully!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingService && showActionRequired && (
|
||||
<div className='flex items-start gap-[12px] rounded-[8px] border border-[var(--border)] bg-[var(--bg)] p-[12px]'>
|
||||
<ExternalLink className='mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
<span className='font-medium text-[var(--text-primary)]'>Action Required:</span>{' '}
|
||||
Please connect your account to enable the requested features.
|
||||
</p>
|
||||
<Button variant='outline' onClick={scrollToHighlightedService}>
|
||||
<span>Go to service</span>
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-[16px]'>
|
||||
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
|
||||
<div key={providerKey} className='flex flex-col gap-[8px]'>
|
||||
<Label className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
|
||||
</Label>
|
||||
{providerServices.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
pendingService === service.id &&
|
||||
'-m-[8px] rounded-[8px] bg-[var(--bg)] p-[8px]'
|
||||
)}
|
||||
ref={pendingService === service.id ? pendingServiceRef : undefined}
|
||||
>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<div className='flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-[var(--surface-5)]'>
|
||||
{createElement(service.icon, { className: 'h-4 w-4' })}
|
||||
</div>
|
||||
<div className='flex flex-col justify-center gap-[1px]'>
|
||||
<span className='font-medium text-[14px]'>{service.name}</span>
|
||||
{service.accounts && service.accounts.length > 0 ? (
|
||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||
{service.accounts.map((a) => a.name).join(', ')}
|
||||
</p>
|
||||
) : (
|
||||
<p className='truncate text-[13px] text-[var(--text-muted)]'>
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{service.accounts && service.accounts.length > 0 ? (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleDisconnect(service, service.accounts![0].id)}
|
||||
disabled={disconnectService.isPending}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={() => handleConnect(service)}
|
||||
disabled={connectService.isPending}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No services found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={showDisconnectDialog} onOpenChange={setShowDisconnectDialog}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Disconnect Service</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
Are you sure you want to disconnect{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{serviceToDisconnect?.service.name}
|
||||
</span>
|
||||
?{' '}
|
||||
<span className='text-[var(--text-error)]'>
|
||||
This will revoke access and you will need to reconnect to use this service.
|
||||
</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => setShowDisconnectDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={confirmDisconnect}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
@@ -79,9 +79,7 @@ interface SettingsModalProps {
|
||||
type SettingsSection =
|
||||
| 'general'
|
||||
| 'credentials'
|
||||
| 'environment'
|
||||
| 'template-profile'
|
||||
| 'integrations'
|
||||
| 'credential-sets'
|
||||
| 'access-control'
|
||||
| 'apikeys'
|
||||
@@ -216,8 +214,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const environmentBeforeLeaveHandler = useRef<((onProceed: () => void) => void) | null>(null)
|
||||
const integrationsCloseHandler = useRef<((open: boolean) => void) | null>(null)
|
||||
|
||||
const userEmail = session?.user?.email
|
||||
const userId = session?.user?.id
|
||||
@@ -319,32 +315,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
if (!isBillingEnabled && (activeSection === 'subscription' || activeSection === 'team')) {
|
||||
return 'general'
|
||||
}
|
||||
if (activeSection === 'environment' || activeSection === 'integrations') {
|
||||
return 'credentials'
|
||||
}
|
||||
return activeSection
|
||||
}, [activeSection])
|
||||
|
||||
const registerEnvironmentBeforeLeaveHandler = useCallback(
|
||||
(handler: (onProceed: () => void) => void) => {
|
||||
environmentBeforeLeaveHandler.current = handler
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const registerIntegrationsCloseHandler = useCallback((handler: (open: boolean) => void) => {
|
||||
integrationsCloseHandler.current = handler
|
||||
}, [])
|
||||
|
||||
const handleSectionChange = useCallback(
|
||||
(sectionId: SettingsSection) => {
|
||||
if (sectionId === effectiveActiveSection) return
|
||||
|
||||
if (effectiveActiveSection === 'credentials' && environmentBeforeLeaveHandler.current) {
|
||||
environmentBeforeLeaveHandler.current(() => setActiveSection(sectionId))
|
||||
return
|
||||
}
|
||||
|
||||
setActiveSection(sectionId)
|
||||
},
|
||||
[effectiveActiveSection]
|
||||
@@ -368,11 +344,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||
if (event.detail.tab === 'environment' || event.detail.tab === 'integrations') {
|
||||
setActiveSection('credentials')
|
||||
} else {
|
||||
setActiveSection(event.detail.tab)
|
||||
}
|
||||
setActiveSection(event.detail.tab)
|
||||
onOpenChange(true)
|
||||
}
|
||||
|
||||
@@ -477,29 +449,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dialog close - delegate to environment component if it's active
|
||||
const handleDialogOpenChange = (newOpen: boolean) => {
|
||||
if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'credentials' &&
|
||||
environmentBeforeLeaveHandler.current
|
||||
) {
|
||||
environmentBeforeLeaveHandler.current(() => {
|
||||
if (integrationsCloseHandler.current) {
|
||||
integrationsCloseHandler.current(newOpen)
|
||||
} else {
|
||||
onOpenChange(false)
|
||||
}
|
||||
})
|
||||
} else if (
|
||||
!newOpen &&
|
||||
effectiveActiveSection === 'credentials' &&
|
||||
integrationsCloseHandler.current
|
||||
) {
|
||||
integrationsCloseHandler.current(newOpen)
|
||||
} else {
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -548,11 +499,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<SModalMainBody>
|
||||
{effectiveActiveSection === 'general' && <General onOpenChange={onOpenChange} />}
|
||||
{effectiveActiveSection === 'credentials' && (
|
||||
<Credentials
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={registerIntegrationsCloseHandler}
|
||||
registerBeforeLeaveHandler={registerEnvironmentBeforeLeaveHandler}
|
||||
/>
|
||||
<Credentials onOpenChange={onOpenChange} />
|
||||
)}
|
||||
{effectiveActiveSection === 'template-profile' && <TemplateProfile />}
|
||||
{effectiveActiveSection === 'credential-sets' && <CredentialSets />}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export type SettingsSection =
|
||||
| 'general'
|
||||
| 'credentials'
|
||||
| 'environment'
|
||||
| 'template-profile'
|
||||
| 'integrations'
|
||||
| 'apikeys'
|
||||
| 'files'
|
||||
| 'subscription'
|
||||
|
||||
Reference in New Issue
Block a user