fix(chat-deploy): fixed permissions to match the workspace permissions, admins can deploy & edit & delete (#753)

* fix(chat-deploy): fixed permissions to match the workspace permissions, admins can deploy & edit & delete

* fixed hanging chat deploy modal

* remove unnecessary fallback
This commit is contained in:
Waleed Latif
2025-07-22 18:21:33 -07:00
committed by GitHub
parent e392ca43aa
commit a7c8f5dfe9
7 changed files with 713 additions and 198 deletions

View File

@@ -0,0 +1,396 @@
import { NextRequest } from 'next/server'
/**
* Tests for chat edit API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('Chat Edit API Route', () => {
const mockSelect = vi.fn()
const mockFrom = vi.fn()
const mockWhere = vi.fn()
const mockLimit = vi.fn()
const mockUpdate = vi.fn()
const mockSet = vi.fn()
const mockDelete = vi.fn()
const mockCreateSuccessResponse = vi.fn()
const mockCreateErrorResponse = vi.fn()
const mockEncryptSecret = vi.fn()
const mockCheckChatAccess = vi.fn()
beforeEach(() => {
vi.resetModules()
mockSelect.mockReturnValue({ from: mockFrom })
mockFrom.mockReturnValue({ where: mockWhere })
mockWhere.mockReturnValue({ limit: mockLimit })
mockUpdate.mockReturnValue({ set: mockSet })
mockSet.mockReturnValue({ where: mockWhere })
mockDelete.mockReturnValue({ where: mockWhere })
vi.doMock('@/db', () => ({
db: {
select: mockSelect,
update: mockUpdate,
delete: mockDelete,
},
}))
vi.doMock('@/db/schema', () => ({
chat: { id: 'id', subdomain: 'subdomain', userId: 'userId' },
}))
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}))
vi.doMock('@/app/api/workflows/utils', () => ({
createSuccessResponse: mockCreateSuccessResponse.mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
createErrorResponse: mockCreateErrorResponse.mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}),
}))
vi.doMock('@/lib/utils', () => ({
encryptSecret: mockEncryptSecret.mockResolvedValue({ encrypted: 'encrypted-password' }),
}))
vi.doMock('@/lib/urls/utils', () => ({
getBaseDomain: vi.fn().mockReturnValue('localhost:3000'),
}))
vi.doMock('@/lib/environment', () => ({
isDev: true,
}))
vi.doMock('@/app/api/chat/utils', () => ({
checkChatAccess: mockCheckChatAccess,
}))
})
afterEach(() => {
vi.clearAllMocks()
})
describe('GET', () => {
it('should return 401 when user is not authenticated', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
const { GET } = await import('./route')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return 404 when chat not found or access denied', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
const { GET } = await import('./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)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should return chat details when user has access', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
title: 'Test Chat',
description: 'A test chat',
password: 'encrypted-password',
customizations: { primaryColor: '#000000' },
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
const { GET } = await import('./route')
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
id: 'chat-123',
subdomain: 'test-chat',
title: 'Test Chat',
description: 'A test chat',
customizations: { primaryColor: '#000000' },
chatUrl: 'http://test-chat.localhost:3000',
hasPassword: true,
})
})
})
describe('PATCH', () => {
it('should return 401 when user is not authenticated', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const { PATCH } = await import('./route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return 404 when chat not found or access denied', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat' }),
})
const { PATCH } = await import('./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)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should update chat when user has access', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
title: 'Test Chat',
authType: 'public',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }),
})
const { PATCH } = await import('./route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockUpdate).toHaveBeenCalled()
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
id: 'chat-123',
chatUrl: 'http://test-chat.localhost:3000',
message: 'Chat deployment updated successfully',
})
})
it('should handle subdomain conflicts', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
title: 'Test Chat',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
// Mock subdomain conflict
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', subdomain: 'new-subdomain' }])
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'PATCH',
body: JSON.stringify({ subdomain: 'new-subdomain' }),
})
const { PATCH } = await import('./route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(400)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
})
it('should validate password requirement for password auth', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
title: 'Test Chat',
authType: 'public',
password: null,
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'PATCH',
body: JSON.stringify({ authType: 'password' }), // No password provided
})
const { PATCH } = await import('./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
)
})
it('should allow access when user has workspace admin permission', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'admin-user-id' },
}),
}))
const mockChat = {
id: 'chat-123',
subdomain: 'test-chat',
title: 'Test Chat',
authType: 'public',
}
// User doesn't own chat but has workspace admin access
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'PATCH',
body: JSON.stringify({ title: 'Admin Updated Chat' }),
})
const { PATCH } = await import('./route')
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
})
})
describe('DELETE', () => {
it('should return 401 when user is not authenticated', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue(null),
}))
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('./route')
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(401)
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
})
it('should return 404 when chat not found or access denied', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('./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)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'user-id')
})
it('should delete chat when user has access', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('./route')
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockDelete).toHaveBeenCalled()
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
message: 'Chat deployment deleted successfully',
})
})
it('should allow deletion when user has workspace admin permission', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'admin-user-id' },
}),
}))
// User doesn't own chat but has workspace admin access
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
method: 'DELETE',
})
const { DELETE } = await import('./route')
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
expect(response.status).toBe(200)
expect(mockCheckChatAccess).toHaveBeenCalledWith('chat-123', 'admin-user-id')
expect(mockDelete).toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
@@ -6,6 +6,7 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { getBaseDomain } from '@/lib/urls/utils' import { getBaseDomain } from '@/lib/urls/utils'
import { encryptSecret } from '@/lib/utils' import { encryptSecret } from '@/lib/utils'
import { checkChatAccess } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db' import { db } from '@/db'
import { chat } from '@/db/schema' import { chat } from '@/db/schema'
@@ -57,23 +58,19 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
return createErrorResponse('Unauthorized', 401) return createErrorResponse('Unauthorized', 401)
} }
// Get the specific chat deployment // Check if user has access to view this chat
const chatInstance = await db const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id)
.select()
.from(chat)
.where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id)))
.limit(1)
if (chatInstance.length === 0) { if (!hasAccess || !chatRecord) {
return createErrorResponse('Chat not found or access denied', 404) return createErrorResponse('Chat not found or access denied', 404)
} }
// Create a new result object without the password // Create a new result object without the password
const { password, ...safeData } = chatInstance[0] const { password, ...safeData } = chatRecord
const chatUrl = isDev const baseDomain = getBaseDomain()
? `http://${chatInstance[0].subdomain}.${getBaseDomain()}` const protocol = isDev ? 'http' : 'https'
: `https://${chatInstance[0].subdomain}.simstudio.ai` const chatUrl = `${protocol}://${chatRecord.subdomain}.${baseDomain}`
const result = { const result = {
...safeData, ...safeData,
@@ -107,17 +104,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
try { try {
const validatedData = chatUpdateSchema.parse(body) const validatedData = chatUpdateSchema.parse(body)
// Verify the chat exists and belongs to the user // Check if user has access to edit this chat
const existingChat = await db const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
.select()
.from(chat)
.where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id)))
.limit(1)
if (existingChat.length === 0) { if (!hasAccess || !existingChatRecord) {
return createErrorResponse('Chat not found or access denied', 404) return createErrorResponse('Chat not found or access denied', 404)
} }
const existingChat = [existingChatRecord] // Keep array format for compatibility
// Extract validated data // Extract validated data
const { const {
workflowId, workflowId,
@@ -219,9 +214,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const updatedSubdomain = subdomain || existingChat[0].subdomain const updatedSubdomain = subdomain || existingChat[0].subdomain
const chatUrl = isDev const baseDomain = getBaseDomain()
? `http://${updatedSubdomain}.${getBaseDomain()}` const protocol = isDev ? 'http' : 'https'
: `https://${updatedSubdomain}.simstudio.ai` const chatUrl = `${protocol}://${updatedSubdomain}.${baseDomain}`
logger.info(`Chat "${chatId}" updated successfully`) logger.info(`Chat "${chatId}" updated successfully`)
@@ -260,14 +255,10 @@ export async function DELETE(
return createErrorResponse('Unauthorized', 401) return createErrorResponse('Unauthorized', 401)
} }
// Verify the chat exists and belongs to the user // Check if user has access to delete this chat
const existingChat = await db const { hasAccess } = await checkChatAccess(chatId, session.user.id)
.select()
.from(chat)
.where(and(eq(chat.id, chatId), eq(chat.userId, session.user.id)))
.limit(1)
if (existingChat.length === 0) { if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404) return createErrorResponse('Chat not found or access denied', 404)
} }

View File

@@ -18,6 +18,7 @@ describe('Chat API Route', () => {
const mockCreateSuccessResponse = vi.fn() const mockCreateSuccessResponse = vi.fn()
const mockCreateErrorResponse = vi.fn() const mockCreateErrorResponse = vi.fn()
const mockEncryptSecret = vi.fn() const mockEncryptSecret = vi.fn()
const mockCheckWorkflowAccessForChatCreation = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.resetModules() vi.resetModules()
@@ -71,6 +72,10 @@ describe('Chat API Route', () => {
vi.doMock('uuid', () => ({ vi.doMock('uuid', () => ({
v4: vi.fn().mockReturnValue('test-uuid'), v4: vi.fn().mockReturnValue('test-uuid'),
})) }))
vi.doMock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
}) })
afterEach(() => { afterEach(() => {
@@ -194,7 +199,7 @@ describe('Chat API Route', () => {
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400) expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
}) })
it('should reject if workflow not found or not owned by user', async () => { it('should reject if workflow not found', async () => {
vi.doMock('@/lib/auth', () => ({ vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({ getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' }, user: { id: 'user-id' },
@@ -212,7 +217,7 @@ describe('Chat API Route', () => {
} }
mockLimit.mockResolvedValueOnce([]) // Subdomain is available mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Workflow not found mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat', { const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST', method: 'POST',
@@ -228,6 +233,158 @@ describe('Chat API Route', () => {
) )
}) })
it('should allow chat deployment when user owns workflow directly', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
vi.doMock('@/lib/env', () => ({
env: {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
}))
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should allow chat deployment when user has workspace admin permission', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
vi.doMock('@/lib/env', () => ({
env: {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
}))
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
})
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should reject when workflow is in workspace but user lacks admin permission', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: false,
})
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(404)
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
'Workflow not found or access denied',
404
)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should handle workspace permission check errors gracefully', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockCheckWorkflowAccessForChatCreation.mockRejectedValue(new Error('Permission check failed'))
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(500)
expect(mockCheckWorkflowAccessForChatCreation).toHaveBeenCalledWith('workflow-123', 'user-id')
})
it('should reject if workflow is not deployed', async () => { it('should reject if workflow is not deployed', async () => {
vi.doMock('@/lib/auth', () => ({ vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({ getSession: vi.fn().mockResolvedValue({
@@ -246,7 +403,10 @@ describe('Chat API Route', () => {
} }
mockLimit.mockResolvedValueOnce([]) // Subdomain is available mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([{ isDeployed: false }]) // Workflow exists but not deployed mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
hasAccess: true,
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
})
const req = new NextRequest('http://localhost:3000/api/chat', { const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST', method: 'POST',
@@ -261,57 +421,5 @@ describe('Chat API Route', () => {
400 400
) )
}) })
it('should successfully create a chat deployment', async () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
}),
}))
vi.doMock('@/lib/env', () => ({
env: {
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
}))
vi.stubGlobal('process', {
...process,
env: {
...process.env,
NODE_ENV: 'development',
NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
},
})
const validData = {
workflowId: 'workflow-123',
subdomain: 'test-chat',
title: 'Test Chat',
customizations: {
primaryColor: '#000000',
welcomeMessage: 'Hello',
},
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([{ isDeployed: true }]) // Workflow exists and is deployed
mockReturning.mockResolvedValue([{ id: 'test-uuid' }])
const req = new NextRequest('http://localhost:3000/api/chat', {
method: 'POST',
body: JSON.stringify(validData),
})
const { POST } = await import('./route')
const response = await POST(req)
expect(response.status).toBe(200)
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
id: 'test-uuid',
chatUrl: 'http://test-chat.localhost:3000',
message: 'Chat deployment created successfully',
})
})
}) })
}) })

View File

@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod' import { z } from 'zod'
@@ -7,9 +7,10 @@ import { env } from '@/lib/env'
import { isDev } from '@/lib/environment' import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { encryptSecret } from '@/lib/utils' import { encryptSecret } from '@/lib/utils'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db' import { db } from '@/db'
import { chat, workflow } from '@/db/schema' import { chat } from '@/db/schema'
const logger = createLogger('ChatAPI') const logger = createLogger('ChatAPI')
@@ -107,19 +108,18 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Subdomain already in use', 400) return createErrorResponse('Subdomain already in use', 400)
} }
// Verify the workflow exists and belongs to the user // Check if user has permission to create chat for this workflow
const workflowExists = await db const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
.select() workflowId,
.from(workflow) session.user.id
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, session.user.id))) )
.limit(1)
if (workflowExists.length === 0) { if (!hasAccess || !workflowRecord) {
return createErrorResponse('Workflow not found or access denied', 404) return createErrorResponse('Workflow not found or access denied', 404)
} }
// Verify the workflow is deployed (required for chat deployment) // Verify the workflow is deployed (required for chat deployment)
if (!workflowExists[0].isDeployed) { if (!workflowRecord.isDeployed) {
return createErrorResponse('Workflow must be deployed before creating a chat', 400) return createErrorResponse('Workflow must be deployed before creating a chat', 400)
} }
@@ -169,23 +169,24 @@ export async function POST(request: NextRequest) {
}) })
// Return successful response with chat URL // Return successful response with chat URL
// Check if we're in development or production // Generate chat URL based on the configured base URL
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
let chatUrl: string let chatUrl: string
if (isDev) { try {
try { const url = new URL(baseUrl)
const url = new URL(baseUrl) chatUrl = `${url.protocol}//${subdomain}.${url.host}`
chatUrl = `${url.protocol}//${subdomain}.${url.host}` } catch (error) {
} catch (error) { logger.warn('Failed to parse baseUrl, falling back to defaults:', {
logger.warn('Failed to parse baseUrl, falling back to localhost:', { baseUrl,
baseUrl, error: error instanceof Error ? error.message : 'Unknown error',
error: error instanceof Error ? error.message : 'Unknown error', })
}) // Fallback based on environment
if (isDev) {
chatUrl = `http://${subdomain}.localhost:3000` chatUrl = `http://${subdomain}.localhost:3000`
} else {
chatUrl = `https://${subdomain}.simstudio.ai`
} }
} else {
chatUrl = `https://${subdomain}.simstudio.ai`
} }
logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`) logger.info(`Chat "${title}" deployed successfully at ${chatUrl}`)

View File

@@ -7,7 +7,6 @@ import type { NextResponse } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { env } from '@/lib/env' import { env } from '@/lib/env'
// Mock all the problematic imports that cause timeouts
vi.mock('@/db', () => ({ vi.mock('@/db', () => ({
db: { db: {
select: vi.fn(), select: vi.fn(),

View File

@@ -5,7 +5,9 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session' import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans' import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { processStreamingBlockLogs } from '@/lib/tokenization' import { processStreamingBlockLogs } from '@/lib/tokenization'
import { getEmailDomain } from '@/lib/urls/utils'
import { decryptSecret } from '@/lib/utils' import { decryptSecret } from '@/lib/utils'
import { db } from '@/db' import { db } from '@/db'
import { chat, environment as envTable, userStats, workflow } from '@/db/schema' import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
@@ -21,6 +23,80 @@ declare global {
const logger = createLogger('ChatAuthUtils') const logger = createLogger('ChatAuthUtils')
/**
* Check if user has permission to create a chat for a specific workflow
* Either the user owns the workflow directly OR has admin permission for the workflow's workspace
*/
export async function checkWorkflowAccessForChatCreation(
workflowId: string,
userId: string
): Promise<{ hasAccess: boolean; workflow?: any }> {
// Get workflow data
const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1)
if (workflowData.length === 0) {
return { hasAccess: false }
}
const workflowRecord = workflowData[0]
// Case 1: User owns the workflow directly
if (workflowRecord.userId === userId) {
return { hasAccess: true, workflow: workflowRecord }
}
// Case 2: Workflow belongs to a workspace and user has admin permission
if (workflowRecord.workspaceId) {
const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId)
if (hasAdmin) {
return { hasAccess: true, workflow: workflowRecord }
}
}
return { hasAccess: false }
}
/**
* Check if user has access to view/edit/delete a specific chat
* Either the user owns the chat directly OR has admin permission for the workflow's workspace
*/
export async function checkChatAccess(
chatId: string,
userId: string
): Promise<{ hasAccess: boolean; chat?: any }> {
// Get chat with workflow information
const chatData = await db
.select({
chat: chat,
workflowWorkspaceId: workflow.workspaceId,
})
.from(chat)
.innerJoin(workflow, eq(chat.workflowId, workflow.id))
.where(eq(chat.id, chatId))
.limit(1)
if (chatData.length === 0) {
return { hasAccess: false }
}
const { chat: chatRecord, workflowWorkspaceId } = chatData[0]
// Case 1: User owns the chat directly
if (chatRecord.userId === userId) {
return { hasAccess: true, chat: chatRecord }
}
// Case 2: Chat's workflow belongs to a workspace and user has admin permission
if (workflowWorkspaceId) {
const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId)
if (hasAdmin) {
return { hasAccess: true, chat: chatRecord }
}
}
return { hasAccess: false }
}
export const encryptAuthToken = (subdomainId: string, type: string): string => { export const encryptAuthToken = (subdomainId: string, type: string): string => {
return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64') return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64')
} }
@@ -66,7 +142,7 @@ export const setChatAuthCookie = (
sameSite: 'lax', sameSite: 'lax',
path: '/', path: '/',
// Using subdomain for the domain in production // Using subdomain for the domain in production
domain: isDev ? undefined : '.simstudio.ai', domain: isDev ? undefined : `.${getEmailDomain()}`,
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24, // 24 hours
}) })
} }

