From e1bea05de07b905feb04a8184a4cfed7c80b9047 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 19 Jan 2026 15:42:55 -0800 Subject: [PATCH 1/3] Superuser debug --- .../api/superuser/import-workflow/route.ts | 192 ++++++++++++++++++ .../components/tool-call/tool-call.tsx | 2 +- .../settings-modal/components/debug/debug.tsx | 177 ++++++++++++++++ .../settings-modal/components/index.ts | 1 + .../settings-modal/settings-modal.tsx | 39 +++- 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/api/superuser/import-workflow/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/debug/debug.tsx 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..d174faf2fe --- /dev/null +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -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 }) + } +} 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..7070571b17 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/debug/debug.tsx @@ -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(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 ( +
+
+ +

+ This is a superuser debug feature. Use with caution. Imported workflows and copilot chats + will be copied to your current workspace. +

+
+ +
+
+

Import Workflow by ID

+

+ 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. +

+
+ +
+ +
+ setWorkflowId(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter workflow ID (e.g., abc123-def456-...)" + disabled={isImporting} + className="flex-1" + /> + +
+
+ + {result && ( +
+ {result.success ? ( +
+

Workflow imported successfully!

+

+ New workflow ID: {result.newWorkflowId} +

+

+ Copilot chats imported: {result.copilotChatsImported} +

+ +
+ ) : ( +

{result.error}

+ )} +
+ )} +
+
+ ) +} 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..6b1a152aaa 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,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('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() @@ -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' && } {activeSection === 'custom-tools' && } {activeSection === 'workflow-mcp-servers' && } + {activeSection === 'debug' && } From 9da689bc8ebbc948567ed80acdc0a9bf4607dbff Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 19 Jan 2026 15:58:07 -0800 Subject: [PATCH 2/3] Fix --- .../api/superuser/import-workflow/route.ts | 21 +-- .../settings-modal/components/debug/debug.tsx | 157 ++++-------------- .../settings-modal/settings-modal.tsx | 19 ++- apps/sim/lib/templates/permissions.ts | 38 ++++- 4 files changed, 90 insertions(+), 145 deletions(-) diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index d174faf2fe..3998792993 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -1,9 +1,10 @@ import { db } from '@sim/db' -import { copilotChats, user, workflow, workspace } from '@sim/db/schema' +import { copilotChats, workflow, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' -import { NextRequest, NextResponse } from 'next/server' +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, @@ -25,6 +26,8 @@ interface ImportWorkflowRequest { * 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 { @@ -33,16 +36,14 @@ export async function POST(request: NextRequest) { 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) + const { effectiveSuperUser, isSuperUser, superUserModeEnabled } = + await verifyEffectiveSuperUser(session.user.id) - if (!currentUser?.isSuperUser) { - logger.warn('Non-superuser attempted to access import-workflow endpoint', { + 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 }) } 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 index 7070571b17..b36d32a3a4 100644 --- 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 @@ -1,43 +1,30 @@ '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 { 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') -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(null) const handleImport = async () => { if (!workflowId.trim()) return setIsImporting(true) - setResult(null) try { const response = await fetch('/api/superuser/import-workflow', { @@ -51,126 +38,42 @@ export function Debug() { const data = await response.json() - if (!response.ok) { - setResult({ success: false, error: data.error || 'Failed to import workflow' }) - return + 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, + }) } - - // 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 ( -
-
- -

- This is a superuser debug feature. Use with caution. Imported workflows and copilot chats - will be copied to your current workspace. -

-
- -
-
-

Import Workflow by ID

-

- 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. -

-
- -
- -
- setWorkflowId(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Enter workflow ID (e.g., abc123-def456-...)" - disabled={isImporting} - className="flex-1" - /> - -
-
- - {result && ( -
- {result.success ? ( -
-

Workflow imported successfully!

-

- New workflow ID: {result.newWorkflowId} -

-

- Copilot chats imported: {result.copilotChatsImported} -

- -
- ) : ( -

{result.error}

- )} -
- )} +
+

+ 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/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 6b1a152aaa..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 @@ -95,7 +95,13 @@ type SettingsSection = | 'workflow-mcp-servers' | 'debug' -type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise' | 'superuser' +type NavigationSection = + | 'account' + | 'subscription' + | 'tools' + | 'system' + | 'enterprise' + | 'superuser' type NavigationItem = { id: SettingsSection @@ -202,6 +208,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { 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() @@ -298,8 +305,10 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { return false } - // requiresSuperUser: only show if user is a superuser - if (item.requiresSuperUser && !isSuperUser) { + // 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 } @@ -316,6 +325,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { isAdmin, permissionConfig, isSuperUser, + generalSettings?.superUserModeEnabled, ]) // Memoized callbacks to prevent infinite loops in child components @@ -344,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) { diff --git a/apps/sim/lib/templates/permissions.ts b/apps/sim/lib/templates/permissions.ts index 60288928c8..7c17b92fcb 100644 --- a/apps/sim/lib/templates/permissions.ts +++ b/apps/sim/lib/templates/permissions.ts @@ -1,11 +1,11 @@ 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 a super user (database flag only). * * @param userId - The ID of the user to check * @returns Object with isSuperUser boolean @@ -15,6 +15,40 @@ export async function verifySuperUser(userId: string): Promise<{ isSuperUser: bo return { isSuperUser: currentUser?.isSuperUser || false } } +/** + * 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 effectiveSuperUser boolean and component values + */ +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, + } +} + /** * Fetches a template and verifies the user has permission to modify it. * Combines template existence check and creator permission check in one call. From 526b7a64f6d81dd1842c284ef791c8f046b46c6e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 19 Jan 2026 16:20:26 -0800 Subject: [PATCH 3/3] update templates routes to use helper --- apps/sim/app/api/creators/[id]/verify/route.ts | 13 ++++++------- apps/sim/app/api/templates/[id]/approve/route.ts | 10 +++++----- apps/sim/app/api/templates/[id]/reject/route.ts | 6 +++--- apps/sim/app/api/templates/route.ts | 6 +++--- apps/sim/lib/templates/permissions.ts | 11 ----------- 5 files changed, 17 insertions(+), 29 deletions(-) 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/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/lib/templates/permissions.ts b/apps/sim/lib/templates/permissions.ts index 7c17b92fcb..f49dffb5e7 100644 --- a/apps/sim/lib/templates/permissions.ts +++ b/apps/sim/lib/templates/permissions.ts @@ -4,17 +4,6 @@ import { and, eq, or } from 'drizzle-orm' export type CreatorPermissionLevel = 'member' | 'admin' -/** - * Verifies if a user is a super user (database flag only). - * - * @param userId - The ID of the user to check - * @returns Object with isSuperUser boolean - */ -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 } -} - /** * 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.