mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
Compare commits
1 Commits
v0.5.16
...
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 { Subscription } from './subscription/subscription'
|
||||
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 {
|
||||
Bot,
|
||||
CreditCard,
|
||||
Download,
|
||||
FileCode,
|
||||
Files,
|
||||
Home,
|
||||
@@ -27,6 +28,7 @@ import { generalSettingsKeys } from '@/hooks/queries/general-settings'
|
||||
import { organizationKeys, useOrganizations } from '@/hooks/queries/organization'
|
||||
import { ssoKeys, useSSOProviders } from '@/hooks/queries/sso'
|
||||
import { subscriptionKeys, useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useSuperUserStatus } from '@/hooks/queries/super-user'
|
||||
|
||||
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))
|
||||
|
||||
@@ -48,6 +50,7 @@ interface SettingsNavigationProps {
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'workflow-import'
|
||||
) => void
|
||||
hasOrganization: boolean
|
||||
}
|
||||
@@ -68,6 +71,7 @@ type NavigationItem = {
|
||||
| 'privacy'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'workflow-import'
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
hideWhenBillingDisabled?: boolean
|
||||
@@ -75,6 +79,7 @@ type NavigationItem = {
|
||||
requiresEnterprise?: boolean
|
||||
requiresOwner?: boolean
|
||||
requiresHosted?: boolean
|
||||
requiresSuperUser?: boolean
|
||||
}
|
||||
|
||||
const allNavigationItems: NavigationItem[] = [
|
||||
@@ -155,6 +160,12 @@ const allNavigationItems: NavigationItem[] = [
|
||||
requiresEnterprise: true,
|
||||
requiresOwner: true,
|
||||
},
|
||||
{
|
||||
id: 'workflow-import',
|
||||
label: 'Workflow Import',
|
||||
icon: Download,
|
||||
requiresSuperUser: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function SettingsNavigation({
|
||||
@@ -176,6 +187,10 @@ export function SettingsNavigation({
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
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)
|
||||
// Only fetch if not hosted (hosted uses billing/org checks)
|
||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||
@@ -224,9 +239,13 @@ export function SettingsNavigation({
|
||||
return false
|
||||
}
|
||||
|
||||
if (item.requiresSuperUser && !isSuperUser) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [hasOrganization, hasEnterprisePlan, canManageSSO, isSSOProviderOwner, isOwner])
|
||||
}, [hasOrganization, hasEnterprisePlan, canManageSSO, isSSOProviderOwner, isOwner, isSuperUser])
|
||||
|
||||
// Prefetch functions for React Query
|
||||
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,
|
||||
Subscription,
|
||||
TeamManagement,
|
||||
WorkflowImport,
|
||||
} 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 { useGeneralSettings } from '@/hooks/queries/general-settings'
|
||||
@@ -49,6 +50,7 @@ type SettingsSection =
|
||||
| 'copilot'
|
||||
| 'mcp'
|
||||
| 'custom-tools'
|
||||
| 'workflow-import'
|
||||
|
||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||
@@ -212,6 +214,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<CustomTools />
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'workflow-import' && (
|
||||
<div className='h-full'>
|
||||
<WorkflowImport />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 canonicalParallels = generateParallelBlocks(blockRecords)
|
||||
|
||||
// Load existing webhooks BEFORE transaction to avoid aborting transaction on failure
|
||||
let existingWebhooks: any[] = []
|
||||
try {
|
||||
existingWebhooks = await db.select().from(webhook).where(eq(webhook.workflowId, workflowId))
|
||||
} catch (webhookError) {
|
||||
// Webhook table might not exist or query might fail - skip preservation
|
||||
logger.debug('Could not load webhooks before save, skipping preservation', {
|
||||
error: webhookError instanceof Error ? webhookError.message : String(webhookError),
|
||||
})
|
||||
}
|
||||
|
||||
// Start a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Snapshot existing webhooks before deletion to preserve them through the cycle
|
||||
let existingWebhooks: any[] = []
|
||||
try {
|
||||
existingWebhooks = await tx.select().from(webhook).where(eq(webhook.workflowId, workflowId))
|
||||
} catch (webhookError) {
|
||||
// Webhook table might not be available in test environments
|
||||
logger.debug('Could not load webhooks before save, skipping preservation', {
|
||||
error: webhookError instanceof Error ? webhookError.message : String(webhookError),
|
||||
})
|
||||
}
|
||||
|
||||
// Clear existing data for this workflow
|
||||
await Promise.all([
|
||||
tx.delete(workflowBlocks).where(eq(workflowBlocks.workflowId, workflowId)),
|
||||
|
||||
Reference in New Issue
Block a user