View File

@@ -30,7 +30,6 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger' import { createLogger } from '@/lib/logs/console-logger'
import { getBaseDomain } from '@/lib/urls/utils' import { getBaseDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -54,11 +53,10 @@ interface ChatDeployProps {
type AuthType = 'public' | 'password' | 'email' type AuthType = 'public' | 'password' | 'email'
const getDomainSuffix = (() => { const getDomainSuffix = (() => {
const suffix = isDev ? `.${getBaseDomain()}` : '.simstudio.ai' const suffix = `.${getBaseDomain()}`
return () => suffix return () => suffix
})() })()
// Define Zod schema for API request validation
const chatSchema = z.object({ const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'), workflowId: z.string().min(1, 'Workflow ID is required'),
subdomain: z subdomain: z
@@ -124,10 +122,6 @@ export function ChatDeploy({
selectedOutputIds: string[] selectedOutputIds: string[]
} | null>(null) } | null>(null)
// State to track if any changes have been made
const [hasChanges, setHasChanges] = useState(false)
// Confirmation dialogs
const [showEditConfirmation, setShowEditConfirmation] = useState(false) const [showEditConfirmation, setShowEditConfirmation] = useState(false)
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false) const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
@@ -184,53 +178,6 @@ export function ChatDeploy({
} }
}, [workflowId]) }, [workflowId])
// Check for changes when form values update
useEffect(() => {
if (originalValues && existingChat) {
const currentAuthTypeChanged = authType !== originalValues.authType
const subdomainChanged = subdomain !== originalValues.subdomain
const titleChanged = title !== originalValues.title
const descriptionChanged = description !== originalValues.description
const outputBlockChanged = selectedOutputBlocks.some(
(blockId) => !originalValues.selectedOutputIds.includes(blockId)
)
const welcomeMessageChanged =
welcomeMessage !==
(existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?')
// Check if emails have changed
const emailsChanged =
emails.length !== originalValues.emails.length ||
emails.some((email) => !originalValues.emails.includes(email))
// Check if password has changed - any value in password field means change
const passwordChanged = password.length > 0
// Determine if any changes have been made
const changed =
subdomainChanged ||
titleChanged ||
descriptionChanged ||
currentAuthTypeChanged ||
emailsChanged ||
passwordChanged ||
outputBlockChanged ||
welcomeMessageChanged
setHasChanges(changed)
}
}, [
subdomain,
title,
description,
authType,
emails,
password,
selectedOutputBlocks,
welcomeMessage,
originalValues,
])
// Fetch existing chat data for this workflow // Fetch existing chat data for this workflow
const fetchExistingChat = async () => { const fetchExistingChat = async () => {
try { try {
@@ -310,7 +257,6 @@ export function ChatDeploy({
} finally { } finally {
setIsLoading(false) setIsLoading(false)
setDataFetched(true) setDataFetched(true)
setHasChanges(false) // Reset changes detection after loading
} }
} }
@@ -490,6 +436,8 @@ export function ChatDeploy({
(!originalValues || subdomain !== originalValues.subdomain) (!originalValues || subdomain !== originalValues.subdomain)
) { ) {
setIsCheckingSubdomain(true) setIsCheckingSubdomain(true)
setSubdomainError('')
try { try {
const response = await fetch( const response = await fetch(
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}` `/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}`
@@ -497,11 +445,15 @@ export function ChatDeploy({
const data = await response.json() const data = await response.json()
if (!response.ok || !data.available) { if (!response.ok || !data.available) {
setSubdomainError('This subdomain is already in use') const errorMsg = data.error || 'This subdomain is already in use'
setSubdomainError(errorMsg)
setChatSubmitting(false) setChatSubmitting(false)
setIsCheckingSubdomain(false) setIsCheckingSubdomain(false)
logger.warn('Subdomain validation failed:', errorMsg)
return return
} }
setSubdomainError('')
} catch (error) { } catch (error) {
logger.error('Error checking subdomain availability:', error) logger.error('Error checking subdomain availability:', error)
setSubdomainError('Error checking subdomain availability') setSubdomainError('Error checking subdomain availability')
@@ -512,15 +464,16 @@ export function ChatDeploy({
setIsCheckingSubdomain(false) setIsCheckingSubdomain(false)
} }
// Verify output selection if it's set if (subdomainError) {
if (selectedOutputBlocks.length === 0) { logger.warn('Blocking submission due to subdomain error:', subdomainError)
logger.error('No output blocks selected')
setErrorMessage('Please select at least one output block')
setChatSubmitting(false) setChatSubmitting(false)
return return
} }
if (subdomainError) { // Verify output selection if it's set
if (selectedOutputBlocks.length === 0) {
logger.error('No output blocks selected')
setErrorMessage('Please select at least one output block')
setChatSubmitting(false) setChatSubmitting(false)
return return
} }
@@ -722,6 +675,11 @@ export function ChatDeploy({
const result = await response.json() const result = await response.json()
if (!response.ok) { if (!response.ok) {
if (result.error === 'Subdomain already in use') {
setSubdomainError(result.error)
setChatSubmitting(false)
return
}
throw new Error(result.error || `Failed to ${existingChat ? 'update' : 'deploy'} chat`) throw new Error(result.error || `Failed to ${existingChat ? 'update' : 'deploy'} chat`)
} }
@@ -743,7 +701,14 @@ export function ChatDeploy({
} }
} catch (error: any) { } catch (error: any) {
logger.error(`Failed to ${existingChat ? 'update' : 'deploy'} chat:`, error) logger.error(`Failed to ${existingChat ? 'update' : 'deploy'} chat:`, error)
setErrorMessage(error.message || 'An unexpected error occurred')
const errorMessage = error.message || 'An unexpected error occurred'
if (errorMessage.includes('Subdomain already in use') || errorMessage.includes('subdomain')) {
setSubdomainError(errorMessage)
} else {
setErrorMessage(errorMessage)
}
logger.error(`Failed to deploy chat: ${error.message}`, workflowId) logger.error(`Failed to deploy chat: ${error.message}`, workflowId)
} finally { } finally {
setChatSubmitting(false) setChatSubmitting(false)
@@ -751,26 +716,6 @@ export function ChatDeploy({
} }
} }
// Determine button label based on state
const _getSubmitButtonLabel = () => {
return existingChat ? 'Update Chat' : 'Deploy Chat'
}
// Check if form submission is possible
const _isFormSubmitDisabled = () => {
return (
chatSubmitting ||
isDeleting ||
!subdomain ||
!title ||
!!subdomainError ||
isCheckingSubdomain ||
(authType === 'password' && !password && !existingChat) ||
(authType === 'email' && emails.length === 0) ||
(existingChat && !hasChanges)
)
}
if (isLoading) { if (isLoading) {
return ( return (
<div className='space-y-4 py-3'> <div className='space-y-4 py-3'>
@@ -827,12 +772,13 @@ export function ChatDeploy({
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000') const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
domainSuffix = `.${baseHost}:${port}` domainSuffix = `.${baseHost}:${port}`
} else { } else {
domainSuffix = '.simstudio.ai' domainSuffix = `.${getBaseDomain()}`
} }
const baseDomainForSplit = getBaseDomain()
const subdomainPart = isDevelopmentUrl const subdomainPart = isDevelopmentUrl
? hostname.split('.')[0] ? hostname.split('.')[0]
: hostname.split('.simstudio.ai')[0] : hostname.split(`.${baseDomainForSplit}`)[0]
// Success view - simplified with no buttons // Success view - simplified with no buttons
return ( return (
@@ -996,11 +942,6 @@ export function ChatDeploy({
onOutputSelect={(values) => { onOutputSelect={(values) => {
logger.info(`Output block selection changed to: ${values}`) logger.info(`Output block selection changed to: ${values}`)
setSelectedOutputBlocks(values) setSelectedOutputBlocks(values)
// Mark as changed to enable update button
if (existingChat) {
setHasChanges(true)
}
}} }}
placeholder='Select which block outputs to use' placeholder='Select which block outputs to use'
disabled={isDeploying} disabled={isDeploying}
@@ -1306,7 +1247,10 @@ export function ChatDeploy({
<AlertDialogTitle>Delete Chat?</AlertDialogTitle> <AlertDialogTitle>Delete Chat?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will permanently delete your chat deployment at{' '} This will permanently delete your chat deployment at{' '}
<span className='font-mono text-destructive'>{subdomain}.simstudio.ai</span>. <span className='font-mono text-destructive'>
{subdomain}.{getBaseDomain()}
</span>
.
<p className='mt-2'> <p className='mt-2'>
All users will lose access immediately, and this action cannot be undone. All users will lose access immediately, and this action cannot be undone.
</p> </p>