mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-12 15:34:58 -05:00
improvement(mcp): improved mcp sse events notifs, update jira to handle files, fix UI issues in settings modal, fix org and workspace invitations when bundled (#3182)
* improvement(mcp): improved mcp sse events notifs, update jira to handle files, fix UI issues in settings modal, fix org and workspace invitations when bundled * added back useMcpToolsEvents for event-driven discovery * ack PR comments * updated placeholder * updated colors, error throwing in mcp modal * ack comments * updated error msg
This commit is contained in:
@@ -6,6 +6,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
@@ -170,6 +171,11 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
workflowRecord.description ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
|
||||
const parameterSchema =
|
||||
body.parameterSchema && Object.keys(body.parameterSchema).length > 0
|
||||
? body.parameterSchema
|
||||
: await generateParameterSchemaForWorkflow(body.workflowId)
|
||||
|
||||
const toolId = crypto.randomUUID()
|
||||
const [tool] = await db
|
||||
.insert(workflowMcpTool)
|
||||
@@ -179,7 +185,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
workflowId: body.workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: body.parameterSchema || {},
|
||||
parameterSchema,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
@@ -156,6 +157,8 @@ export const POST = withMcpAuth('write')(
|
||||
const toolDescription =
|
||||
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
|
||||
|
||||
const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id)
|
||||
|
||||
const toolId = crypto.randomUUID()
|
||||
await db.insert(workflowMcpTool).values({
|
||||
id: toolId,
|
||||
@@ -163,7 +166,7 @@ export const POST = withMcpAuth('write')(
|
||||
workflowId: workflowRecord.id,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: {},
|
||||
parameterSchema,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -446,15 +446,46 @@ export async function PUT(
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: wsInvitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const existingPermission = await tx
|
||||
.select({ id: permissions.id, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, wsInvitation.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingPermission) {
|
||||
const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const
|
||||
type PermissionLevel = keyof typeof PERMISSION_RANK
|
||||
const existingRank =
|
||||
PERMISSION_RANK[existingPermission.permissionType as PermissionLevel] ?? 0
|
||||
const newPermission = (wsInvitation.permissions || 'read') as PermissionLevel
|
||||
const newRank = PERMISSION_RANK[newPermission] ?? 0
|
||||
|
||||
if (newRank > existingRank) {
|
||||
await tx
|
||||
.update(permissions)
|
||||
.set({
|
||||
permissionType: newPermission,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(permissions.id, existingPermission.id))
|
||||
}
|
||||
} else {
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: wsInvitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
await tx
|
||||
|
||||
@@ -47,16 +47,9 @@ export async function POST(request: NextRequest) {
|
||||
(await getJiraCloudId(validatedData.domain, validatedData.accessToken))
|
||||
|
||||
const formData = new FormData()
|
||||
const filesOutput: Array<{ name: string; mimeType: string; data: string; size: number }> = []
|
||||
|
||||
for (const file of userFiles) {
|
||||
const buffer = await downloadFileFromStorage(file, requestId, logger)
|
||||
filesOutput.push({
|
||||
name: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
})
|
||||
const blob = new Blob([new Uint8Array(buffer)], {
|
||||
type: file.type || 'application/octet-stream',
|
||||
})
|
||||
@@ -109,7 +102,7 @@ export async function POST(request: NextRequest) {
|
||||
issueKey: validatedData.issueKey,
|
||||
attachments,
|
||||
attachmentIds,
|
||||
files: filesOutput,
|
||||
files: userFiles,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -45,6 +45,12 @@ interface McpDeployProps {
|
||||
onCanSaveChange?: (canSave: boolean) => void
|
||||
}
|
||||
|
||||
function haveSameServerSelection(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const bSet = new Set(b)
|
||||
return a.every((id) => bSet.has(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON Schema from input format with optional descriptions
|
||||
*/
|
||||
@@ -143,6 +149,7 @@ export function McpDeploy({
|
||||
})
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
const [pendingServerChanges, setPendingServerChanges] = useState<Set<string>>(new Set())
|
||||
const [saveErrors, setSaveErrors] = useState<string[]>([])
|
||||
|
||||
const parameterSchema = useMemo(
|
||||
() => generateParameterSchema(inputFormat, parameterDescriptions),
|
||||
@@ -179,6 +186,7 @@ export function McpDeploy({
|
||||
}
|
||||
return ids
|
||||
}, [servers, serverToolsMap])
|
||||
const [draftSelectedServerIds, setDraftSelectedServerIds] = useState<string[] | null>(null)
|
||||
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
|
||||
@@ -238,9 +246,10 @@ export function McpDeploy({
|
||||
}
|
||||
}, [toolName, toolDescription, parameterDescriptions, savedValues])
|
||||
|
||||
const hasDeployedTools = selectedServerIds.length > 0
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!savedValues || !hasDeployedTools) return false
|
||||
const selectedServerIdsForForm = draftSelectedServerIds ?? selectedServerIds
|
||||
|
||||
const hasToolConfigurationChanges = useMemo(() => {
|
||||
if (!savedValues) return false
|
||||
if (toolName !== savedValues.toolName) return true
|
||||
if (toolDescription !== savedValues.toolDescription) return true
|
||||
if (
|
||||
@@ -249,11 +258,18 @@ export function McpDeploy({
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [toolName, toolDescription, parameterDescriptions, hasDeployedTools, savedValues])
|
||||
}, [toolName, toolDescription, parameterDescriptions, savedValues])
|
||||
const hasServerSelectionChanges = useMemo(
|
||||
() => !haveSameServerSelection(selectedServerIdsForForm, selectedServerIds),
|
||||
[selectedServerIdsForForm, selectedServerIds]
|
||||
)
|
||||
const hasChanges =
|
||||
hasServerSelectionChanges ||
|
||||
(hasToolConfigurationChanges && selectedServerIdsForForm.length > 0)
|
||||
|
||||
useEffect(() => {
|
||||
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
|
||||
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
|
||||
onCanSaveChange?.(hasChanges && !!toolName.trim())
|
||||
}, [hasChanges, toolName, onCanSaveChange])
|
||||
|
||||
/**
|
||||
* Save tool configuration to all deployed servers
|
||||
@@ -261,74 +277,25 @@ export function McpDeploy({
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!toolName.trim()) return
|
||||
|
||||
const toolsToUpdate: Array<{ serverId: string; toolId: string }> = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
toolsToUpdate.push({ serverId: server.id, toolId: toolInfo.tool.id })
|
||||
}
|
||||
}
|
||||
const currentIds = new Set(selectedServerIds)
|
||||
const nextIds = new Set(selectedServerIdsForForm)
|
||||
const toAdd = new Set(selectedServerIdsForForm.filter((id) => !currentIds.has(id)))
|
||||
const toRemove = selectedServerIds.filter((id) => !nextIds.has(id))
|
||||
const shouldUpdateExisting = hasToolConfigurationChanges
|
||||
|
||||
if (toolsToUpdate.length === 0) return
|
||||
if (toAdd.size === 0 && toRemove.length === 0 && !shouldUpdateExisting) return
|
||||
|
||||
onSubmittingChange?.(true)
|
||||
setSaveErrors([])
|
||||
try {
|
||||
for (const { serverId, toolId } of toolsToUpdate) {
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
}
|
||||
// Update saved values after successful save (triggers re-render → hasChanges becomes false)
|
||||
setSavedValues({
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions: { ...parameterDescriptions },
|
||||
})
|
||||
onCanSaveChange?.(false)
|
||||
onSubmittingChange?.(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save tool configuration:', error)
|
||||
onSubmittingChange?.(false)
|
||||
}
|
||||
}, [
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions,
|
||||
parameterSchema,
|
||||
servers,
|
||||
serverToolsMap,
|
||||
workspaceId,
|
||||
updateToolMutation,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
])
|
||||
|
||||
const serverOptions: ComboboxOption[] = useMemo(() => {
|
||||
return servers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
}))
|
||||
}, [servers])
|
||||
|
||||
const handleServerSelectionChange = useCallback(
|
||||
async (newSelectedIds: string[]) => {
|
||||
if (!toolName.trim()) return
|
||||
|
||||
const currentIds = new Set(selectedServerIds)
|
||||
const newIds = new Set(newSelectedIds)
|
||||
|
||||
const toAdd = newSelectedIds.filter((id) => !currentIds.has(id))
|
||||
const toRemove = selectedServerIds.filter((id) => !newIds.has(id))
|
||||
const errors: string[] = []
|
||||
const addedEntries: Record<string, { tool: WorkflowMcpTool; isLoading: boolean }> = {}
|
||||
const removedIds: string[] = []
|
||||
|
||||
for (const serverId of toAdd) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
const addedTool = await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
workflowId,
|
||||
@@ -336,10 +303,13 @@ export function McpDeploy({
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
addedEntries[serverId] = { tool: addedTool, isLoading: false }
|
||||
onAddedToServer?.()
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add tool:', error)
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to add to ${serverName}`)
|
||||
logger.error(`Failed to add tool to server ${serverId}:`, error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -351,54 +321,115 @@ export function McpDeploy({
|
||||
|
||||
for (const serverId of toRemove) {
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (toolInfo?.tool) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
if (!toolInfo?.tool) continue
|
||||
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
})
|
||||
removedIds.push(serverId)
|
||||
} catch (error) {
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to remove from ${serverName}`)
|
||||
logger.error(`Failed to remove tool from server ${serverId}:`, error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdateExisting) {
|
||||
for (const serverId of selectedServerIdsForForm) {
|
||||
if (toAdd.has(serverId)) continue
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (!toolInfo?.tool) continue
|
||||
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
})
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[serverId]
|
||||
return next
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove tool:', error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to update on ${serverName}`)
|
||||
logger.error(`Failed to update tool on server ${serverId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedServerIds,
|
||||
serverToolsMap,
|
||||
toolName,
|
||||
toolDescription,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
parameterSchema,
|
||||
addToolMutation,
|
||||
deleteToolMutation,
|
||||
onAddedToServer,
|
||||
]
|
||||
)
|
||||
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev, ...addedEntries }
|
||||
for (const id of removedIds) {
|
||||
delete next[id]
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (errors.length > 0) {
|
||||
setSaveErrors(errors)
|
||||
} else {
|
||||
setDraftSelectedServerIds(null)
|
||||
setSavedValues({
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions: { ...parameterDescriptions },
|
||||
})
|
||||
onCanSaveChange?.(false)
|
||||
}
|
||||
onSubmittingChange?.(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save tool configuration:', error)
|
||||
onSubmittingChange?.(false)
|
||||
}
|
||||
}, [
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions,
|
||||
parameterSchema,
|
||||
selectedServerIds,
|
||||
selectedServerIdsForForm,
|
||||
hasToolConfigurationChanges,
|
||||
serverToolsMap,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
servers,
|
||||
addToolMutation,
|
||||
deleteToolMutation,
|
||||
updateToolMutation,
|
||||
onAddedToServer,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
])
|
||||
|
||||
const serverOptions: ComboboxOption[] = useMemo(() => {
|
||||
return servers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
}))
|
||||
}, [servers])
|
||||
|
||||
const handleServerSelectionChange = useCallback((newSelectedIds: string[]) => {
|
||||
setDraftSelectedServerIds(newSelectedIds)
|
||||
}, [])
|
||||
|
||||
const selectedServersLabel = useMemo(() => {
|
||||
const count = selectedServerIds.length
|
||||
const count = selectedServerIdsForForm.length
|
||||
if (count === 0) return 'Select servers...'
|
||||
if (count === 1) {
|
||||
const server = servers.find((s) => s.id === selectedServerIds[0])
|
||||
const server = servers.find((s) => s.id === selectedServerIdsForForm[0])
|
||||
return server?.name || '1 server'
|
||||
}
|
||||
return `${count} servers selected`
|
||||
}, [selectedServerIds, servers])
|
||||
}, [selectedServerIdsForForm, servers])
|
||||
|
||||
const isPending = pendingServerChanges.size > 0
|
||||
|
||||
@@ -544,7 +575,7 @@ export function McpDeploy({
|
||||
<Combobox
|
||||
options={serverOptions}
|
||||
multiSelect
|
||||
multiSelectValues={selectedServerIds}
|
||||
multiSelectValues={selectedServerIdsForForm}
|
||||
onMultiSelectChange={handleServerSelectionChange}
|
||||
placeholder='Select servers...'
|
||||
searchable
|
||||
@@ -561,10 +592,14 @@ export function McpDeploy({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addToolMutation.isError && (
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
|
||||
{addToolMutation.error?.message || 'Failed to add tool'}
|
||||
</p>
|
||||
{saveErrors.length > 0 && (
|
||||
<div className='mt-[6.5px] flex flex-col gap-[2px]'>
|
||||
{saveErrors.map((error) => (
|
||||
<p key={error} className='text-[12px] text-[var(--text-error)]'>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ToolCredentialSelector({
|
||||
disabled = false,
|
||||
}: ToolCredentialSelectorProps) {
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
@@ -100,11 +100,7 @@ export function ToolCredentialSelector({
|
||||
return ''
|
||||
}, [selectedCredential, isForeign])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(resolvedLabel)
|
||||
}
|
||||
}, [resolvedLabel, isEditing])
|
||||
const inputValue = isEditing ? editingInputValue : resolvedLabel
|
||||
|
||||
const invalidSelection =
|
||||
Boolean(selectedId) &&
|
||||
@@ -189,13 +185,12 @@ export function ToolCredentialSelector({
|
||||
|
||||
const matchedCred = credentials.find((c) => c.id === newValue)
|
||||
if (matchedCred) {
|
||||
setInputValue(matchedCred.name)
|
||||
handleSelect(newValue)
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
setInputValue(newValue)
|
||||
setEditingInputValue(newValue)
|
||||
},
|
||||
[credentials, handleAddCredential, handleSelect]
|
||||
)
|
||||
|
||||
@@ -2642,7 +2642,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
</div>
|
||||
|
||||
{!isCustomTool && isExpandedForDisplay && (
|
||||
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
|
||||
{/* Operation dropdown for tools with multiple operations */}
|
||||
{(() => {
|
||||
const hasOperations = hasMultipleOperations(tool.type)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
|
||||
|
||||
@@ -103,12 +102,7 @@ export function CustomTools() {
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'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)]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
@@ -118,7 +112,7 @@ export function CustomTools() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
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={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
useMcpToolsQuery,
|
||||
useRefreshMcpServer,
|
||||
useStoredMcpTools,
|
||||
useUpdateMcpServer,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -96,6 +97,8 @@ interface McpServer {
|
||||
name?: string
|
||||
transport?: string
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
enabled?: boolean
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string | null
|
||||
lastConnected?: string
|
||||
@@ -378,6 +381,13 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
const refreshServerMutation = useRefreshMcpServer()
|
||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||
const updateServerMutation = useUpdateMcpServer()
|
||||
const {
|
||||
testResult: editTestResult,
|
||||
isTestingConnection: isEditTestingConnection,
|
||||
testConnection: editTestConnection,
|
||||
clearTestResult: clearEditTestResult,
|
||||
} = useMcpServerTest()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -407,6 +417,19 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editFormData, setEditFormData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
|
||||
const [editOriginalData, setEditOriginalData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
|
||||
const [isUpdatingServer, setIsUpdatingServer] = useState(false)
|
||||
const [editSaveError, setEditSaveError] = useState<string | null>(null)
|
||||
const [editShowEnvVars, setEditShowEnvVars] = useState(false)
|
||||
const [editEnvSearchTerm, setEditEnvSearchTerm] = useState('')
|
||||
const [editCursorPosition, setEditCursorPosition] = useState(0)
|
||||
const [editActiveInputField, setEditActiveInputField] = useState<InputFieldType | null>(null)
|
||||
const [editActiveHeaderIndex, setEditActiveHeaderIndex] = useState<number | null>(null)
|
||||
const [editUrlScrollLeft, setEditUrlScrollLeft] = useState(0)
|
||||
const [editHeaderScrollLeft, setEditHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
|
||||
setSelectedServerId(initialServerId)
|
||||
@@ -757,6 +780,215 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
[refreshServerMutation, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Resets edit modal environment variable dropdown state.
|
||||
*/
|
||||
const resetEditEnvVarState = useCallback(() => {
|
||||
setEditShowEnvVars(false)
|
||||
setEditActiveInputField(null)
|
||||
setEditActiveHeaderIndex(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Opens the edit modal and populates form with current server data.
|
||||
*/
|
||||
const handleOpenEditModal = useCallback(
|
||||
(server: McpServer) => {
|
||||
const headers: HeaderEntry[] = server.headers
|
||||
? Object.entries(server.headers).map(([key, value]) => ({ key, value }))
|
||||
: [{ key: '', value: '' }]
|
||||
if (headers.length === 0) headers.push({ key: '', value: '' })
|
||||
|
||||
const data: McpServerFormData = {
|
||||
name: server.name || '',
|
||||
transport: (server.transport as McpTransport) || 'streamable-http',
|
||||
url: server.url || '',
|
||||
timeout: 30000,
|
||||
headers,
|
||||
}
|
||||
setEditFormData(data)
|
||||
setEditOriginalData(JSON.parse(JSON.stringify(data)))
|
||||
setShowEditModal(true)
|
||||
setEditSaveError(null)
|
||||
clearEditTestResult()
|
||||
resetEditEnvVarState()
|
||||
setEditUrlScrollLeft(0)
|
||||
setEditHeaderScrollLeft({})
|
||||
},
|
||||
[clearEditTestResult, resetEditEnvVarState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Closes the edit modal and resets state.
|
||||
*/
|
||||
const handleCloseEditModal = useCallback(() => {
|
||||
setShowEditModal(false)
|
||||
setEditFormData(DEFAULT_FORM_DATA)
|
||||
setEditOriginalData(DEFAULT_FORM_DATA)
|
||||
setEditSaveError(null)
|
||||
clearEditTestResult()
|
||||
resetEditEnvVarState()
|
||||
}, [clearEditTestResult, resetEditEnvVarState])
|
||||
|
||||
/**
|
||||
* Handles environment variable selection in the edit modal.
|
||||
*/
|
||||
const handleEditEnvVarSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (editActiveInputField === 'url') {
|
||||
setEditFormData((prev) => ({ ...prev, url: newValue }))
|
||||
} else if (editActiveHeaderIndex !== null) {
|
||||
const field = editActiveInputField === 'header-key' ? 'key' : 'value'
|
||||
const processedValue = field === 'key' ? newValue.replace(/[{}]/g, '') : newValue
|
||||
setEditFormData((prev) => {
|
||||
const newHeaders = [...(prev.headers || [])]
|
||||
if (newHeaders[editActiveHeaderIndex]) {
|
||||
newHeaders[editActiveHeaderIndex] = {
|
||||
...newHeaders[editActiveHeaderIndex],
|
||||
[field]: processedValue,
|
||||
}
|
||||
}
|
||||
return { ...prev, headers: newHeaders }
|
||||
})
|
||||
}
|
||||
resetEditEnvVarState()
|
||||
},
|
||||
[editActiveInputField, editActiveHeaderIndex, resetEditEnvVarState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles input changes in the edit modal and manages env var dropdown.
|
||||
*/
|
||||
const handleEditInputChange = useCallback(
|
||||
(field: InputFieldType, value: string, headerIndex?: number) => {
|
||||
const input = document.activeElement as HTMLInputElement
|
||||
const pos = input?.selectionStart || 0
|
||||
setEditCursorPosition(pos)
|
||||
|
||||
if (editTestResult) {
|
||||
clearEditTestResult()
|
||||
}
|
||||
|
||||
const envVarTrigger = checkEnvVarTrigger(value, pos)
|
||||
setEditShowEnvVars(envVarTrigger.show)
|
||||
setEditEnvSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
|
||||
if (envVarTrigger.show) {
|
||||
setEditActiveInputField(field)
|
||||
setEditActiveHeaderIndex(headerIndex ?? null)
|
||||
} else {
|
||||
resetEditEnvVarState()
|
||||
}
|
||||
|
||||
if (field === 'url') {
|
||||
setEditFormData((prev) => ({ ...prev, url: value }))
|
||||
} else if (headerIndex !== undefined) {
|
||||
const headerField = field === 'header-key' ? 'key' : 'value'
|
||||
setEditFormData((prev) => {
|
||||
const newHeaders = [...(prev.headers || [])]
|
||||
if (newHeaders[headerIndex]) {
|
||||
newHeaders[headerIndex] = { ...newHeaders[headerIndex], [headerField]: value }
|
||||
}
|
||||
return { ...prev, headers: newHeaders }
|
||||
})
|
||||
}
|
||||
},
|
||||
[editTestResult, clearEditTestResult, resetEditEnvVarState]
|
||||
)
|
||||
|
||||
const handleEditHeaderScroll = useCallback((key: string, scrollLeft: number) => {
|
||||
setEditHeaderScrollLeft((prev) => ({ ...prev, [key]: scrollLeft }))
|
||||
}, [])
|
||||
|
||||
const handleEditAddHeader = useCallback(() => {
|
||||
setEditFormData((prev) => ({
|
||||
...prev,
|
||||
headers: [...(prev.headers || []), { key: '', value: '' }],
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleEditRemoveHeader = useCallback((index: number) => {
|
||||
setEditFormData((prev) => ({
|
||||
...prev,
|
||||
headers: (prev.headers || []).filter((_, i) => i !== index),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Tests the connection with the edit modal's form data.
|
||||
*/
|
||||
const handleEditTestConnection = useCallback(async () => {
|
||||
if (!editFormData.name.trim() || !editFormData.url?.trim()) return
|
||||
|
||||
await editTestConnection({
|
||||
name: editFormData.name,
|
||||
transport: editFormData.transport,
|
||||
url: editFormData.url,
|
||||
headers: headersToRecord(editFormData.headers),
|
||||
timeout: editFormData.timeout,
|
||||
workspaceId,
|
||||
})
|
||||
}, [editFormData, editTestConnection, workspaceId, headersToRecord])
|
||||
|
||||
/**
|
||||
* Saves the edited MCP server after validating and testing the connection.
|
||||
*/
|
||||
const handleSaveEdit = useCallback(async () => {
|
||||
if (!selectedServerId || !editFormData.name.trim()) return
|
||||
|
||||
setEditSaveError(null)
|
||||
try {
|
||||
const headersRecord = headersToRecord(editFormData.headers)
|
||||
const serverConfig = {
|
||||
name: editFormData.name,
|
||||
transport: editFormData.transport,
|
||||
url: editFormData.url,
|
||||
headers: headersRecord,
|
||||
timeout: editFormData.timeout,
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const connectionResult = await editTestConnection(serverConfig)
|
||||
|
||||
if (!connectionResult.success) {
|
||||
setEditSaveError(connectionResult.error || 'Connection test failed')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUpdatingServer(true)
|
||||
const currentServer = servers.find((s) => s.id === selectedServerId)
|
||||
await updateServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: selectedServerId,
|
||||
updates: {
|
||||
name: editFormData.name.trim(),
|
||||
transport: editFormData.transport,
|
||||
url: editFormData.url,
|
||||
headers: headersRecord,
|
||||
timeout: editFormData.timeout || 30000,
|
||||
enabled: currentServer?.enabled ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
setShowEditModal(false)
|
||||
logger.info(`Updated MCP server: ${editFormData.name}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update server'
|
||||
setEditSaveError(message)
|
||||
logger.error('Failed to update MCP server:', error)
|
||||
} finally {
|
||||
setIsUpdatingServer(false)
|
||||
}
|
||||
}, [
|
||||
selectedServerId,
|
||||
editFormData,
|
||||
editTestConnection,
|
||||
updateServerMutation,
|
||||
workspaceId,
|
||||
headersToRecord,
|
||||
servers,
|
||||
])
|
||||
|
||||
/**
|
||||
* Gets the selected server and its tools for the detail view.
|
||||
*/
|
||||
@@ -777,6 +1009,26 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
|
||||
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
||||
|
||||
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
|
||||
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
|
||||
const hasEditChanges = useMemo(() => {
|
||||
if (editFormData.name !== editOriginalData.name) return true
|
||||
if (editFormData.url !== editOriginalData.url) return true
|
||||
if (editFormData.transport !== editOriginalData.transport) return true
|
||||
|
||||
const currentHeaders = editFormData.headers || []
|
||||
const originalHeaders = editOriginalData.headers || []
|
||||
if (currentHeaders.length !== originalHeaders.length) return true
|
||||
for (let i = 0; i < currentHeaders.length; i++) {
|
||||
if (
|
||||
currentHeaders[i].key !== originalHeaders[i].key ||
|
||||
currentHeaders[i].value !== originalHeaders[i].value
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [editFormData, editOriginalData])
|
||||
|
||||
/**
|
||||
* Gets issues for stored tools that reference a specific server tool.
|
||||
* Returns issues from all workflows that have stored this tool.
|
||||
@@ -905,7 +1157,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<Badge
|
||||
variant={getIssueBadgeVariant(issues[0].issue)}
|
||||
size='sm'
|
||||
className='cursor-help'
|
||||
>
|
||||
{getIssueBadgeLabel(issues[0].issue)}
|
||||
</Badge>
|
||||
@@ -991,23 +1242,135 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
</div>
|
||||
|
||||
<div className='mt-auto flex items-center justify-between'>
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={!!refreshingServers[server.id]}
|
||||
>
|
||||
{refreshingServers[server.id]?.status === 'refreshing'
|
||||
? 'Refreshing...'
|
||||
: refreshingServers[server.id]?.status === 'refreshed'
|
||||
? refreshingServers[server.id].workflowsUpdated
|
||||
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
|
||||
: 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={!!refreshingServers[server.id]}
|
||||
>
|
||||
{refreshingServers[server.id]?.status === 'refreshing'
|
||||
? 'Refreshing...'
|
||||
: refreshingServers[server.id]?.status === 'refreshed'
|
||||
? refreshingServers[server.id].workflowsUpdated
|
||||
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
|
||||
: 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
<Button onClick={() => handleOpenEditModal(server)} variant='default'>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleBackToList} variant='tertiary'>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={editFormData.name}
|
||||
onChange={(e) => {
|
||||
if (editTestResult) clearEditTestResult()
|
||||
setEditFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Server URL'>
|
||||
<FormattedInput
|
||||
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
|
||||
value={editFormData.url || ''}
|
||||
scrollLeft={editUrlScrollLeft}
|
||||
showEnvVars={editShowEnvVars && editActiveInputField === 'url'}
|
||||
envVarProps={{
|
||||
searchTerm: editEnvSearchTerm,
|
||||
cursorPosition: editCursorPosition,
|
||||
workspaceId,
|
||||
onSelect: handleEditEnvVarSelect,
|
||||
onClose: resetEditEnvVarState,
|
||||
}}
|
||||
availableEnvVars={availableEnvVars}
|
||||
onChange={(e) => handleEditInputChange('url', e.target.value)}
|
||||
onScroll={setEditUrlScrollLeft}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Headers
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={handleEditAddHeader}
|
||||
className='h-6 w-6 p-0'
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex max-h-[140px] flex-col gap-[8px] overflow-y-auto'>
|
||||
{(editFormData.headers || []).map((header, index) => (
|
||||
<HeaderRow
|
||||
key={index}
|
||||
header={header}
|
||||
index={index}
|
||||
headerScrollLeft={editHeaderScrollLeft}
|
||||
showEnvVars={editShowEnvVars}
|
||||
activeInputField={editActiveInputField}
|
||||
activeHeaderIndex={editActiveHeaderIndex}
|
||||
envSearchTerm={editEnvSearchTerm}
|
||||
cursorPosition={editCursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
onInputChange={handleEditInputChange}
|
||||
onHeaderScroll={handleEditHeaderScroll}
|
||||
onEnvVarSelect={handleEditEnvVarSelect}
|
||||
onEnvVarClose={resetEditEnvVarState}
|
||||
onRemove={() => handleEditRemoveHeader(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{editSaveError && (
|
||||
<p className='mb-[8px] w-full text-[12px] text-[var(--text-error)]'>
|
||||
{editSaveError}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleEditTestConnection}
|
||||
disabled={isEditTestingConnection || !isEditFormValid}
|
||||
>
|
||||
{editTestButtonLabel}
|
||||
</Button>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button variant='ghost' onClick={handleCloseEditModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
|
||||
variant='tertiary'
|
||||
>
|
||||
{isUpdatingServer ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -52,21 +52,17 @@ export function SkillModal({
|
||||
const [content, setContent] = useState('')
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [prevOpen, setPrevOpen] = useState(false)
|
||||
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialValues) {
|
||||
setName(initialValues.name)
|
||||
setDescription(initialValues.description)
|
||||
setContent(initialValues.content)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setContent('')
|
||||
}
|
||||
setErrors({})
|
||||
}
|
||||
}, [open, initialValues])
|
||||
if ((open && !prevOpen) || (open && initialValues !== prevInitialValues)) {
|
||||
setName(initialValues?.name ?? '')
|
||||
setDescription(initialValues?.description ?? '')
|
||||
setContent(initialValues?.content ?? '')
|
||||
setErrors({})
|
||||
}
|
||||
if (open !== prevOpen) setPrevOpen(open)
|
||||
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!initialValues) return true
|
||||
|
||||
@@ -4,17 +4,8 @@ import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
|
||||
@@ -105,12 +96,7 @@ export function Skills() {
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'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)]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<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}
|
||||
@@ -120,7 +106,7 @@ export function Skills() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
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={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Checkbox,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
@@ -64,8 +65,8 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
|
||||
PermissionSelector.displayName = 'PermissionSelector'
|
||||
|
||||
interface MemberInvitationCardProps {
|
||||
inviteEmail: string
|
||||
setInviteEmail: (email: string) => void
|
||||
inviteEmails: TagItem[]
|
||||
setInviteEmails: (emails: TagItem[]) => void
|
||||
isInviting: boolean
|
||||
showWorkspaceInvite: boolean
|
||||
setShowWorkspaceInvite: (show: boolean) => void
|
||||
@@ -82,8 +83,8 @@ interface MemberInvitationCardProps {
|
||||
}
|
||||
|
||||
export function MemberInvitationCard({
|
||||
inviteEmail,
|
||||
setInviteEmail,
|
||||
inviteEmails,
|
||||
setInviteEmails,
|
||||
isInviting,
|
||||
showWorkspaceInvite,
|
||||
setShowWorkspaceInvite,
|
||||
@@ -100,45 +101,26 @@ export function MemberInvitationCard({
|
||||
}: MemberInvitationCardProps) {
|
||||
const selectedCount = selectedWorkspaces.length
|
||||
const hasAvailableSeats = availableSeats > 0
|
||||
const [emailError, setEmailError] = useState<string>('')
|
||||
const hasValidEmails = inviteEmails.some((e) => e.isValid)
|
||||
|
||||
const validateEmailInput = (email: string) => {
|
||||
if (!email.trim()) {
|
||||
setEmailError('')
|
||||
return
|
||||
}
|
||||
const handleAddEmail = (value: string) => {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
|
||||
const validation = quickValidateEmail(email.trim())
|
||||
if (!validation.isValid) {
|
||||
setEmailError(validation.reason || 'Please enter a valid email address')
|
||||
} else {
|
||||
setEmailError('')
|
||||
}
|
||||
const isDuplicate = inviteEmails.some((e) => e.value === normalized)
|
||||
if (isDuplicate) return false
|
||||
|
||||
const validation = quickValidateEmail(normalized)
|
||||
setInviteEmails([...inviteEmails, { value: normalized, isValid: validation.isValid }])
|
||||
return validation.isValid
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInviteEmail(value)
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleInviteClick = () => {
|
||||
if (inviteEmail.trim()) {
|
||||
validateEmailInput(inviteEmail)
|
||||
const validation = quickValidateEmail(inviteEmail.trim())
|
||||
if (!validation.isValid) {
|
||||
return // Don't proceed if validation fails
|
||||
}
|
||||
}
|
||||
|
||||
onInviteMember()
|
||||
const handleRemoveEmail = (_value: string, index: number) => {
|
||||
setInviteEmails(inviteEmails.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
|
||||
{/* Header */}
|
||||
<div className='px-[14px] py-[10px]'>
|
||||
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Invite Team Members</h4>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
@@ -147,46 +129,18 @@ export function MemberInvitationCard({
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[12px] border-[var(--border-1)] border-t bg-[var(--surface-4)] px-[14px] py-[12px]'>
|
||||
{/* Main invitation input */}
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<div className='flex-1'>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
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
|
||||
/>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={handleEmailChange}
|
||||
<TagInput
|
||||
items={inviteEmails}
|
||||
onAdd={handleAddEmail}
|
||||
onRemove={handleRemoveEmail}
|
||||
placeholder='Enter email addresses'
|
||||
placeholderWithTags='Add another email'
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
name='member_invite_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
triggerKeys={['Enter', ',', ' ']}
|
||||
maxHeight='max-h-24'
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-1 text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Popover
|
||||
open={showWorkspaceInvite}
|
||||
@@ -220,8 +174,9 @@ export function MemberInvitationCard({
|
||||
align='end'
|
||||
maxHeight={320}
|
||||
sideOffset={4}
|
||||
className='w-[240px] border border-[var(--border-muted)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
style={{ minWidth: '240px', maxWidth: '240px' }}
|
||||
minWidth={240}
|
||||
maxWidth={240}
|
||||
border
|
||||
>
|
||||
{isLoadingWorkspaces ? (
|
||||
<div className='px-[6px] py-[16px] text-center'>
|
||||
@@ -286,14 +241,13 @@ export function MemberInvitationCard({
|
||||
</Popover>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleInviteClick}
|
||||
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
|
||||
onClick={() => onInviteMember()}
|
||||
disabled={!hasValidEmails || isInviting || !hasAvailableSeats}
|
||||
>
|
||||
{isInviting ? 'Inviting...' : hasAvailableSeats ? 'Invite' : 'No Seats'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Invitation error - inline */}
|
||||
{invitationError && (
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{invitationError instanceof Error && invitationError.message
|
||||
@@ -302,7 +256,6 @@ export function MemberInvitationCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{inviteSuccess && (
|
||||
<p className='text-[11px] text-[var(--text-success)] leading-tight'>
|
||||
Invitation sent successfully
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { TagItem } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
@@ -69,7 +70,7 @@ export function TeamManagement() {
|
||||
|
||||
const [inviteSuccess, setInviteSuccess] = useState(false)
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteEmails, setInviteEmails] = useState<TagItem[]>([])
|
||||
const [showWorkspaceInvite, setShowWorkspaceInvite] = useState(false)
|
||||
const [selectedWorkspaces, setSelectedWorkspaces] = useState<
|
||||
Array<{ workspaceId: string; permission: string }>
|
||||
@@ -129,7 +130,8 @@ export function TeamManagement() {
|
||||
}, [orgName, orgSlug, createOrgMutation])
|
||||
|
||||
const handleInviteMember = useCallback(async () => {
|
||||
if (!session?.user || !activeOrganization?.id || !inviteEmail.trim()) return
|
||||
const validEmails = inviteEmails.filter((e) => e.isValid).map((e) => e.value)
|
||||
if (!session?.user || !activeOrganization?.id || validEmails.length === 0) return
|
||||
|
||||
try {
|
||||
const workspaceInvitations =
|
||||
@@ -141,23 +143,21 @@ export function TeamManagement() {
|
||||
: undefined
|
||||
|
||||
await inviteMutation.mutateAsync({
|
||||
email: inviteEmail.trim(),
|
||||
emails: validEmails,
|
||||
orgId: activeOrganization.id,
|
||||
workspaceInvitations,
|
||||
})
|
||||
|
||||
// Show success state
|
||||
setInviteSuccess(true)
|
||||
setTimeout(() => setInviteSuccess(false), 3000)
|
||||
|
||||
// Reset form
|
||||
setInviteEmail('')
|
||||
setInviteEmails([])
|
||||
setSelectedWorkspaces([])
|
||||
setShowWorkspaceInvite(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to invite member', error)
|
||||
}
|
||||
}, [session?.user?.id, activeOrganization?.id, inviteEmail, selectedWorkspaces, inviteMutation])
|
||||
}, [session?.user?.id, activeOrganization?.id, inviteEmails, selectedWorkspaces, inviteMutation])
|
||||
|
||||
const handleWorkspaceToggle = useCallback((workspaceId: string, permission: string) => {
|
||||
setSelectedWorkspaces((prev) => {
|
||||
@@ -391,15 +391,15 @@ export function TeamManagement() {
|
||||
{adminOrOwner && !isInvitationsDisabled && (
|
||||
<div>
|
||||
<MemberInvitationCard
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
inviteEmails={inviteEmails}
|
||||
setInviteEmails={setInviteEmails}
|
||||
isInviting={inviteMutation.isPending}
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
userWorkspaces={adminWorkspaces}
|
||||
onInviteMember={handleInviteMember}
|
||||
onLoadUserWorkspaces={async () => {}} // No-op: data is auto-loaded by React Query
|
||||
onLoadUserWorkspaces={async () => {}}
|
||||
onWorkspaceToggle={handleWorkspaceToggle}
|
||||
inviteSuccess={inviteSuccess}
|
||||
availableSeats={Math.max(0, totalSeats - usedSeats.used)}
|
||||
|
||||
Reference in New Issue
Block a user