This commit is contained in:
Siddharth Ganesan
2026-01-19 15:58:07 -08:00
parent e1bea05de0
commit 9da689bc8e
4 changed files with 89 additions and 144 deletions

View File

@@ -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 })
}

View File

@@ -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<ImportResult | null>(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 (
<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 h-full flex-col gap-[16px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Import a workflow by ID along with its associated copilot chats.
</p>
<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 className='flex gap-[8px]'>
<EmcnInput
value={workflowId}
onChange={(e) => setWorkflowId(e.target.value)}
placeholder='Enter workflow ID'
disabled={isImporting}
/>
<Button
variant='tertiary'
onClick={handleImport}
disabled={isImporting || !workflowId.trim()}
>
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
)

View File

@@ -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) {

View File

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