mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal (#2130)
* fix(permissions): add client-side permissions validation to prevent unauthorized actions, upgraded custom tool modal * fix failing test * fix test * cleanup
This commit is contained in:
@@ -4330,7 +4330,7 @@ export function PylonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
viewBox='0 0 26 26'
|
||||
fill='none'
|
||||
>
|
||||
<g clip-path='url(#clip0_6559_17753)'>
|
||||
<g clipPath='url(#clip0_6559_17753)'>
|
||||
<path
|
||||
d='M21.3437 4.1562C18.9827 1.79763 15.8424 0.5 12.5015 0.5C9.16056 0.5 6.02027 1.79763 3.66091 4.15455C1.29989 6.51147 0 9.64465 0 12.9798C0 16.3149 1.29989 19.448 3.66091 21.805C6.02193 24.1619 9.16222 25.4612 12.5031 25.4612C15.844 25.4612 18.9843 24.1635 21.3454 21.8066C23.7064 19.4497 25.0063 16.3165 25.0063 12.9814C25.0063 9.6463 23.7064 6.51312 21.3454 4.1562H21.3437ZM22.3949 12.9814C22.3949 17.927 18.7074 22.1227 13.8063 22.7699V3.1896C18.7074 3.83676 22.3949 8.0342 22.3949 12.9798V12.9814ZM4.8265 6.75643C6.43312 4.7835 8.68803 3.52063 11.1983 3.1896V6.75643H4.8265ZM11.1983 9.36162V11.6904H2.69428C2.79874 10.8926 3.00267 10.1097 3.2978 9.36162H11.1983ZM11.1983 14.2939V16.6227H3.30775C3.00931 15.8746 2.80371 15.0917 2.6976 14.2939H11.1983ZM11.1983 19.2279V22.7699C8.70129 22.4405 6.45302 21.1859 4.84805 19.2279H11.1983Z'
|
||||
fill='#5B0EFF'
|
||||
|
||||
@@ -42,6 +42,7 @@ With Zendesk in Sim, you can:
|
||||
By leveraging Zendesk’s Sim integration, your automated workflows can seamlessly handle support ticket triage, user onboarding/offboarding, company management, and keep your support operations running smoothly. Whether you’re integrating support with product, CRM, or automation systems, Zendesk tools in Sim provide robust, programmatic control to power best-in-class support at scale.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate Zendesk into the workflow. Can get tickets, get ticket, create ticket, create tickets bulk, update ticket, update tickets bulk, delete ticket, merge tickets, get users, get user, get current user, search users, create user, create users bulk, update user, update users bulk, delete user, get organizations, get organization, autocomplete organizations, create organization, create organizations bulk, update organization, delete organization, search, search count.
|
||||
|
||||
@@ -19,11 +19,13 @@ describe('Chat Edit API Route', () => {
|
||||
const mockCreateErrorResponse = vi.fn()
|
||||
const mockEncryptSecret = vi.fn()
|
||||
const mockCheckChatAccess = vi.fn()
|
||||
const mockGetSession = vi.fn()
|
||||
const mockDeployWorkflow = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
// Set default return values
|
||||
mockLimit.mockResolvedValue([])
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
@@ -43,10 +45,6 @@ describe('Chat Edit API Route', () => {
|
||||
chat: { id: 'id', identifier: 'identifier', userId: 'userId' },
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
@@ -86,6 +84,15 @@ describe('Chat Edit API Route', () => {
|
||||
vi.doMock('@/app/api/chat/utils', () => ({
|
||||
checkChatAccess: mockCheckChatAccess,
|
||||
}))
|
||||
|
||||
mockDeployWorkflow.mockResolvedValue({ success: true, version: 1 })
|
||||
vi.doMock('@/lib/workflows/db-helpers', () => ({
|
||||
deployWorkflow: mockDeployWorkflow,
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -94,20 +101,25 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
mockGetSession.mockResolvedValueOnce(null)
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
|
||||
const { GET } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 404 when chat not found or access denied', async () => {
|
||||
mockGetSession.mockResolvedValueOnce({
|
||||
user: { id: 'user-id' },
|
||||
})
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
@@ -116,7 +128,8 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Chat not found or access denied')
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
|
||||
})
|
||||
|
||||
@@ -143,15 +156,12 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
id: 'chat-123',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
description: 'A test chat',
|
||||
customizations: { primaryColor: '#000000' },
|
||||
chatUrl: 'http://localhost:3000/chat/test-chat',
|
||||
hasPassword: true,
|
||||
})
|
||||
const data = await response.json()
|
||||
expect(data.id).toBe('chat-123')
|
||||
expect(data.identifier).toBe('test-chat')
|
||||
expect(data.title).toBe('Test Chat')
|
||||
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
|
||||
expect(data.hasPassword).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -169,7 +179,8 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 404 when chat not found or access denied', async () => {
|
||||
@@ -189,7 +200,8 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Chat not found or access denied')
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
|
||||
})
|
||||
|
||||
@@ -205,9 +217,11 @@ describe('Chat Edit API Route', () => {
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
@@ -218,11 +232,10 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
id: 'chat-123',
|
||||
chatUrl: 'http://localhost:3000/chat/test-chat',
|
||||
message: 'Chat deployment updated successfully',
|
||||
})
|
||||
const data = await response.json()
|
||||
expect(data.id).toBe('chat-123')
|
||||
expect(data.chatUrl).toBe('http://localhost:3000/chat/test-chat')
|
||||
expect(data.message).toBe('Chat deployment updated successfully')
|
||||
})
|
||||
|
||||
it('should handle identifier conflicts', async () => {
|
||||
@@ -236,11 +249,15 @@ describe('Chat Edit API Route', () => {
|
||||
id: 'chat-123',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
// Mock identifier conflict
|
||||
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', identifier: 'new-identifier' }])
|
||||
|
||||
// Reset and reconfigure mockLimit to return the conflict
|
||||
mockLimit.mockReset()
|
||||
mockLimit.mockResolvedValue([{ id: 'other-chat-id', identifier: 'new-identifier' }])
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
@@ -250,7 +267,8 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Identifier already in use')
|
||||
})
|
||||
|
||||
it('should validate password requirement for password auth', async () => {
|
||||
@@ -266,6 +284,7 @@ describe('Chat Edit API Route', () => {
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
password: null,
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
@@ -278,10 +297,8 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'Password is required when using password protection',
|
||||
400
|
||||
)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Password is required when using password protection')
|
||||
})
|
||||
|
||||
it('should allow access when user has workspace admin permission', async () => {
|
||||
@@ -296,10 +313,12 @@ describe('Chat Edit API Route', () => {
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
// User doesn't own chat but has workspace admin access
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
@@ -326,7 +345,8 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('should return 404 when chat not found or access denied', async () => {
|
||||
@@ -345,7 +365,8 @@ describe('Chat Edit API Route', () => {
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Chat not found or access denied', 404)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Chat not found or access denied')
|
||||
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
|
||||
})
|
||||
|
||||
@@ -367,9 +388,8 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockDelete).toHaveBeenCalled()
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
message: 'Chat deployment deleted successfully',
|
||||
})
|
||||
const data = await response.json()
|
||||
expect(data.message).toBe('Chat deployment deleted successfully')
|
||||
})
|
||||
|
||||
it('should allow deletion when user has workspace admin permission', async () => {
|
||||
|
||||
@@ -175,7 +175,8 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error updating custom tools`, error)
|
||||
return NextResponse.json({ error: 'Failed to update custom tools' }, { status: 500 })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to update custom tools'
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,11 +97,9 @@ export function CodeEditor({
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [code])
|
||||
|
||||
// Calculate the number of lines to determine gutter width
|
||||
const lineCount = code.split('\n').length
|
||||
const gutterWidth = calculateGutterWidth(lineCount)
|
||||
|
||||
// Render helpers
|
||||
const renderLineNumbers = () => {
|
||||
const numbers: ReactElement[] = []
|
||||
let lineNumber = 1
|
||||
@@ -127,88 +125,41 @@ export function CodeEditor({
|
||||
return numbers
|
||||
}
|
||||
|
||||
// Custom highlighter that highlights environment variables and tags
|
||||
const customHighlight = (code: string) => {
|
||||
if (!highlightVariables || language !== 'javascript') {
|
||||
// Use default Prism highlighting for non-JS or when variable highlighting is off
|
||||
return highlight(code, languages[language], language)
|
||||
}
|
||||
|
||||
// First, get the default Prism highlighting
|
||||
let highlighted = highlight(code, languages[language], language)
|
||||
const placeholders: Array<{ placeholder: string; original: string; type: 'env' | 'param' }> = []
|
||||
let processedCode = code
|
||||
|
||||
// Collect all syntax highlights to apply in a single pass
|
||||
type SyntaxHighlight = {
|
||||
start: number
|
||||
end: number
|
||||
replacement: string
|
||||
}
|
||||
const highlights: SyntaxHighlight[] = []
|
||||
processedCode = processedCode.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
||||
const placeholder = `__ENV_VAR_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'env' })
|
||||
return placeholder
|
||||
})
|
||||
|
||||
// Find environment variables with {{var_name}} syntax
|
||||
let match
|
||||
const envVarRegex = /\{\{([^}]+)\}\}/g
|
||||
while ((match = envVarRegex.exec(highlighted)) !== null) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
replacement: `<span class="text-[#34B5FF] dark:text-[#34B5FF]">${match[0]}</span>`,
|
||||
})
|
||||
}
|
||||
|
||||
// Find tags with <tag_name> syntax (not in HTML context)
|
||||
if (!language.includes('html')) {
|
||||
const tagRegex = /<([^>\s/]+)>/g
|
||||
while ((match = tagRegex.exec(highlighted)) !== null) {
|
||||
// Skip HTML comments and closing tags
|
||||
if (!match[0].startsWith('<!--') && !match[0].includes('</')) {
|
||||
const escaped = `<${match[1]}>`
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
replacement: `<span class="text-[#34B5FF] dark:text-[#34B5FF]">${escaped}</span>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find schema parameters as whole words
|
||||
if (schemaParameters.length > 0) {
|
||||
schemaParameters.forEach((param) => {
|
||||
// Escape special regex characters in parameter name
|
||||
const escapedName = param.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const paramRegex = new RegExp(`\\b(${escapedName})\\b`, 'g')
|
||||
while ((match = paramRegex.exec(highlighted)) !== null) {
|
||||
// Check if this position is already inside an HTML tag
|
||||
// by looking for unclosed < before this position
|
||||
let insideTag = false
|
||||
let pos = match.index - 1
|
||||
while (pos >= 0) {
|
||||
if (highlighted[pos] === '>') break
|
||||
if (highlighted[pos] === '<') {
|
||||
insideTag = true
|
||||
break
|
||||
}
|
||||
pos--
|
||||
}
|
||||
|
||||
if (!insideTag) {
|
||||
highlights.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
replacement: `<span class="text-[#34B5FF] dark:text-[#34B5FF] font-medium">${match[0]}</span>`,
|
||||
})
|
||||
}
|
||||
}
|
||||
processedCode = processedCode.replace(paramRegex, (match) => {
|
||||
const placeholder = `__PARAM_${placeholders.length}__`
|
||||
placeholders.push({ placeholder, original: match, type: 'param' })
|
||||
return placeholder
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Sort highlights by start position (reverse order to maintain positions)
|
||||
highlights.sort((a, b) => b.start - a.start)
|
||||
let highlighted = highlight(processedCode, languages[language], language)
|
||||
|
||||
// Apply all highlights
|
||||
highlights.forEach(({ start, end, replacement }) => {
|
||||
highlighted = highlighted.slice(0, start) + replacement + highlighted.slice(end)
|
||||
placeholders.forEach(({ placeholder, original, type }) => {
|
||||
const replacement =
|
||||
type === 'env'
|
||||
? `<span style="color: #34B5FF;">${original}</span>`
|
||||
: `<span style="color: #34B5FF; font-weight: 500;">${original}</span>`
|
||||
|
||||
highlighted = highlighted.replace(placeholder, replacement)
|
||||
})
|
||||
|
||||
return highlighted
|
||||
|
||||
@@ -198,14 +198,14 @@ Example 2:
|
||||
prompt: `You are an expert JavaScript programmer.
|
||||
Generate ONLY the raw body of a JavaScript function based on the user's request.
|
||||
The code should be executable within an 'async function(params, environmentVariables) {...}' context.
|
||||
- 'params' (object): Contains input parameters derived from the JSON schema. Access these directly using the parameter name wrapped in angle brackets, e.g., '<paramName>'. Do NOT use 'params.paramName'.
|
||||
- 'params' (object): Contains input parameters derived from the JSON schema. Reference these directly by name (e.g., 'userId', 'cityName'). Do NOT use 'params.paramName'.
|
||||
- 'environmentVariables' (object): Contains environment variables. Reference these using the double curly brace syntax: '{{ENV_VAR_NAME}}'. Do NOT use 'environmentVariables.VAR_NAME' or env.
|
||||
|
||||
Current code: {context}
|
||||
|
||||
IMPORTANT FORMATTING RULES:
|
||||
1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'apiKey = {{SERVICE_API_KEY}}' not 'apiKey = "{{SERVICE_API_KEY}}"'). Our system replaces these placeholders before execution.
|
||||
2. Reference Input Parameters/Workflow Variables: Use the exact syntax <variable_name>. Do NOT wrap it in quotes (e.g., use 'userId = <userId>;' not 'userId = "<userId>";'). This includes parameters defined in the block's schema and outputs from previous blocks.
|
||||
1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes (e.g., use 'const apiKey = {{SERVICE_API_KEY}};' not 'const apiKey = "{{SERVICE_API_KEY}}";'). Our system replaces these placeholders before execution.
|
||||
2. Reference Input Parameters/Workflow Variables: Reference them directly by name (e.g., 'const city = cityName;' or use directly in template strings like \`\${cityName}\`). Do NOT wrap in quotes or angle brackets.
|
||||
3. Function Body ONLY: Do NOT include the function signature (e.g., 'async function myFunction() {' or the surrounding '}').
|
||||
4. Imports: Do NOT include import/require statements unless they are standard Node.js built-in modules (e.g., 'crypto', 'fs'). External libraries are not supported in this context.
|
||||
5. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'.
|
||||
@@ -213,33 +213,28 @@ IMPORTANT FORMATTING RULES:
|
||||
7. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw JavaScript code for the function body.
|
||||
|
||||
Example Scenario:
|
||||
User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable."
|
||||
User Prompt: "Fetch weather data from OpenWeather API. Use the city name passed in as 'cityName' and an API Key stored as the 'OPENWEATHER_API_KEY' environment variable."
|
||||
|
||||
Generated Code:
|
||||
const userId = userId; // Correct: Accessing userId input parameter without quotes
|
||||
const apiKey = {{SERVICE_API_KEY}}; // Correct: Accessing environment variable without quotes
|
||||
const url = \`https://api.example.com/users/\${userId}\`;
|
||||
const apiKey = {{OPENWEATHER_API_KEY}};
|
||||
const url = \`https://api.openweathermap.org/data/2.5/weather?q=\${cityName}&appid=\${apiKey}\`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': \`Bearer \${apiKey}\`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Throwing an error will mark the block execution as failed
|
||||
throw new Error(\`API request failed with status \${response.status}: \${await response.text()}\`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('User data fetched successfully.'); // Optional: logging for debugging
|
||||
return data; // Return the fetched data which becomes the block's output
|
||||
const weatherData = await response.json();
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
console.error(\`Error fetching user data: \${error.message}\`);
|
||||
// Re-throwing the error ensures the workflow knows this step failed.
|
||||
console.error(\`Error fetching weather data: \${error.message}\`);
|
||||
throw error;
|
||||
}`,
|
||||
placeholder: 'Describe the JavaScript function to generate...',
|
||||
@@ -253,16 +248,13 @@ try {
|
||||
onStreamChunk: (chunk) => {
|
||||
setFunctionCode((prev) => {
|
||||
const newCode = prev + chunk
|
||||
// Use existing handler logic for consistency, though dropdowns might be disabled during streaming
|
||||
handleFunctionCodeChange(newCode)
|
||||
// Clear error as soon as streaming starts
|
||||
if (codeError) setCodeError(null)
|
||||
return newCode
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Environment variables and tags dropdown state
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
const [showTags, setShowTags] = useState(false)
|
||||
const [showSchemaParams, setShowSchemaParams] = useState(false)
|
||||
@@ -270,20 +262,18 @@ try {
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const codeEditorRef = useRef<HTMLDivElement>(null)
|
||||
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
|
||||
// Add state for dropdown positioning
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 })
|
||||
// Schema params keyboard navigation
|
||||
const [schemaParamSelectedIndex, setSchemaParamSelectedIndex] = useState(0)
|
||||
|
||||
// React Query mutations
|
||||
const createToolMutation = useCreateCustomTool()
|
||||
const updateToolMutation = useUpdateCustomTool()
|
||||
const deleteToolMutation = useDeleteCustomTool()
|
||||
const { data: customTools = [] } = useCustomTools(workspaceId)
|
||||
|
||||
// Initialize form with initial values if provided
|
||||
useEffect(() => {
|
||||
if (open && initialValues) {
|
||||
if (!open) return
|
||||
|
||||
if (initialValues) {
|
||||
try {
|
||||
setJsonSchema(
|
||||
typeof initialValues.schema === 'string'
|
||||
@@ -297,11 +287,10 @@ try {
|
||||
logger.error('Error initializing form with initial values:', { error })
|
||||
setSchemaError('Failed to load tool data. Please try again.')
|
||||
}
|
||||
} else if (open) {
|
||||
// Reset form when opening without initial values
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}, [open, initialValues])
|
||||
}, [open])
|
||||
|
||||
const resetForm = () => {
|
||||
setJsonSchema('')
|
||||
@@ -311,6 +300,12 @@ try {
|
||||
setActiveSection('schema')
|
||||
setIsEditing(false)
|
||||
setToolId(undefined)
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
setIsSchemaPromptActive(false)
|
||||
setIsCodePromptActive(false)
|
||||
setSchemaPromptInput('')
|
||||
setCodePromptInput('')
|
||||
schemaGeneration.closePrompt()
|
||||
schemaGeneration.hidePromptInline()
|
||||
codeGeneration.closePrompt()
|
||||
@@ -318,21 +313,18 @@ try {
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
// Cancel any ongoing generation before closing
|
||||
if (schemaGeneration.isStreaming) schemaGeneration.cancelGeneration()
|
||||
if (codeGeneration.isStreaming) codeGeneration.cancelGeneration()
|
||||
resetForm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// Pure validation function that doesn't update state
|
||||
const validateJsonSchema = (schema: string): boolean => {
|
||||
if (!schema) return false
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(schema)
|
||||
|
||||
// Basic validation for function schema
|
||||
if (!parsed.type || parsed.type !== 'function') {
|
||||
return false
|
||||
}
|
||||
@@ -341,7 +333,6 @@ try {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate that parameters object exists with correct structure
|
||||
if (!parsed.function.parameters) {
|
||||
return false
|
||||
}
|
||||
@@ -356,12 +347,10 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Pure validation function that doesn't update state
|
||||
const validateFunctionCode = (code: string): boolean => {
|
||||
return true // Allow empty code
|
||||
}
|
||||
|
||||
// Extract parameters from JSON schema for autocomplete
|
||||
const schemaParameters = useMemo(() => {
|
||||
try {
|
||||
if (!jsonSchema) return []
|
||||
@@ -380,13 +369,11 @@ try {
|
||||
}
|
||||
}, [jsonSchema])
|
||||
|
||||
// Memoize validation results to prevent unnecessary recalculations
|
||||
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
|
||||
const isCodeValid = useMemo(() => validateFunctionCode(functionCode), [functionCode])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Validation with error messages
|
||||
if (!jsonSchema) {
|
||||
setSchemaError('Schema cannot be empty')
|
||||
setActiveSection('schema')
|
||||
@@ -407,7 +394,6 @@ try {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate parameters structure - must be present
|
||||
if (!parsed.function.parameters) {
|
||||
setSchemaError('Missing function.parameters object')
|
||||
setActiveSection('schema')
|
||||
@@ -435,16 +421,13 @@ try {
|
||||
return
|
||||
}
|
||||
|
||||
// No errors, proceed with save - clear any existing errors
|
||||
setSchemaError(null)
|
||||
setCodeError(null)
|
||||
|
||||
// Parse schema to get tool details
|
||||
const schema = JSON.parse(jsonSchema)
|
||||
const name = schema.function.name
|
||||
const description = schema.function.description || ''
|
||||
|
||||
// Determine the tool ID for editing
|
||||
let toolIdToUpdate: string | undefined = toolId
|
||||
if (isEditing && !toolIdToUpdate && initialValues?.schema) {
|
||||
const originalName = initialValues.schema.function?.name
|
||||
@@ -458,9 +441,7 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Save to the store (server validates duplicates)
|
||||
if (isEditing && toolIdToUpdate) {
|
||||
// Update existing tool
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
toolId: toolIdToUpdate,
|
||||
@@ -471,7 +452,6 @@ try {
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Create new tool
|
||||
await createToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
tool: {
|
||||
@@ -482,7 +462,6 @@ try {
|
||||
})
|
||||
}
|
||||
|
||||
// Create the custom tool object for the parent component
|
||||
const customTool: CustomTool = {
|
||||
type: 'custom-tool',
|
||||
title: name,
|
||||
@@ -494,35 +473,35 @@ try {
|
||||
isExpanded: true,
|
||||
}
|
||||
|
||||
// Pass the tool to parent component
|
||||
onSave(customTool)
|
||||
|
||||
// Close the modal
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
logger.error('Error saving custom tool:', { error })
|
||||
|
||||
// Check if it's an API error with status code (from store)
|
||||
const hasStatus = error && typeof error === 'object' && 'status' in error
|
||||
const errorStatus = hasStatus ? (error as { status: number }).status : null
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to save custom tool'
|
||||
|
||||
// Display server validation errors (400) directly, generic message for others
|
||||
setSchemaError(
|
||||
errorStatus === 400
|
||||
? errorMessage
|
||||
: 'Failed to save custom tool. Please check your inputs and try again.'
|
||||
)
|
||||
if (errorMessage.includes('Cannot change function name')) {
|
||||
setSchemaError(
|
||||
'Function name cannot be changed after creation. To use a different name, delete this tool and create a new one.'
|
||||
)
|
||||
} else {
|
||||
setSchemaError(errorMessage)
|
||||
}
|
||||
setActiveSection('schema')
|
||||
}
|
||||
}
|
||||
|
||||
const handleJsonSchemaChange = (value: string) => {
|
||||
// Prevent updates during AI generation/streaming
|
||||
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||
setJsonSchema(value)
|
||||
|
||||
// Real-time validation - show error immediately when schema is invalid
|
||||
if (value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
@@ -560,21 +539,17 @@ try {
|
||||
return
|
||||
}
|
||||
|
||||
// Schema is valid, clear any existing error
|
||||
setSchemaError(null)
|
||||
} catch {
|
||||
setSchemaError('Invalid JSON format')
|
||||
}
|
||||
} else {
|
||||
// Clear error when schema is empty (will be caught during save)
|
||||
setSchemaError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFunctionCodeChange = (value: string) => {
|
||||
// Prevent updates during AI generation/streaming
|
||||
if (codeGeneration.isLoading || codeGeneration.isStreaming) {
|
||||
// We still need to update the state for streaming chunks, but skip dropdown logic
|
||||
setFunctionCode(value)
|
||||
if (codeError) {
|
||||
setCodeError(null)
|
||||
@@ -587,27 +562,23 @@ try {
|
||||
setCodeError(null)
|
||||
}
|
||||
|
||||
// Check for environment variables and tags
|
||||
const textarea = codeEditorRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
const pos = textarea.selectionStart
|
||||
setCursorPosition(pos)
|
||||
|
||||
// Calculate cursor position for dropdowns
|
||||
const textBeforeCursor = value.substring(0, pos)
|
||||
const lines = textBeforeCursor.split('\n')
|
||||
const currentLine = lines.length
|
||||
const currentCol = lines[lines.length - 1].length
|
||||
|
||||
// Find position of cursor in the editor
|
||||
try {
|
||||
if (codeEditorRef.current) {
|
||||
const editorRect = codeEditorRef.current.getBoundingClientRect()
|
||||
const lineHeight = 21 // Same as in CodeEditor
|
||||
const lineHeight = 21
|
||||
|
||||
// Calculate approximate position
|
||||
const top = currentLine * lineHeight + 5
|
||||
const left = Math.min(currentCol * 8, editorRect.width - 260) // Prevent dropdown from going off-screen
|
||||
const left = Math.min(currentCol * 8, editorRect.width - 260)
|
||||
|
||||
setDropdownPosition({ top, left })
|
||||
}
|
||||
@@ -615,19 +586,16 @@ try {
|
||||
logger.error('Error calculating cursor position:', { error })
|
||||
}
|
||||
|
||||
// Check if we should show the environment variables dropdown
|
||||
const envVarTrigger = checkEnvVarTrigger(value, pos)
|
||||
setShowEnvVars(envVarTrigger.show && !codeGeneration.isStreaming) // Hide dropdown during streaming
|
||||
setShowEnvVars(envVarTrigger.show && !codeGeneration.isStreaming)
|
||||
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
|
||||
// Check if we should show the tags dropdown
|
||||
const tagTrigger = checkTagTrigger(value, pos)
|
||||
setShowTags(tagTrigger.show && !codeGeneration.isStreaming) // Hide dropdown during streaming
|
||||
setShowTags(tagTrigger.show && !codeGeneration.isStreaming)
|
||||
if (!tagTrigger.show) {
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
// Show/hide schema parameters dropdown based on typing context
|
||||
if (!codeGeneration.isStreaming && schemaParameters.length > 0) {
|
||||
const schemaParamTrigger = checkSchemaParamTrigger(value, pos, schemaParameters)
|
||||
if (schemaParamTrigger.show && !showSchemaParams) {
|
||||
@@ -640,16 +608,13 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to check if we should show schema parameters dropdown
|
||||
const checkSchemaParamTrigger = (text: string, cursorPos: number, parameters: any[]) => {
|
||||
if (parameters.length === 0) return { show: false, searchTerm: '' }
|
||||
|
||||
// Look for partial parameter names after common patterns like 'const ', '= ', etc.
|
||||
const beforeCursor = text.substring(0, cursorPos)
|
||||
const words = beforeCursor.split(/[\s=();,{}[\]]+/)
|
||||
const currentWord = words[words.length - 1] || ''
|
||||
|
||||
// Show dropdown if typing and current word could be a parameter
|
||||
if (currentWord.length > 0 && /^[a-zA-Z_][\w]*$/.test(currentWord)) {
|
||||
const matchingParams = parameters.filter((param) =>
|
||||
param.name.toLowerCase().startsWith(currentWord.toLowerCase())
|
||||
@@ -660,20 +625,17 @@ try {
|
||||
return { show: false, searchTerm: '' }
|
||||
}
|
||||
|
||||
// Handle environment variable selection
|
||||
const handleEnvVarSelect = (newValue: string) => {
|
||||
setFunctionCode(newValue)
|
||||
setShowEnvVars(false)
|
||||
}
|
||||
|
||||
// Handle tag selection
|
||||
const handleTagSelect = (newValue: string) => {
|
||||
setFunctionCode(newValue)
|
||||
setShowTags(false)
|
||||
setActiveSourceBlockId(null)
|
||||
}
|
||||
|
||||
// Handle schema parameter selection
|
||||
const handleSchemaParamSelect = (paramName: string) => {
|
||||
const textarea = codeEditorRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
@@ -681,17 +643,14 @@ try {
|
||||
const beforeCursor = functionCode.substring(0, pos)
|
||||
const afterCursor = functionCode.substring(pos)
|
||||
|
||||
// Find the start of the current word
|
||||
const words = beforeCursor.split(/[\s=();,{}[\]]+/)
|
||||
const currentWord = words[words.length - 1] || ''
|
||||
const wordStart = beforeCursor.lastIndexOf(currentWord)
|
||||
|
||||
// Replace the current partial word with the selected parameter
|
||||
const newValue = beforeCursor.substring(0, wordStart) + paramName + afterCursor
|
||||
setFunctionCode(newValue)
|
||||
setShowSchemaParams(false)
|
||||
|
||||
// Set cursor position after the inserted parameter
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(wordStart + paramName.length, wordStart + paramName.length)
|
||||
@@ -699,10 +658,7 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key press events
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Allow AI prompt interaction (e.g., Escape to close prompt bar)
|
||||
// Check if AI prompt is visible for the current section
|
||||
const isSchemaPromptVisible = activeSection === 'schema' && schemaGeneration.isPromptVisible
|
||||
const isCodePromptVisible = activeSection === 'code' && codeGeneration.isPromptVisible
|
||||
|
||||
@@ -719,7 +675,6 @@ try {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
// Close dropdowns first, only close modal if no dropdowns are open
|
||||
if (showEnvVars || showTags || showSchemaParams) {
|
||||
setShowEnvVars(false)
|
||||
setShowTags(false)
|
||||
@@ -730,7 +685,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent regular input if streaming in the active section
|
||||
if (activeSection === 'schema' && schemaGeneration.isStreaming) {
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -740,7 +694,6 @@ try {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle schema parameters dropdown keyboard navigation
|
||||
if (showSchemaParams && schemaParameters.length > 0) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
@@ -767,10 +720,9 @@ try {
|
||||
setShowSchemaParams(false)
|
||||
break
|
||||
}
|
||||
return // Don't handle other dropdown events when schema params is active
|
||||
return
|
||||
}
|
||||
|
||||
// Let other dropdowns handle their own keyboard events if visible
|
||||
if (showEnvVars || showTags) {
|
||||
if (['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
@@ -779,7 +731,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Schema inline wand handlers (copied from regular sub-block UX)
|
||||
const handleSchemaWandClick = () => {
|
||||
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||
setIsSchemaPromptActive(true)
|
||||
@@ -819,7 +770,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
// Code inline wand handlers
|
||||
const handleCodeWandClick = () => {
|
||||
if (codeGeneration.isLoading || codeGeneration.isStreaming) return
|
||||
setIsCodePromptActive(true)
|
||||
@@ -865,26 +815,23 @@ try {
|
||||
try {
|
||||
setShowDeleteConfirm(false)
|
||||
|
||||
// Delete using React Query mutation
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
toolId,
|
||||
})
|
||||
logger.info(`Deleted tool: ${toolId}`)
|
||||
|
||||
// Notify parent component if callback provided
|
||||
if (onDelete) {
|
||||
onDelete(toolId)
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
logger.error('Error deleting custom tool:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to delete custom tool'
|
||||
setSchemaError(`${errorMessage}. Please try again.`)
|
||||
setActiveSection('schema') // Switch to schema tab to show the error
|
||||
setShowDeleteConfirm(false) // Close the confirmation dialog
|
||||
setActiveSection('schema')
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,7 +850,6 @@ try {
|
||||
},
|
||||
]
|
||||
|
||||
// Ensure modal overlay appears above Settings modal (z-index: 9999999)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
|
||||
@@ -61,6 +61,26 @@ interface ContextMenuProps {
|
||||
* Set to true for items that can be exported (like workspaces)
|
||||
*/
|
||||
showExport?: boolean
|
||||
/**
|
||||
* Whether the export option is disabled (default: false)
|
||||
* Set to true when user lacks permissions
|
||||
*/
|
||||
disableExport?: boolean
|
||||
/**
|
||||
* Whether the rename option is disabled (default: false)
|
||||
* Set to true when user lacks permissions
|
||||
*/
|
||||
disableRename?: boolean
|
||||
/**
|
||||
* Whether the duplicate option is disabled (default: false)
|
||||
* Set to true when user lacks permissions
|
||||
*/
|
||||
disableDuplicate?: boolean
|
||||
/**
|
||||
* Whether the delete option is disabled (default: false)
|
||||
* Set to true when user lacks permissions
|
||||
*/
|
||||
disableDelete?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,6 +104,10 @@ export function ContextMenu({
|
||||
showCreate = false,
|
||||
showDuplicate = true,
|
||||
showExport = false,
|
||||
disableExport = false,
|
||||
disableRename = false,
|
||||
disableDuplicate = false,
|
||||
disableDelete = false,
|
||||
}: ContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose}>
|
||||
@@ -99,9 +123,12 @@ export function ContextMenu({
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{showRename && onRename && (
|
||||
<PopoverItem
|
||||
disabled={disableRename}
|
||||
onClick={() => {
|
||||
onRename()
|
||||
onClose()
|
||||
if (!disableRename) {
|
||||
onRename()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pencil className='h-3 w-3' />
|
||||
@@ -121,9 +148,12 @@ export function ContextMenu({
|
||||
)}
|
||||
{showDuplicate && onDuplicate && (
|
||||
<PopoverItem
|
||||
disabled={disableDuplicate}
|
||||
onClick={() => {
|
||||
onDuplicate()
|
||||
onClose()
|
||||
if (!disableDuplicate) {
|
||||
onDuplicate()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className='h-3 w-3' />
|
||||
@@ -132,9 +162,12 @@ export function ContextMenu({
|
||||
)}
|
||||
{showExport && onExport && (
|
||||
<PopoverItem
|
||||
disabled={disableExport}
|
||||
onClick={() => {
|
||||
onExport()
|
||||
onClose()
|
||||
if (!disableExport) {
|
||||
onExport()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUp className='h-3 w-3' />
|
||||
@@ -142,9 +175,12 @@ export function ContextMenu({
|
||||
</PopoverItem>
|
||||
)}
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
if (!disableDelete) {
|
||||
onDelete()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash className='h-3 w-3' />
|
||||
|
||||
@@ -445,6 +445,10 @@ export function WorkspaceHeader({
|
||||
showRename={true}
|
||||
showDuplicate={true}
|
||||
showExport={true}
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableExport={!userPermissions.canAdmin}
|
||||
disableDelete={!userPermissions.canAdmin}
|
||||
/>
|
||||
|
||||
{/* Invite Modal */}
|
||||
|
||||
@@ -511,11 +511,17 @@ export function WorkspaceSelector({
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleExportWorkspace()
|
||||
if (userPermissions.canAdmin) {
|
||||
handleExportWorkspace()
|
||||
}
|
||||
}}
|
||||
disabled={isExporting}
|
||||
disabled={isExporting || !userPermissions.canAdmin}
|
||||
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
|
||||
title='Export workspace'
|
||||
title={
|
||||
!userPermissions.canAdmin
|
||||
? 'Admin permission required to export workspace'
|
||||
: 'Export workspace'
|
||||
}
|
||||
>
|
||||
<Download className='!h-3.5 !w-3.5' />
|
||||
</Button>
|
||||
|
||||
@@ -4330,7 +4330,7 @@ export function PylonIcon(props: SVGProps<SVGSVGElement>) {
|
||||
viewBox='0 0 26 26'
|
||||
fill='none'
|
||||
>
|
||||
<g clip-path='url(#clip0_6559_17753)'>
|
||||
<g clipPath='url(#clip0_6559_17753)'>
|
||||
<path
|
||||
d='M21.3437 4.1562C18.9827 1.79763 15.8424 0.5 12.5015 0.5C9.16056 0.5 6.02027 1.79763 3.66091 4.15455C1.29989 6.51147 0 9.64465 0 12.9798C0 16.3149 1.29989 19.448 3.66091 21.805C6.02193 24.1619 9.16222 25.4612 12.5031 25.4612C15.844 25.4612 18.9843 24.1635 21.3454 21.8066C23.7064 19.4497 25.0063 16.3165 25.0063 12.9814C25.0063 9.6463 23.7064 6.51312 21.3454 4.1562H21.3437ZM22.3949 12.9814C22.3949 17.927 18.7074 22.1227 13.8063 22.7699V3.1896C18.7074 3.83676 22.3949 8.0342 22.3949 12.9798V12.9814ZM4.8265 6.75643C6.43312 4.7835 8.68803 3.52063 11.1983 3.1896V6.75643H4.8265ZM11.1983 9.36162V11.6904H2.69428C2.79874 10.8926 3.00267 10.1097 3.2978 9.36162H11.1983ZM11.1983 14.2939V16.6227H3.30775C3.00931 15.8746 2.80371 15.0917 2.6976 14.2939H11.1983ZM11.1983 19.2279V22.7699C8.70129 22.4405 6.45302 21.1859 4.84805 19.2279H11.1983Z'
|
||||
fill='#5B0EFF'
|
||||
|
||||
Reference in New Issue
Block a user