mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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:
396
apps/sim/app/api/chat/edit/[id]/route.test.ts
Normal file
396
apps/sim/app/api/chat/edit/[id]/route.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user