diff --git a/apps/sim/app/api/creators/[id]/verify/route.ts b/apps/sim/app/api/creators/[id]/verify/route.ts index 45cd2dc0b0..6ce9e8b3c1 100644 --- a/apps/sim/app/api/creators/[id]/verify/route.ts +++ b/apps/sim/app/api/creators/[id]/verify/route.ts @@ -1,10 +1,11 @@ import { db } from '@sim/db' -import { templateCreators, user } from '@sim/db/schema' +import { templateCreators } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' const logger = createLogger('CreatorVerificationAPI') @@ -23,9 +24,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ } // Check if user is a super user - const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) - - if (!currentUser[0]?.isSuperUser) { + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) { logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`) return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 }) } @@ -76,9 +76,8 @@ export async function DELETE( } // Check if user is a super user - const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) - - if (!currentUser[0]?.isSuperUser) { + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) { logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`) return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 }) } diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts new file mode 100644 index 0000000000..3998792993 --- /dev/null +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -0,0 +1,193 @@ +import { db } from '@sim/db' +import { copilotChats, workflow, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' +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. + * + * Requires both isSuperUser flag AND superUserModeEnabled setting. + */ +export async function POST(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { effectiveSuperUser, isSuperUser, superUserModeEnabled } = + await verifyEffectiveSuperUser(session.user.id) + + if (!effectiveSuperUser) { + logger.warn('Non-effective-superuser attempted to access import-workflow endpoint', { + userId: session.user.id, + isSuperUser, + superUserModeEnabled, + }) + 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 }) + } +} diff --git a/apps/sim/app/api/templates/[id]/approve/route.ts b/apps/sim/app/api/templates/[id]/approve/route.ts index c15c1916ee..0492ae5845 100644 --- a/apps/sim/app/api/templates/[id]/approve/route.ts +++ b/apps/sim/app/api/templates/[id]/approve/route.ts @@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' -import { verifySuperUser } from '@/lib/templates/permissions' +import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' const logger = createLogger('TemplateApprovalAPI') @@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { isSuperUser } = await verifySuperUser(session.user.id) - if (!isSuperUser) { + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) { logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`) return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 }) } @@ -71,8 +71,8 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { isSuperUser } = await verifySuperUser(session.user.id) - if (!isSuperUser) { + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) { logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`) return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 }) } diff --git a/apps/sim/app/api/templates/[id]/reject/route.ts b/apps/sim/app/api/templates/[id]/reject/route.ts index af5ed2e12b..99e50e52a9 100644 --- a/apps/sim/app/api/templates/[id]/reject/route.ts +++ b/apps/sim/app/api/templates/[id]/reject/route.ts @@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' -import { verifySuperUser } from '@/lib/templates/permissions' +import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' const logger = createLogger('TemplateRejectionAPI') @@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { isSuperUser } = await verifySuperUser(session.user.id) - if (!isSuperUser) { + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + if (!effectiveSuperUser) { logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`) return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 }) } diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 7177aa0050..2985684e4b 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -3,7 +3,6 @@ import { templateCreators, templateStars, templates, - user, workflow, workflowDeploymentVersion, } from '@sim/db/schema' @@ -14,6 +13,7 @@ import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' +import { verifyEffectiveSuperUser } from '@/lib/templates/permissions' import { extractRequiredCredentials, sanitizeCredentials, @@ -70,8 +70,8 @@ export async function GET(request: NextRequest) { logger.debug(`[${requestId}] Fetching templates with params:`, params) // Check if user is a super user - const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1) - const isSuperUser = currentUser[0]?.isSuperUser || false + const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id) + const isSuperUser = effectiveSuperUser // Build query conditions const conditions = [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx index 0992aaaea0..826085970d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/tool-call/tool-call.tsx @@ -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' ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/debug/debug.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/debug/debug.tsx new file mode 100644 index 0000000000..b36d32a3a4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/debug/debug.tsx @@ -0,0 +1,80 @@ +'use client' + +import { useState } from 'react' +import { createLogger } from '@sim/logger' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' +import { Button, Input as EmcnInput } from '@/components/emcn' +import { workflowKeys } from '@/hooks/queries/workflows' + +const logger = createLogger('DebugSettings') + +/** + * Debug settings component for superusers. + * Allows importing workflows by ID for debugging purposes. + */ +export function Debug() { + const params = useParams() + const queryClient = useQueryClient() + const workspaceId = params?.workspaceId as string + + const [workflowId, setWorkflowId] = useState('') + const [isImporting, setIsImporting] = useState(false) + + const handleImport = async () => { + if (!workflowId.trim()) return + + setIsImporting(true) + + 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) { + await queryClient.invalidateQueries({ queryKey: workflowKeys.list(workspaceId) }) + setWorkflowId('') + logger.info('Workflow imported successfully', { + originalWorkflowId: workflowId.trim(), + newWorkflowId: data.newWorkflowId, + copilotChatsImported: data.copilotChatsImported, + }) + } + } catch (error) { + logger.error('Failed to import workflow', error) + } finally { + setIsImporting(false) + } + } + + return ( +
+

+ Import a workflow by ID along with its associated copilot chats. +

+ +
+ setWorkflowId(e.target.value)} + placeholder='Enter workflow ID' + disabled={isImporting} + /> + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts index f83fd34de6..e2241137f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index bb8ef48a06..f862a12902 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -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,15 @@ 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 +113,7 @@ type NavigationItem = { requiresEnterprise?: boolean requiresHosted?: boolean selfHostedOverride?: boolean + requiresSuperUser?: boolean } const sectionConfig: { key: NavigationSection; title: string }[] = [ @@ -112,6 +122,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,15 +191,24 @@ 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('general') const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore() const [pendingMcpServerId, setPendingMcpServerId] = useState(null) + const [isSuperUser, setIsSuperUser] = useState(false) const { data: session } = useSession() const queryClient = useQueryClient() const { data: organizationsData } = useOrganizations() + const { data: generalSettings } = useGeneralSettings() const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders() @@ -209,6 +229,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 +305,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { return false } + // requiresSuperUser: only show if user is a superuser AND has superuser mode enabled + const superUserModeEnabled = generalSettings?.superUserModeEnabled ?? false + const effectiveSuperUser = isSuperUser && superUserModeEnabled + if (item.requiresSuperUser && !effectiveSuperUser) { + return false + } + return true }) }, [ @@ -280,6 +324,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { isOwner, isAdmin, permissionConfig, + isSuperUser, + generalSettings?.superUserModeEnabled, ]) // Memoized callbacks to prevent infinite loops in child components @@ -308,9 +354,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { [activeSection] ) - // React Query hook automatically loads and syncs settings - useGeneralSettings() - // Apply initial section from store when modal opens useEffect(() => { if (open && initialSection) { @@ -523,6 +566,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { {activeSection === 'mcp' && } {activeSection === 'custom-tools' && } {activeSection === 'workflow-mcp-servers' && } + {activeSection === 'debug' && } diff --git a/apps/sim/lib/templates/permissions.ts b/apps/sim/lib/templates/permissions.ts index 60288928c8..f49dffb5e7 100644 --- a/apps/sim/lib/templates/permissions.ts +++ b/apps/sim/lib/templates/permissions.ts @@ -1,18 +1,41 @@ import { db } from '@sim/db' -import { member, templateCreators, templates, user } from '@sim/db/schema' +import { member, settings, templateCreators, templates, user } from '@sim/db/schema' import { and, eq, or } from 'drizzle-orm' export type CreatorPermissionLevel = 'member' | 'admin' /** - * Verifies if a user is a super user. + * Verifies if a user is an effective super user (database flag AND settings toggle). + * This should be used for features that can be disabled by the user's settings toggle. * * @param userId - The ID of the user to check - * @returns Object with isSuperUser boolean + * @returns Object with effectiveSuperUser boolean and component values */ -export async function verifySuperUser(userId: string): Promise<{ isSuperUser: boolean }> { - const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1) - return { isSuperUser: currentUser?.isSuperUser || false } +export async function verifyEffectiveSuperUser(userId: string): Promise<{ + effectiveSuperUser: boolean + isSuperUser: boolean + superUserModeEnabled: boolean +}> { + const [currentUser] = await db + .select({ isSuperUser: user.isSuperUser }) + .from(user) + .where(eq(user.id, userId)) + .limit(1) + + const [userSettings] = await db + .select({ superUserModeEnabled: settings.superUserModeEnabled }) + .from(settings) + .where(eq(settings.userId, userId)) + .limit(1) + + const isSuperUser = currentUser?.isSuperUser || false + const superUserModeEnabled = userSettings?.superUserModeEnabled ?? false + + return { + effectiveSuperUser: isSuperUser && superUserModeEnabled, + isSuperUser, + superUserModeEnabled, + } } /**