Superuser debug

This commit is contained in:
Siddharth Ganesan
2026-01-19 15:42:55 -08:00
parent 5f45db4343
commit e1bea05de0
5 changed files with 409 additions and 2 deletions

View File

@@ -0,0 +1,192 @@
import { db } from '@sim/db'
import { copilotChats, user, workflow, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import {
loadWorkflowFromNormalizedTables,
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
const logger = createLogger('SuperUserImportWorkflow')
interface ImportWorkflowRequest {
workflowId: string
targetWorkspaceId: string
}
/**
* POST /api/superuser/import-workflow
*
* Superuser endpoint to import a workflow by ID along with its copilot chats.
* This creates a copy of the workflow in the target workspace with new IDs.
* Only the workflow structure and copilot chats are copied - no deployments,
* webhooks, triggers, or other sensitive data.
*/
export async function POST(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Verify the user is a superuser
const [currentUser] = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (!currentUser?.isSuperUser) {
logger.warn('Non-superuser attempted to access import-workflow endpoint', {
userId: session.user.id,
})
return NextResponse.json({ error: 'Forbidden: Superuser access required' }, { status: 403 })
}
const body: ImportWorkflowRequest = await request.json()
const { workflowId, targetWorkspaceId } = body
if (!workflowId) {
return NextResponse.json({ error: 'workflowId is required' }, { status: 400 })
}
if (!targetWorkspaceId) {
return NextResponse.json({ error: 'targetWorkspaceId is required' }, { status: 400 })
}
// Verify target workspace exists
const [targetWorkspace] = await db
.select({ id: workspace.id, ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, targetWorkspaceId))
.limit(1)
if (!targetWorkspace) {
return NextResponse.json({ error: 'Target workspace not found' }, { status: 404 })
}
// Get the source workflow
const [sourceWorkflow] = await db
.select()
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
if (!sourceWorkflow) {
return NextResponse.json({ error: 'Source workflow not found' }, { status: 404 })
}
// Load the workflow state from normalized tables
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalizedData) {
return NextResponse.json(
{ error: 'Workflow has no normalized data - cannot import' },
{ status: 400 }
)
}
// Use existing export logic to create export format
const workflowState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
metadata: {
name: sourceWorkflow.name,
description: sourceWorkflow.description ?? undefined,
color: sourceWorkflow.color,
},
}
const exportData = sanitizeForExport(workflowState)
// Use existing import logic (parseWorkflowJson regenerates IDs automatically)
const { data: importedData, errors } = parseWorkflowJson(JSON.stringify(exportData))
if (!importedData || errors.length > 0) {
return NextResponse.json(
{ error: `Failed to parse workflow: ${errors.join(', ')}` },
{ status: 400 }
)
}
// Create new workflow record
const newWorkflowId = crypto.randomUUID()
const now = new Date()
await db.insert(workflow).values({
id: newWorkflowId,
userId: session.user.id,
workspaceId: targetWorkspaceId,
folderId: null, // Don't copy folder association
name: `[Debug Import] ${sourceWorkflow.name}`,
description: sourceWorkflow.description,
color: sourceWorkflow.color,
lastSynced: now,
createdAt: now,
updatedAt: now,
isDeployed: false, // Never copy deployment status
runCount: 0,
variables: sourceWorkflow.variables || {},
})
// Save using existing persistence logic
const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, importedData)
if (!saveResult.success) {
// Clean up the workflow record if save failed
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
return NextResponse.json(
{ error: `Failed to save workflow state: ${saveResult.error}` },
{ status: 500 }
)
}
// Copy copilot chats associated with the source workflow
const sourceCopilotChats = await db
.select()
.from(copilotChats)
.where(eq(copilotChats.workflowId, workflowId))
let copilotChatsImported = 0
for (const chat of sourceCopilotChats) {
await db.insert(copilotChats).values({
userId: session.user.id,
workflowId: newWorkflowId,
title: chat.title ? `[Import] ${chat.title}` : null,
messages: chat.messages,
model: chat.model,
conversationId: null, // Don't copy conversation ID
previewYaml: chat.previewYaml,
planArtifact: chat.planArtifact,
config: chat.config,
createdAt: new Date(),
updatedAt: new Date(),
})
copilotChatsImported++
}
logger.info('Superuser imported workflow', {
userId: session.user.id,
sourceWorkflowId: workflowId,
newWorkflowId,
targetWorkspaceId,
copilotChatsImported,
})
return NextResponse.json({
success: true,
newWorkflowId,
copilotChatsImported,
})
} catch (error) {
logger.error('Error importing workflow', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1477,7 +1477,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
toolCall.name === 'mark_todo_in_progress' ||
toolCall.name === 'tool_search_tool_regex' ||
toolCall.name === 'user_memory' ||
toolCall.name === 'edit_responsd' ||
toolCall.name === 'edit_respond' ||
toolCall.name === 'debug_respond' ||
toolCall.name === 'plan_respond'
)

View File

@@ -0,0 +1,177 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Download, ExternalLink, Loader2 } from 'lucide-react'
import { createLogger } from '@sim/logger'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { workflowKeys } from '@/hooks/queries/workflows'
const logger = createLogger('DebugSettings')
interface ImportResult {
success: boolean
newWorkflowId?: string
copilotChatsImported?: number
error?: string
}
/**
* Debug settings component for superusers.
* Allows importing workflows by ID for debugging purposes.
*/
export function Debug() {
const params = useParams()
const router = useRouter()
const queryClient = useQueryClient()
const workspaceId = params?.workspaceId as string
const [workflowId, setWorkflowId] = useState('')
const [isImporting, setIsImporting] = useState(false)
const [result, setResult] = useState<ImportResult | null>(null)
const handleImport = async () => {
if (!workflowId.trim()) return
setIsImporting(true)
setResult(null)
try {
const response = await fetch('/api/superuser/import-workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflowId: workflowId.trim(),
targetWorkspaceId: workspaceId,
}),
})
const data = await response.json()
if (!response.ok) {
setResult({ success: false, error: data.error || 'Failed to import workflow' })
return
}
// Invalidate workflow list cache to show the new workflow immediately
await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) })
setResult({
success: true,
newWorkflowId: data.newWorkflowId,
copilotChatsImported: data.copilotChatsImported,
})
setWorkflowId('')
logger.info('Workflow imported successfully', {
originalWorkflowId: workflowId.trim(),
newWorkflowId: data.newWorkflowId,
copilotChatsImported: data.copilotChatsImported,
})
} catch (error) {
logger.error('Failed to import workflow', error)
setResult({ success: false, error: 'An unexpected error occurred' })
} finally {
setIsImporting(false)
}
}
const handleNavigateToWorkflow = () => {
if (result?.newWorkflowId) {
router.push(`/workspace/${workspaceId}/w/${result.newWorkflowId}`)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isImporting && workflowId.trim()) {
handleImport()
}
}
return (
<div className="flex flex-col gap-6 p-1">
<div className="flex items-center gap-2 rounded-lg border border-amber-500/20 bg-amber-500/10 p-4">
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-amber-500" />
<p className="text-sm text-amber-200">
This is a superuser debug feature. Use with caution. Imported workflows and copilot chats
will be copied to your current workspace.
</p>
</div>
<div className="flex flex-col gap-4">
<div>
<h3 className="mb-1 text-base font-medium text-white">Import Workflow by ID</h3>
<p className="text-sm text-muted-foreground">
Enter a workflow ID to import it along with its associated copilot chats into your
current workspace. Only the workflow structure and copilot conversations will be copied
- no deployments, webhooks, or triggers.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="workflow-id">Workflow ID</Label>
<div className="flex gap-2">
<Input
id="workflow-id"
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter workflow ID (e.g., abc123-def456-...)"
disabled={isImporting}
className="flex-1"
/>
<Button onClick={handleImport} disabled={isImporting || !workflowId.trim()}>
{isImporting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Importing...
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Import
</>
)}
</Button>
</div>
</div>
{result && (
<div
className={`rounded-lg border p-4 ${
result.success
? 'border-green-500/20 bg-green-500/10'
: 'border-red-500/20 bg-red-500/10'
}`}
>
{result.success ? (
<div className="flex flex-col gap-2">
<p className="font-medium text-green-400">Workflow imported successfully!</p>
<p className="text-sm text-green-300">
New workflow ID: <code className="font-mono">{result.newWorkflowId}</code>
</p>
<p className="text-sm text-green-300">
Copilot chats imported: {result.copilotChatsImported}
</p>
<Button
variant="outline"
size="sm"
onClick={handleNavigateToWorkflow}
className="mt-2 w-fit"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open Workflow
</Button>
</div>
) : (
<p className="text-red-400">{result.error}</p>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ export { BYOK } from './byok/byok'
export { Copilot } from './copilot/copilot'
export { CredentialSets } from './credential-sets/credential-sets'
export { CustomTools } from './custom-tools/custom-tools'
export { Debug } from './debug/debug'
export { EnvironmentVariables } from './environment/environment'
export { Files as FileUploads } from './files/files'
export { General } from './general/general'

View File

@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { useQueryClient } from '@tanstack/react-query'
import {
Bug,
Files,
KeySquare,
LogIn,
@@ -46,6 +47,7 @@ import {
Copilot,
CredentialSets,
CustomTools,
Debug,
EnvironmentVariables,
FileUploads,
General,
@@ -91,8 +93,9 @@ type SettingsSection =
| 'mcp'
| 'custom-tools'
| 'workflow-mcp-servers'
| 'debug'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise'
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise' | 'superuser'
type NavigationItem = {
id: SettingsSection
@@ -104,6 +107,7 @@ type NavigationItem = {
requiresEnterprise?: boolean
requiresHosted?: boolean
selfHostedOverride?: boolean
requiresSuperUser?: boolean
}
const sectionConfig: { key: NavigationSection; title: string }[] = [
@@ -112,6 +116,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
{ key: 'subscription', title: 'Subscription' },
{ key: 'system', title: 'System' },
{ key: 'enterprise', title: 'Enterprise' },
{ key: 'superuser', title: 'Superuser' },
]
const allNavigationItems: NavigationItem[] = [
@@ -180,12 +185,20 @@ const allNavigationItems: NavigationItem[] = [
requiresEnterprise: true,
selfHostedOverride: isSSOEnabled,
},
{
id: 'debug',
label: 'Debug',
icon: Bug,
section: 'superuser',
requiresSuperUser: true,
},
]
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
const [isSuperUser, setIsSuperUser] = useState(false)
const { data: session } = useSession()
const queryClient = useQueryClient()
const { data: organizationsData } = useOrganizations()
@@ -209,6 +222,23 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const hasEnterprisePlan = subscriptionStatus.isEnterprise
const hasOrganization = !!activeOrganization?.id
// Fetch superuser status
useEffect(() => {
const fetchSuperUserStatus = async () => {
if (!userId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser)
}
} catch {
setIsSuperUser(false)
}
}
fetchSuperUserStatus()
}, [userId])
// Memoize SSO provider ownership check
const isSSOProviderOwner = useMemo(() => {
if (isHosted) return null
@@ -268,6 +298,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return false
}
// requiresSuperUser: only show if user is a superuser
if (item.requiresSuperUser && !isSuperUser) {
return false
}
return true
})
}, [
@@ -280,6 +315,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
isOwner,
isAdmin,
permissionConfig,
isSuperUser,
])
// Memoized callbacks to prevent infinite loops in child components
@@ -523,6 +559,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
{activeSection === 'custom-tools' && <CustomTools />}
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
{activeSection === 'debug' && <Debug />}
</SModalMainBody>
</SModalMain>
</SModalContent>