mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-26 07:18:38 -05:00
Compare commits
1 Commits
python-sdk
...
feat/workf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
198a9ca69a |
159
apps/sim/app/api/admin/import-workflow/route.ts
Normal file
159
apps/sim/app/api/admin/import-workflow/route.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { user, workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import { generateRequestId } from '@/lib/utils'
|
||||||
|
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||||
|
import { sanitizeForExport } from '@/lib/workflows/json-sanitizer'
|
||||||
|
|
||||||
|
const logger = createLogger('AdminImportWorkflowAPI')
|
||||||
|
|
||||||
|
const ImportWorkflowSchema = z.object({
|
||||||
|
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||||
|
targetWorkspaceId: z.string().min(1, 'Target workspace ID is required'),
|
||||||
|
deploymentVersion: z.number().int().positive().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/import-workflow
|
||||||
|
* Export a workflow from database by ID (superuser only)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
logger.warn(`[${requestId}] Unauthorized import attempt`)
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is superuser
|
||||||
|
const currentUser = await db
|
||||||
|
.select({ isSuperUser: user.isSuperUser })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!currentUser[0]?.isSuperUser) {
|
||||||
|
logger.warn(`[${requestId}] Non-superuser attempted workflow import: ${session.user.id}`)
|
||||||
|
return NextResponse.json({ error: 'Forbidden - Superuser access required' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const validation = ImportWorkflowSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!validation.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid request', details: validation.error.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workflowId, targetWorkspaceId, deploymentVersion } = validation.data
|
||||||
|
|
||||||
|
// Fetch workflow metadata
|
||||||
|
const [workflowData] = await db
|
||||||
|
.select()
|
||||||
|
.from(workflow)
|
||||||
|
.where(eq(workflow.id, workflowId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!workflowData) {
|
||||||
|
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflowState: any
|
||||||
|
let sourceLabel = 'current state'
|
||||||
|
|
||||||
|
if (deploymentVersion !== undefined) {
|
||||||
|
// Load from deployment version
|
||||||
|
const [deployedVersion] = await db
|
||||||
|
.select({ state: workflowDeploymentVersion.state })
|
||||||
|
.from(workflowDeploymentVersion)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workflowDeploymentVersion.workflowId, workflowId),
|
||||||
|
eq(workflowDeploymentVersion.version, deploymentVersion)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!deployedVersion?.state) {
|
||||||
|
return NextResponse.json({ error: `Deployment version ${deploymentVersion} not found` }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployedState = deployedVersion.state as any
|
||||||
|
workflowState = {
|
||||||
|
blocks: deployedState.blocks || {},
|
||||||
|
edges: Array.isArray(deployedState.edges) ? deployedState.edges : [],
|
||||||
|
loops: deployedState.loops || {},
|
||||||
|
parallels: deployedState.parallels || {},
|
||||||
|
metadata: {
|
||||||
|
name: workflowData.name,
|
||||||
|
description: workflowData.description ?? undefined,
|
||||||
|
color: workflowData.color ?? undefined,
|
||||||
|
},
|
||||||
|
variables: Array.isArray(deployedState.variables) ? deployedState.variables : [],
|
||||||
|
}
|
||||||
|
sourceLabel = `deployment v${deploymentVersion}`
|
||||||
|
} else {
|
||||||
|
// Load current state from normalized tables
|
||||||
|
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||||
|
|
||||||
|
if (!normalizedData) {
|
||||||
|
return NextResponse.json({ error: 'Workflow has no data' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let workflowVariables: any[] = []
|
||||||
|
if (workflowData.variables && typeof workflowData.variables === 'object') {
|
||||||
|
workflowVariables = Object.values(workflowData.variables).map((v: any) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
type: v.type,
|
||||||
|
value: v.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowState = {
|
||||||
|
blocks: normalizedData.blocks || {},
|
||||||
|
edges: Array.isArray(normalizedData.edges) ? normalizedData.edges : [],
|
||||||
|
loops: normalizedData.loops || {},
|
||||||
|
parallels: normalizedData.parallels || {},
|
||||||
|
metadata: {
|
||||||
|
name: workflowData.name,
|
||||||
|
description: workflowData.description ?? undefined,
|
||||||
|
color: workflowData.color ?? undefined,
|
||||||
|
},
|
||||||
|
variables: workflowVariables,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportState = sanitizeForExport(workflowState)
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Exported workflow ${workflowId} (${sourceLabel})`)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
workflow: exportState,
|
||||||
|
metadata: {
|
||||||
|
originalId: workflowId,
|
||||||
|
originalName: workflowData.name,
|
||||||
|
originalDescription: workflowData.description,
|
||||||
|
targetWorkspaceId,
|
||||||
|
deploymentVersion: deploymentVersion ?? null,
|
||||||
|
source: sourceLabel,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Error importing workflow:`, error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to import workflow' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
65
apps/sim/app/api/admin/workflow-deployments/route.ts
Normal file
65
apps/sim/app/api/admin/workflow-deployments/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { db } from '@sim/db'
|
||||||
|
import { user, workflowDeploymentVersion } from '@sim/db/schema'
|
||||||
|
import { desc, eq } from 'drizzle-orm'
|
||||||
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getSession } from '@/lib/auth'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import { generateRequestId } from '@/lib/utils'
|
||||||
|
|
||||||
|
const logger = createLogger('AdminWorkflowDeploymentsAPI')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/workflow-deployments?workflowId=xxx
|
||||||
|
* List all deployment versions for a workflow (superuser only)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is superuser
|
||||||
|
const currentUser = await db
|
||||||
|
.select({ isSuperUser: user.isSuperUser })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!currentUser[0]?.isSuperUser) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden - Superuser access required' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const workflowId = searchParams.get('workflowId')
|
||||||
|
|
||||||
|
if (!workflowId) {
|
||||||
|
return NextResponse.json({ error: 'workflowId query parameter is required' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions = await db
|
||||||
|
.select({
|
||||||
|
id: workflowDeploymentVersion.id,
|
||||||
|
version: workflowDeploymentVersion.version,
|
||||||
|
name: workflowDeploymentVersion.name,
|
||||||
|
isActive: workflowDeploymentVersion.isActive,
|
||||||
|
createdAt: workflowDeploymentVersion.createdAt,
|
||||||
|
createdBy: workflowDeploymentVersion.createdBy,
|
||||||
|
deployedBy: user.name,
|
||||||
|
})
|
||||||
|
.from(workflowDeploymentVersion)
|
||||||
|
.leftJoin(user, eq(workflowDeploymentVersion.createdBy, user.id))
|
||||||
|
.where(eq(workflowDeploymentVersion.workflowId, workflowId))
|
||||||
|
.orderBy(desc(workflowDeploymentVersion.version))
|
||||||
|
|
||||||
|
logger.info(`[${requestId}] Retrieved ${versions.length} deployments for workflow ${workflowId}`)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, versions })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[${requestId}] Error listing deployments:`, error)
|
||||||
|
return NextResponse.json({ error: 'Failed to list deployments' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,3 +12,4 @@ export { SettingsNavigation } from './settings-navigation/settings-navigation'
|
|||||||
export { SSO } from './sso/sso'
|
export { SSO } from './sso/sso'
|
||||||
export { Subscription } from './subscription/subscription'
|
export { Subscription } from './subscription/subscription'
|
||||||
export { TeamManagement } from './team-management/team-management'
|
export { TeamManagement } from './team-management/team-management'
|
||||||
|
export { WorkflowImport } from './workflow-import/workflow-import'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
|||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
Download,
|
||||||
FileCode,
|
FileCode,
|
||||||
Files,
|
Files,
|
||||||
Home,
|
Home,
|
||||||
@@ -27,6 +28,7 @@ import { generalSettingsKeys } from '@/hooks/queries/general-settings'
|
|||||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||||
|
import { useSuperUserStatus } from '@/hooks/queries/super-user'
|
||||||
|
|
||||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ interface SettingsNavigationProps {
|
|||||||
| 'copilot'
|
| 'copilot'
|
||||||
| 'mcp'
|
| 'mcp'
|
||||||
| 'custom-tools'
|
| 'custom-tools'
|
||||||
|
| 'workflow-import'
|
||||||
) => void
|
) => void
|
||||||
hasOrganization: boolean
|
hasOrganization: boolean
|
||||||
}
|
}
|
||||||
@@ -68,6 +71,7 @@ type NavigationItem = {
|
|||||||
| 'privacy'
|
| 'privacy'
|
||||||
| 'mcp'
|
| 'mcp'
|
||||||
| 'custom-tools'
|
| 'custom-tools'
|
||||||
|
| 'workflow-import'
|
||||||
label: string
|
label: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
hideWhenBillingDisabled?: boolean
|
hideWhenBillingDisabled?: boolean
|
||||||
@@ -75,6 +79,7 @@ type NavigationItem = {
|
|||||||
requiresEnterprise?: boolean
|
requiresEnterprise?: boolean
|
||||||
requiresOwner?: boolean
|
requiresOwner?: boolean
|
||||||
requiresHosted?: boolean
|
requiresHosted?: boolean
|
||||||
|
requiresSuperUser?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const allNavigationItems: NavigationItem[] = [
|
const allNavigationItems: NavigationItem[] = [
|
||||||
@@ -155,6 +160,12 @@ const allNavigationItems: NavigationItem[] = [
|
|||||||
requiresEnterprise: true,
|
requiresEnterprise: true,
|
||||||
requiresOwner: true,
|
requiresOwner: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'workflow-import',
|
||||||
|
label: 'Workflow Import',
|
||||||
|
icon: Download,
|
||||||
|
requiresSuperUser: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SettingsNavigation({
|
export function SettingsNavigation({
|
||||||
@@ -176,6 +187,10 @@ export function SettingsNavigation({
|
|||||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||||
|
|
||||||
|
// Check superuser status
|
||||||
|
const { data: superUserData } = useSuperUserStatus()
|
||||||
|
const isSuperUser = superUserData?.isSuperUser ?? false
|
||||||
|
|
||||||
// Use React Query to check SSO provider ownership (with proper caching)
|
// Use React Query to check SSO provider ownership (with proper caching)
|
||||||
// Only fetch if not hosted (hosted uses billing/org checks)
|
// Only fetch if not hosted (hosted uses billing/org checks)
|
||||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||||
@@ -224,9 +239,13 @@ export function SettingsNavigation({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.requiresSuperUser && !isSuperUser) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [hasOrganization, hasEnterprisePlan, canManageSSO, isSSOProviderOwner, isOwner])
|
}, [hasOrganization, hasEnterprisePlan, canManageSSO, isSSOProviderOwner, isOwner, isSuperUser])
|
||||||
|
|
||||||
// Prefetch functions for React Query
|
// Prefetch functions for React Query
|
||||||
const prefetchGeneral = () => {
|
const prefetchGeneral = () => {
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { Download, Loader2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import { parseWorkflowJson } from '@/stores/workflows/json/importer'
|
||||||
|
import { useCreateWorkflow, workflowKeys } from '@/hooks/queries/workflows'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||||
|
import { useSuperUserStatus } from '@/hooks/queries/super-user'
|
||||||
|
|
||||||
|
const logger = createLogger('WorkflowImport')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WorkflowImport Settings Component
|
||||||
|
* Allows superusers to import workflows by ID from the database
|
||||||
|
*/
|
||||||
|
export function WorkflowImport() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const createWorkflowMutation = useCreateWorkflow()
|
||||||
|
const { data: superUserData, isLoading: loadingSuperUser } = useSuperUserStatus()
|
||||||
|
|
||||||
|
const workspaceId = params?.workspaceId as string
|
||||||
|
const isSuperUser = superUserData?.isSuperUser ?? false
|
||||||
|
|
||||||
|
const [workflowId, setWorkflowId] = useState('')
|
||||||
|
const [useDeployment, setUseDeployment] = useState(false)
|
||||||
|
const [deployments, setDeployments] = useState<any[]>([])
|
||||||
|
const [selectedDeployment, setSelectedDeployment] = useState<string>('')
|
||||||
|
const [loadingDeployments, setLoadingDeployments] = useState(false)
|
||||||
|
const [isImporting, setIsImporting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Fetch deployments when workflow ID changes and useDeployment is enabled
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDeployments = async () => {
|
||||||
|
if (!useDeployment || !workflowId.trim()) {
|
||||||
|
setDeployments([])
|
||||||
|
setSelectedDeployment('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingDeployments(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/workflow-deployments?workflowId=${workflowId.trim()}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to fetch deployments')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
setDeployments(data.versions || [])
|
||||||
|
|
||||||
|
if (data.versions && data.versions.length > 0) {
|
||||||
|
setSelectedDeployment(String(data.versions[0].version))
|
||||||
|
} else {
|
||||||
|
setSelectedDeployment('')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch deployments:', err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch deployments')
|
||||||
|
setDeployments([])
|
||||||
|
setSelectedDeployment('')
|
||||||
|
} finally {
|
||||||
|
setLoadingDeployments(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDeployments()
|
||||||
|
}, [useDeployment, workflowId])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle workflow import
|
||||||
|
*/
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!workflowId.trim()) {
|
||||||
|
setError('Please enter a workflow ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsImporting(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build request
|
||||||
|
const requestBody: any = {
|
||||||
|
workflowId: workflowId.trim(),
|
||||||
|
targetWorkspaceId: workspaceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useDeployment && selectedDeployment) {
|
||||||
|
requestBody.deploymentVersion = Number.parseInt(selectedDeployment, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch workflow data from admin API
|
||||||
|
const response = await fetch('/api/admin/import-workflow', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to fetch workflow')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { workflow: exportState, metadata } = await response.json()
|
||||||
|
|
||||||
|
// Parse the workflow JSON (regenerate IDs)
|
||||||
|
const { data: workflowData, errors: parseErrors } = parseWorkflowJson(
|
||||||
|
JSON.stringify(exportState),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!workflowData || parseErrors.length > 0) {
|
||||||
|
throw new Error(`Failed to parse workflow: ${parseErrors.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear diff state
|
||||||
|
useWorkflowDiffStore.getState().clearDiff()
|
||||||
|
|
||||||
|
// Create new workflow
|
||||||
|
const importSuffix = metadata.deploymentVersion
|
||||||
|
? ` (Imported v${metadata.deploymentVersion})`
|
||||||
|
: ' (Imported)'
|
||||||
|
|
||||||
|
const workflowColor = exportState.state?.metadata?.color || '#3972F6'
|
||||||
|
|
||||||
|
const result = await createWorkflowMutation.mutateAsync({
|
||||||
|
name: `${metadata.originalName}${importSuffix}`,
|
||||||
|
description: metadata.originalDescription || `Imported from ${metadata.source}`,
|
||||||
|
workspaceId,
|
||||||
|
color: workflowColor,
|
||||||
|
})
|
||||||
|
const newWorkflowId = result.id
|
||||||
|
|
||||||
|
// Save workflow state
|
||||||
|
const stateResponse = await fetch(`/api/workflows/${newWorkflowId}/state`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(workflowData),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stateResponse.ok) {
|
||||||
|
// Clean up on failure
|
||||||
|
await fetch(`/api/workflows/${newWorkflowId}`, { method: 'DELETE' })
|
||||||
|
throw new Error('Failed to save workflow state')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save variables if present
|
||||||
|
if (workflowData.variables && Array.isArray(workflowData.variables) && workflowData.variables.length > 0) {
|
||||||
|
const variablesPayload = workflowData.variables.map((v: any) => ({
|
||||||
|
id: v.id,
|
||||||
|
workflowId: newWorkflowId,
|
||||||
|
name: v.name,
|
||||||
|
type: v.type,
|
||||||
|
value: v.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await fetch(`/api/workflows/${newWorkflowId}/variables`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ variables: variablesPayload }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh workflow list
|
||||||
|
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
|
||||||
|
|
||||||
|
const successMsg = metadata.deploymentVersion
|
||||||
|
? `Imported "${metadata.originalName}" v${metadata.deploymentVersion}`
|
||||||
|
: `Imported "${metadata.originalName}"`
|
||||||
|
|
||||||
|
setSuccess(successMsg)
|
||||||
|
setWorkflowId('')
|
||||||
|
|
||||||
|
// Navigate to new workflow
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/workspace/${workspaceId}/w/${newWorkflowId}`)
|
||||||
|
}, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to import workflow:', err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Import failed')
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingSuperUser) {
|
||||||
|
return (
|
||||||
|
<div className='flex h-full items-center justify-center'>
|
||||||
|
<Loader2 className='h-6 w-6 animate-spin text-[var(--text-40)]' />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuperUser) {
|
||||||
|
return (
|
||||||
|
<div className='flex h-full items-center justify-center'>
|
||||||
|
<p className='text-[var(--text-40)]'>This feature is only available to superusers.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='h-full overflow-y-auto p-6'>
|
||||||
|
<div className='mx-auto max-w-2xl space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h2 className='text-lg font-semibold text-[var(--text-90)]'>Import Workflow</h2>
|
||||||
|
<p className='mt-1 text-sm text-[var(--text-40)]'>
|
||||||
|
Import a workflow from the database by ID into this workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='workflow-id'>Workflow ID</Label>
|
||||||
|
<Input
|
||||||
|
id='workflow-id'
|
||||||
|
placeholder='Enter workflow ID'
|
||||||
|
value={workflowId}
|
||||||
|
onChange={(e) => setWorkflowId(e.target.value)}
|
||||||
|
disabled={isImporting}
|
||||||
|
className='font-mono'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between rounded-md border border-[var(--surface-11)] p-3'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='use-deployment'>Load from deployment</Label>
|
||||||
|
<p className='text-xs text-[var(--text-40)]'>Import a deployed version</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id='use-deployment'
|
||||||
|
checked={useDeployment}
|
||||||
|
onCheckedChange={setUseDeployment}
|
||||||
|
disabled={isImporting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useDeployment && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Deployment Version</Label>
|
||||||
|
{loadingDeployments ? (
|
||||||
|
<div className='flex items-center gap-2 rounded-md border border-[var(--surface-11)] p-3 text-sm text-[var(--text-40)]'>
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin' />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : deployments.length === 0 ? (
|
||||||
|
<div className='rounded-md border border-[var(--surface-11)] p-3 text-sm text-[var(--text-40)]'>
|
||||||
|
{workflowId.trim() ? 'No deployments found' : 'Enter a workflow ID'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select value={selectedDeployment} onValueChange={setSelectedDeployment} disabled={isImporting}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select version' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{deployments.map((d) => (
|
||||||
|
<SelectItem key={d.version} value={String(d.version)}>
|
||||||
|
v{d.version}{d.name ? ` - ${d.name}` : ''}{d.isActive ? ' (Active)' : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className='rounded-md bg-red-500/10 p-3 text-sm text-red-500'>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className='rounded-md bg-green-500/10 p-3 text-sm text-green-500'>{success}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={isImporting || !workflowId.trim() || (useDeployment && !selectedDeployment)}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
{isImporting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
|
Importing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className='mr-2 h-4 w-4' />
|
||||||
|
Import Workflow
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='rounded-md border border-yellow-500/20 bg-yellow-500/5 p-4'>
|
||||||
|
<p className='text-xs text-yellow-600 dark:text-yellow-500'>
|
||||||
|
⚠️ Superuser Only - Use responsibly
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
SSO,
|
SSO,
|
||||||
Subscription,
|
Subscription,
|
||||||
TeamManagement,
|
TeamManagement,
|
||||||
|
WorkflowImport,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components'
|
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components'
|
||||||
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile'
|
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile'
|
||||||
import { useGeneralSettings } from '@/hooks/queries/general-settings'
|
import { useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||||
@@ -49,6 +50,7 @@ type SettingsSection =
|
|||||||
| 'copilot'
|
| 'copilot'
|
||||||
| 'mcp'
|
| 'mcp'
|
||||||
| 'custom-tools'
|
| 'custom-tools'
|
||||||
|
| 'workflow-import'
|
||||||
|
|
||||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||||
@@ -212,6 +214,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
<CustomTools />
|
<CustomTools />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeSection === 'workflow-import' && (
|
||||||
|
<div className='h-full'>
|
||||||
|
<WorkflowImport />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|||||||
24
apps/sim/hooks/queries/super-user.ts
Normal file
24
apps/sim/hooks/queries/super-user.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const superUserKeys = {
|
||||||
|
status: () => ['superUserStatus'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch the current user's superuser status
|
||||||
|
*/
|
||||||
|
export function useSuperUserStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: superUserKeys.status(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch('/api/user/super-user')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch super user status')
|
||||||
|
}
|
||||||
|
return response.json() as Promise<{ isSuperUser: boolean }>
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -326,19 +326,19 @@ export async function saveWorkflowToNormalizedTables(
|
|||||||
const canonicalLoops = generateLoopBlocks(blockRecords)
|
const canonicalLoops = generateLoopBlocks(blockRecords)
|
||||||
const canonicalParallels = generateParallelBlocks(blockRecords)
|
const canonicalParallels = generateParallelBlocks(blockRecords)
|
||||||
|
|
||||||
// Start a transaction
|
// Load existing webhooks BEFORE transaction to avoid aborting transaction on failure
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
// Snapshot existing webhooks before deletion to preserve them through the cycle
|
|
||||||
let existingWebhooks: any[] = []
|
let existingWebhooks: any[] = []
|
||||||
try {
|
try {
|
||||||
existingWebhooks = await tx.select().from(webhook).where(eq(webhook.workflowId, workflowId))
|
existingWebhooks = await db.select().from(webhook).where(eq(webhook.workflowId, workflowId))
|
||||||
} catch (webhookError) {
|
} catch (webhookError) {
|
||||||
// Webhook table might not be available in test environments
|
// Webhook table might not exist or query might fail - skip preservation
|
||||||
logger.debug('Could not load webhooks before save, skipping preservation', {
|
logger.debug('Could not load webhooks before save, skipping preservation', {
|
||||||
error: webhookError instanceof Error ? webhookError.message : String(webhookError),
|
error: webhookError instanceof Error ? webhookError.message : String(webhookError),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
// Clear existing data for this workflow
|
// Clear existing data for this workflow
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)),
|
tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)),
|
||||||
|
|||||||
Reference in New Issue
Block a user