mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-19 20:08:04 -05:00
Compare commits
3 Commits
improvemen
...
feat/super
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
526b7a64f6 | ||
|
|
9da689bc8e | ||
|
|
e1bea05de0 |
@@ -1,10 +1,11 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { templateCreators, user } from '@sim/db/schema'
|
import { templateCreators } from '@sim/db/schema'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('CreatorVerificationAPI')
|
const logger = createLogger('CreatorVerificationAPI')
|
||||||
|
|
||||||
@@ -23,9 +24,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
|
if (!effectiveSuperUser) {
|
||||||
if (!currentUser[0]?.isSuperUser) {
|
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
|
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
|
// Check if user is a super user
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
|
if (!effectiveSuperUser) {
|
||||||
if (!currentUser[0]?.isSuperUser) {
|
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
193
apps/sim/app/api/superuser/import-workflow/route.ts
Normal file
193
apps/sim/app/api/superuser/import-workflow/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateApprovalAPI')
|
const logger = createLogger('TemplateApprovalAPI')
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!effectiveSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
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 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!effectiveSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateRejectionAPI')
|
const logger = createLogger('TemplateRejectionAPI')
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!effectiveSuperUser) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
templateCreators,
|
templateCreators,
|
||||||
templateStars,
|
templateStars,
|
||||||
templates,
|
templates,
|
||||||
user,
|
|
||||||
workflow,
|
workflow,
|
||||||
workflowDeploymentVersion,
|
workflowDeploymentVersion,
|
||||||
} from '@sim/db/schema'
|
} from '@sim/db/schema'
|
||||||
@@ -14,6 +13,7 @@ import { v4 as uuidv4 } from 'uuid'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
|
||||||
import {
|
import {
|
||||||
extractRequiredCredentials,
|
extractRequiredCredentials,
|
||||||
sanitizeCredentials,
|
sanitizeCredentials,
|
||||||
@@ -70,8 +70,8 @@ export async function GET(request: NextRequest) {
|
|||||||
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
||||||
|
|
||||||
// Check if user is a super user
|
// Check if user is a super user
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
|
||||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
const isSuperUser = effectiveSuperUser
|
||||||
|
|
||||||
// Build query conditions
|
// Build query conditions
|
||||||
const conditions = []
|
const conditions = []
|
||||||
|
|||||||
@@ -1477,7 +1477,7 @@ export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }:
|
|||||||
toolCall.name === 'mark_todo_in_progress' ||
|
toolCall.name === 'mark_todo_in_progress' ||
|
||||||
toolCall.name === 'tool_search_tool_regex' ||
|
toolCall.name === 'tool_search_tool_regex' ||
|
||||||
toolCall.name === 'user_memory' ||
|
toolCall.name === 'user_memory' ||
|
||||||
toolCall.name === 'edit_responsd' ||
|
toolCall.name === 'edit_respond' ||
|
||||||
toolCall.name === 'debug_respond' ||
|
toolCall.name === 'debug_respond' ||
|
||||||
toolCall.name === 'plan_respond'
|
toolCall.name === 'plan_respond'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export { BYOK } from './byok/byok'
|
|||||||
export { Copilot } from './copilot/copilot'
|
export { Copilot } from './copilot/copilot'
|
||||||
export { CredentialSets } from './credential-sets/credential-sets'
|
export { CredentialSets } from './credential-sets/credential-sets'
|
||||||
export { CustomTools } from './custom-tools/custom-tools'
|
export { CustomTools } from './custom-tools/custom-tools'
|
||||||
|
export { Debug } from './debug/debug'
|
||||||
export { EnvironmentVariables } from './environment/environment'
|
export { EnvironmentVariables } from './environment/environment'
|
||||||
export { Files as FileUploads } from './files/files'
|
export { Files as FileUploads } from './files/files'
|
||||||
export { General } from './general/general'
|
export { General } from './general/general'
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|||||||
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
Bug,
|
||||||
Files,
|
Files,
|
||||||
KeySquare,
|
KeySquare,
|
||||||
LogIn,
|
LogIn,
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
Copilot,
|
Copilot,
|
||||||
CredentialSets,
|
CredentialSets,
|
||||||
CustomTools,
|
CustomTools,
|
||||||
|
Debug,
|
||||||
EnvironmentVariables,
|
EnvironmentVariables,
|
||||||
FileUploads,
|
FileUploads,
|
||||||
General,
|
General,
|
||||||
@@ -91,8 +93,15 @@ type SettingsSection =
|
|||||||
| 'mcp'
|
| 'mcp'
|
||||||
| 'custom-tools'
|
| 'custom-tools'
|
||||||
| 'workflow-mcp-servers'
|
| 'workflow-mcp-servers'
|
||||||
|
| 'debug'
|
||||||
|
|
||||||
type NavigationSection = 'account' | 'subscription' | 'tools' | 'system' | 'enterprise'
|
type NavigationSection =
|
||||||
|
| 'account'
|
||||||
|
| 'subscription'
|
||||||
|
| 'tools'
|
||||||
|
| 'system'
|
||||||
|
| 'enterprise'
|
||||||
|
| 'superuser'
|
||||||
|
|
||||||
type NavigationItem = {
|
type NavigationItem = {
|
||||||
id: SettingsSection
|
id: SettingsSection
|
||||||
@@ -104,6 +113,7 @@ type NavigationItem = {
|
|||||||
requiresEnterprise?: boolean
|
requiresEnterprise?: boolean
|
||||||
requiresHosted?: boolean
|
requiresHosted?: boolean
|
||||||
selfHostedOverride?: boolean
|
selfHostedOverride?: boolean
|
||||||
|
requiresSuperUser?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionConfig: { key: NavigationSection; title: string }[] = [
|
const sectionConfig: { key: NavigationSection; title: string }[] = [
|
||||||
@@ -112,6 +122,7 @@ const sectionConfig: { key: NavigationSection; title: string }[] = [
|
|||||||
{ key: 'subscription', title: 'Subscription' },
|
{ key: 'subscription', title: 'Subscription' },
|
||||||
{ key: 'system', title: 'System' },
|
{ key: 'system', title: 'System' },
|
||||||
{ key: 'enterprise', title: 'Enterprise' },
|
{ key: 'enterprise', title: 'Enterprise' },
|
||||||
|
{ key: 'superuser', title: 'Superuser' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const allNavigationItems: NavigationItem[] = [
|
const allNavigationItems: NavigationItem[] = [
|
||||||
@@ -180,15 +191,24 @@ const allNavigationItems: NavigationItem[] = [
|
|||||||
requiresEnterprise: true,
|
requiresEnterprise: true,
|
||||||
selfHostedOverride: isSSOEnabled,
|
selfHostedOverride: isSSOEnabled,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'debug',
|
||||||
|
label: 'Debug',
|
||||||
|
icon: Bug,
|
||||||
|
section: 'superuser',
|
||||||
|
requiresSuperUser: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
const [activeSection, setActiveSection] = useState<SettingsSection>('general')
|
||||||
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
const { initialSection, mcpServerId, clearInitialState } = useSettingsModalStore()
|
||||||
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
const [pendingMcpServerId, setPendingMcpServerId] = useState<string | null>(null)
|
||||||
|
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: organizationsData } = useOrganizations()
|
const { data: organizationsData } = useOrganizations()
|
||||||
|
const { data: generalSettings } = useGeneralSettings()
|
||||||
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||||
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders()
|
||||||
|
|
||||||
@@ -209,6 +229,23 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||||
const hasOrganization = !!activeOrganization?.id
|
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
|
// Memoize SSO provider ownership check
|
||||||
const isSSOProviderOwner = useMemo(() => {
|
const isSSOProviderOwner = useMemo(() => {
|
||||||
if (isHosted) return null
|
if (isHosted) return null
|
||||||
@@ -268,6 +305,13 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
return false
|
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
|
return true
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
@@ -280,6 +324,8 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
isOwner,
|
isOwner,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
permissionConfig,
|
permissionConfig,
|
||||||
|
isSuperUser,
|
||||||
|
generalSettings?.superUserModeEnabled,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Memoized callbacks to prevent infinite loops in child components
|
// Memoized callbacks to prevent infinite loops in child components
|
||||||
@@ -308,9 +354,6 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
[activeSection]
|
[activeSection]
|
||||||
)
|
)
|
||||||
|
|
||||||
// React Query hook automatically loads and syncs settings
|
|
||||||
useGeneralSettings()
|
|
||||||
|
|
||||||
// Apply initial section from store when modal opens
|
// Apply initial section from store when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && initialSection) {
|
if (open && initialSection) {
|
||||||
@@ -523,6 +566,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
|||||||
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
{activeSection === 'mcp' && <MCP initialServerId={pendingMcpServerId} />}
|
||||||
{activeSection === 'custom-tools' && <CustomTools />}
|
{activeSection === 'custom-tools' && <CustomTools />}
|
||||||
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
{activeSection === 'workflow-mcp-servers' && <WorkflowMcpServers />}
|
||||||
|
{activeSection === 'debug' && <Debug />}
|
||||||
</SModalMainBody>
|
</SModalMainBody>
|
||||||
</SModalMain>
|
</SModalMain>
|
||||||
</SModalContent>
|
</SModalContent>
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import { db } from '@sim/db'
|
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'
|
import { and, eq, or } from 'drizzle-orm'
|
||||||
|
|
||||||
export type CreatorPermissionLevel = 'member' | 'admin'
|
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
|
* @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 }> {
|
export async function verifyEffectiveSuperUser(userId: string): Promise<{
|
||||||
const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
|
effectiveSuperUser: boolean
|
||||||
return { isSuperUser: currentUser?.isSuperUser || false }
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user