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

View File

@@ -18,6 +18,7 @@ describe('Chat API Route', () => {
const mockCreateSuccessResponse = vi.fn()
const mockCreateErrorResponse = vi.fn()
const mockEncryptSecret = vi.fn()
const mockCheckWorkflowAccessForChatCreation = vi.fn()
beforeEach(() => {
vi.resetModules()
@@ -71,6 +72,10 @@ describe('Chat API Route', () => {
vi.doMock('uuid', () => ({
v4: vi.fn().mockReturnValue('test-uuid'),
}))
vi.doMock('@/app/api/chat/utils', () => ({
checkWorkflowAccessForChatCreation: mockCheckWorkflowAccessForChatCreation,
}))
})
afterEach(() => {
@@ -194,7 +199,7 @@ describe('Chat API Route', () => {
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', () => ({
getSession: vi.fn().mockResolvedValue({
user: { id: 'user-id' },
@@ -212,7 +217,7 @@ describe('Chat API Route', () => {
}
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
mockLimit.mockResolvedValueOnce([]) // Workflow not found
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
const req = new NextRequest('http://localhost:3000/api/chat', {
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 () => {
vi.doMock('@/lib/auth', () => ({
getSession: vi.fn().mockResolvedValue({
@@ -246,7 +403,10 @@ describe('Chat API Route', () => {
}
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', {
method: 'POST',
@@ -261,57 +421,5 @@ describe('Chat API Route', () => {
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 { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
@@ -7,9 +7,10 @@ import { env } from '@/lib/env'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { encryptSecret } from '@/lib/utils'
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
import { db } from '@/db'
import { chat, workflow } from '@/db/schema'
import { chat } from '@/db/schema'
const logger = createLogger('ChatAPI')
@@ -107,19 +108,18 @@ export async function POST(request: NextRequest) {
return createErrorResponse('Subdomain already in use', 400)
}
// Verify the workflow exists and belongs to the user
const workflowExists = await db
.select()
.from(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, session.user.id)))
.limit(1)
// Check if user has permission to create chat for this workflow
const { hasAccess, workflow: workflowRecord } = await checkWorkflowAccessForChatCreation(
workflowId,
session.user.id
)
if (workflowExists.length === 0) {
if (!hasAccess || !workflowRecord) {
return createErrorResponse('Workflow not found or access denied', 404)
}
// 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)
}
@@ -169,23 +169,24 @@ export async function POST(request: NextRequest) {
})
// 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'
let chatUrl: string
if (isDev) {
try {
const url = new URL(baseUrl)
chatUrl = `${url.protocol}//${subdomain}.${url.host}`
} catch (error) {
logger.warn('Failed to parse baseUrl, falling back to localhost:', {
baseUrl,
error: error instanceof Error ? error.message : 'Unknown error',
})
try {
const url = new URL(baseUrl)
chatUrl = `${url.protocol}//${subdomain}.${url.host}`
} catch (error) {
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
baseUrl,
error: error instanceof Error ? error.message : 'Unknown error',
})
// Fallback based on environment
if (isDev) {
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}`)

View File

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

View File

@@ -5,7 +5,9 @@ import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { EnhancedLoggingSession } from '@/lib/logs/enhanced-logging-session'
import { buildTraceSpans } from '@/lib/logs/trace-spans'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { processStreamingBlockLogs } from '@/lib/tokenization'
import { getEmailDomain } from '@/lib/urls/utils'
import { decryptSecret } from '@/lib/utils'
import { db } from '@/db'
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
@@ -21,6 +23,80 @@ declare global {
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 => {
return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64')
}
@@ -66,7 +142,7 @@ export const setChatAuthCookie = (
sameSite: 'lax',
path: '/',
// Using subdomain for the domain in production
domain: isDev ? undefined : '.simstudio.ai',
domain: isDev ? undefined : `.${getEmailDomain()}`,
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 { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { isDev } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console-logger'
import { getBaseDomain } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
@@ -54,11 +53,10 @@ interface ChatDeployProps {
type AuthType = 'public' | 'password' | 'email'
const getDomainSuffix = (() => {
const suffix = isDev ? `.${getBaseDomain()}` : '.simstudio.ai'
const suffix = `.${getBaseDomain()}`
return () => suffix
})()
// Define Zod schema for API request validation
const chatSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
subdomain: z
@@ -124,10 +122,6 @@ export function ChatDeploy({
selectedOutputIds: string[]
} | null>(null)
// State to track if any changes have been made
const [hasChanges, setHasChanges] = useState(false)
// Confirmation dialogs
const [showEditConfirmation, setShowEditConfirmation] = useState(false)
const [internalShowDeleteConfirmation, setInternalShowDeleteConfirmation] = useState(false)
@@ -184,53 +178,6 @@ export function ChatDeploy({
}
}, [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
const fetchExistingChat = async () => {
try {
@@ -310,7 +257,6 @@ export function ChatDeploy({
} finally {
setIsLoading(false)
setDataFetched(true)
setHasChanges(false) // Reset changes detection after loading
}
}
@@ -490,6 +436,8 @@ export function ChatDeploy({
(!originalValues || subdomain !== originalValues.subdomain)
) {
setIsCheckingSubdomain(true)
setSubdomainError('')
try {
const response = await fetch(
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}`
@@ -497,11 +445,15 @@ export function ChatDeploy({
const data = await response.json()
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)
setIsCheckingSubdomain(false)
logger.warn('Subdomain validation failed:', errorMsg)
return
}
setSubdomainError('')
} catch (error) {
logger.error('Error checking subdomain availability:', error)
setSubdomainError('Error checking subdomain availability')
@@ -512,15 +464,16 @@ export function ChatDeploy({
setIsCheckingSubdomain(false)
}
// 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')
if (subdomainError) {
logger.warn('Blocking submission due to subdomain error:', subdomainError)
setChatSubmitting(false)
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)
return
}
@@ -722,6 +675,11 @@ export function ChatDeploy({
const result = await response.json()
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`)
}
@@ -743,7 +701,14 @@ export function ChatDeploy({
}
} catch (error: any) {
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)
} finally {
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) {
return (
<div className='space-y-4 py-3'>
@@ -827,12 +772,13 @@ export function ChatDeploy({
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
domainSuffix = `.${baseHost}:${port}`
} else {
domainSuffix = '.simstudio.ai'
domainSuffix = `.${getBaseDomain()}`
}
const baseDomainForSplit = getBaseDomain()
const subdomainPart = isDevelopmentUrl
? hostname.split('.')[0]
: hostname.split('.simstudio.ai')[0]
: hostname.split(`.${baseDomainForSplit}`)[0]
// Success view - simplified with no buttons
return (
@@ -996,11 +942,6 @@ export function ChatDeploy({
onOutputSelect={(values) => {
logger.info(`Output block selection changed to: ${values}`)
setSelectedOutputBlocks(values)
// Mark as changed to enable update button
if (existingChat) {
setHasChanges(true)
}
}}
placeholder='Select which block outputs to use'
disabled={isDeploying}
@@ -1306,7 +1247,10 @@ export function ChatDeploy({
<AlertDialogTitle>Delete Chat?</AlertDialogTitle>
<AlertDialogDescription>
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'>
All users will lose access immediately, and this action cannot be undone.
</p>