mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(chat): deployed chat no longer uses subdomains, uses sim.ai/chat/[identifier] (#1474)
* improvement(chat): deployed chat no longer uses subdomains, uses sim.ai/chat/[identifier] * ui fix * added back validate route, remove backwards compatibility for subdomain redirects * cleanup * cleanup * add chat page to conditional theme layout, and remove custom chat css * consolidate theme providers * ack pr commnets --------- Co-authored-by: waleed <waleed>
This commit is contained in:
@@ -113,13 +113,13 @@ const otpVerifySchema = z.object({
|
||||
// Send OTP endpoint
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ subdomain: string }> }
|
||||
{ params }: { params: Promise<{ identifier: string }> }
|
||||
) {
|
||||
const { subdomain } = await params
|
||||
const { identifier } = await params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
logger.debug(`[${requestId}] Processing OTP request for subdomain: ${subdomain}`)
|
||||
logger.debug(`[${requestId}] Processing OTP request for identifier: ${identifier}`)
|
||||
|
||||
// Parse request body
|
||||
let body
|
||||
@@ -136,11 +136,11 @@ export async function POST(
|
||||
title: chat.title,
|
||||
})
|
||||
.from(chat)
|
||||
.where(eq(chat.subdomain, subdomain))
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
}
|
||||
|
||||
@@ -227,13 +227,13 @@ export async function POST(
|
||||
// Verify OTP endpoint
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ subdomain: string }> }
|
||||
{ params }: { params: Promise<{ identifier: string }> }
|
||||
) {
|
||||
const { subdomain } = await params
|
||||
const { identifier } = await params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
logger.debug(`[${requestId}] Verifying OTP for subdomain: ${subdomain}`)
|
||||
logger.debug(`[${requestId}] Verifying OTP for identifier: ${identifier}`)
|
||||
|
||||
// Parse request body
|
||||
let body
|
||||
@@ -248,11 +248,11 @@ export async function PUT(
|
||||
authType: chat.authType,
|
||||
})
|
||||
.from(chat)
|
||||
.where(eq(chat.subdomain, subdomain))
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Tests for chat subdomain API route
|
||||
* Tests for chat identifier API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMockRequest } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
describe('Chat Subdomain API Route', () => {
|
||||
describe('Chat Identifier API Route', () => {
|
||||
const createMockStream = () => {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -134,11 +134,11 @@ describe('Chat Subdomain API Route', () => {
|
||||
})
|
||||
|
||||
describe('GET endpoint', () => {
|
||||
it('should return chat info for a valid subdomain', async () => {
|
||||
it('should return chat info for a valid identifier', async () => {
|
||||
const req = createMockRequest('GET')
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { GET } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { GET } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('Chat Subdomain API Route', () => {
|
||||
expect(data.customizations).toHaveProperty('welcomeMessage', 'Welcome to the test chat')
|
||||
})
|
||||
|
||||
it('should return 404 for non-existent subdomain', async () => {
|
||||
it('should return 404 for non-existent identifier', async () => {
|
||||
vi.doMock('@sim/db', () => {
|
||||
const mockLimit = vi.fn().mockReturnValue([])
|
||||
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit })
|
||||
@@ -167,9 +167,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
})
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const params = Promise.resolve({ subdomain: 'nonexistent' })
|
||||
const params = Promise.resolve({ identifier: 'nonexistent' })
|
||||
|
||||
const { GET } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { GET } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
@@ -201,9 +201,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
})
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const params = Promise.resolve({ subdomain: 'inactive-chat' })
|
||||
const params = Promise.resolve({ identifier: 'inactive-chat' })
|
||||
|
||||
const { GET } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { GET } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
@@ -222,9 +222,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
}))
|
||||
|
||||
const req = createMockRequest('GET')
|
||||
const params = Promise.resolve({ subdomain: 'password-protected-chat' })
|
||||
const params = Promise.resolve({ identifier: 'password-protected-chat' })
|
||||
|
||||
const { GET } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { GET } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await GET(req, { params })
|
||||
|
||||
@@ -243,9 +243,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
describe('POST endpoint', () => {
|
||||
it('should handle authentication requests without input', async () => {
|
||||
const req = createMockRequest('POST', { password: 'test-password' })
|
||||
const params = Promise.resolve({ subdomain: 'password-protected-chat' })
|
||||
const params = Promise.resolve({ identifier: 'password-protected-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -259,9 +259,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
|
||||
it('should return 400 for requests without input', async () => {
|
||||
const req = createMockRequest('POST', {})
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -280,9 +280,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
}))
|
||||
|
||||
const req = createMockRequest('POST', { input: 'Hello' })
|
||||
const params = Promise.resolve({ subdomain: 'protected-chat' })
|
||||
const params = Promise.resolve({ identifier: 'protected-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -343,9 +343,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', { input: 'Hello' })
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -358,9 +358,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
|
||||
it('should return streaming response for valid chat messages', async () => {
|
||||
const req = createMockRequest('POST', { input: 'Hello world', conversationId: 'conv-123' })
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -375,9 +375,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
|
||||
it('should handle streaming response body correctly', async () => {
|
||||
const req = createMockRequest('POST', { input: 'Hello world' })
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -405,9 +405,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', { input: 'Trigger error' })
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -430,9 +430,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
} as any
|
||||
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
const response = await POST(req, { params })
|
||||
|
||||
@@ -448,9 +448,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
input: 'Hello world',
|
||||
conversationId: 'test-conversation-123',
|
||||
})
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
await POST(req, { params })
|
||||
|
||||
@@ -463,9 +463,9 @@ describe('Chat Subdomain API Route', () => {
|
||||
|
||||
it('should handle missing conversationId gracefully', async () => {
|
||||
const req = createMockRequest('POST', { input: 'Hello world' })
|
||||
const params = Promise.resolve({ subdomain: 'test-chat' })
|
||||
const params = Promise.resolve({ identifier: 'test-chat' })
|
||||
|
||||
const { POST } = await import('@/app/api/chat/[subdomain]/route')
|
||||
const { POST } = await import('@/app/api/chat/[identifier]/route')
|
||||
|
||||
await POST(req, { params })
|
||||
|
||||
@@ -13,18 +13,18 @@ import {
|
||||
} from '@/app/api/chat/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatSubdomainAPI')
|
||||
const logger = createLogger('ChatIdentifierAPI')
|
||||
|
||||
// This endpoint handles chat interactions via the subdomain
|
||||
// This endpoint handles chat interactions via the identifier
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ subdomain: string }> }
|
||||
{ params }: { params: Promise<{ identifier: string }> }
|
||||
) {
|
||||
const { subdomain } = await params
|
||||
const { identifier } = await params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
logger.debug(`[${requestId}] Processing chat request for subdomain: ${subdomain}`)
|
||||
logger.debug(`[${requestId}] Processing chat request for identifier: ${identifier}`)
|
||||
|
||||
// Parse the request body once
|
||||
let parsedBody
|
||||
@@ -34,7 +34,7 @@ export async function POST(
|
||||
return addCorsHeaders(createErrorResponse('Invalid request body', 400), request)
|
||||
}
|
||||
|
||||
// Find the chat deployment for this subdomain
|
||||
// Find the chat deployment for this identifier
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
@@ -47,11 +47,11 @@ export async function POST(
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(eq(chat.subdomain, subdomain))
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function POST(
|
||||
|
||||
// Check if the chat is active
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${subdomain}`)
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request)
|
||||
}
|
||||
|
||||
@@ -139,15 +139,15 @@ export async function POST(
|
||||
// This endpoint returns information about the chat
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ subdomain: string }> }
|
||||
{ params }: { params: Promise<{ identifier: string }> }
|
||||
) {
|
||||
const { subdomain } = await params
|
||||
const { identifier } = await params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
logger.debug(`[${requestId}] Fetching chat info for subdomain: ${subdomain}`)
|
||||
logger.debug(`[${requestId}] Fetching chat info for identifier: ${identifier}`)
|
||||
|
||||
// Find the chat deployment for this subdomain
|
||||
// Find the chat deployment for this identifier
|
||||
const deploymentResult = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
@@ -162,11 +162,11 @@ export async function GET(
|
||||
outputConfigs: chat.outputConfigs,
|
||||
})
|
||||
.from(chat)
|
||||
.where(eq(chat.subdomain, subdomain))
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
|
||||
if (deploymentResult.length === 0) {
|
||||
logger.warn(`[${requestId}] Chat not found for subdomain: ${subdomain}`)
|
||||
logger.warn(`[${requestId}] Chat not found for identifier: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ export async function GET(
|
||||
|
||||
// Check if the chat is active
|
||||
if (!deployment.isActive) {
|
||||
logger.warn(`[${requestId}] Chat is not active: ${subdomain}`)
|
||||
logger.warn(`[${requestId}] Chat is not active: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('This chat is currently unavailable', 403), request)
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ export async function GET(
|
||||
const authResult = await validateChatAuth(requestId, deployment, request)
|
||||
if (!authResult.authorized) {
|
||||
logger.info(
|
||||
`[${requestId}] Authentication required for chat: ${subdomain}, type: ${deployment.authType}`
|
||||
`[${requestId}] Authentication required for chat: ${identifier}, type: ${deployment.authType}`
|
||||
)
|
||||
return addCorsHeaders(
|
||||
createErrorResponse(authResult.error || 'Authentication required', 401),
|
||||
@@ -39,7 +39,7 @@ describe('Chat Edit API Route', () => {
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
chat: { id: 'id', subdomain: 'subdomain', userId: 'userId' },
|
||||
chat: { id: 'id', identifier: 'identifier', userId: 'userId' },
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
@@ -93,8 +93,8 @@ describe('Chat Edit API Route', () => {
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
|
||||
const { GET } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
|
||||
const { GET } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -110,8 +110,8 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
|
||||
const { GET } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
|
||||
const { GET } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
@@ -128,7 +128,7 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
description: 'A test chat',
|
||||
password: 'encrypted-password',
|
||||
@@ -137,18 +137,18 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123')
|
||||
const { GET } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123')
|
||||
const { GET } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
description: 'A test chat',
|
||||
customizations: { primaryColor: '#000000' },
|
||||
chatUrl: 'http://test-chat.localhost:3000',
|
||||
chatUrl: 'http://localhost:3000/chat/test-chat',
|
||||
hasPassword: true,
|
||||
})
|
||||
})
|
||||
@@ -160,11 +160,11 @@ describe('Chat Edit API Route', () => {
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Updated Chat' }),
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -180,11 +180,11 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Updated Chat' }),
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
@@ -201,30 +201,30 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: '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', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Updated Chat', description: 'Updated description' }),
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/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',
|
||||
chatUrl: 'http://localhost:3000/chat/test-chat',
|
||||
message: 'Chat deployment updated successfully',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle subdomain conflicts', async () => {
|
||||
it('should handle identifier conflicts', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
@@ -233,23 +233,23 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
// Mock subdomain conflict
|
||||
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', subdomain: 'new-subdomain' }])
|
||||
// Mock identifier conflict
|
||||
mockLimit.mockResolvedValueOnce([{ id: 'other-chat-id', identifier: 'new-identifier' }])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ subdomain: 'new-subdomain' }),
|
||||
body: JSON.stringify({ identifier: 'new-identifier' }),
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/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)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400)
|
||||
})
|
||||
|
||||
it('should validate password requirement for password auth', async () => {
|
||||
@@ -261,7 +261,7 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
password: null,
|
||||
@@ -269,11 +269,11 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ authType: 'password' }), // No password provided
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
@@ -292,7 +292,7 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
const mockChat = {
|
||||
id: 'chat-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
authType: 'public',
|
||||
}
|
||||
@@ -300,11 +300,11 @@ describe('Chat Edit API Route', () => {
|
||||
// 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', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ title: 'Admin Updated Chat' }),
|
||||
})
|
||||
const { PATCH } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { PATCH } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await PATCH(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -318,10 +318,10 @@ describe('Chat Edit API Route', () => {
|
||||
getSession: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -337,10 +337,10 @@ describe('Chat Edit API Route', () => {
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
@@ -358,10 +358,10 @@ describe('Chat Edit API Route', () => {
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -382,10 +382,10 @@ describe('Chat Edit API Route', () => {
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/edit/chat-123', {
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const { DELETE } = await import('@/app/api/chat/edit/[id]/route')
|
||||
const { DELETE } = await import('@/app/api/chat/manage/[id]/route')
|
||||
const response = await DELETE(req, { params: Promise.resolve({ id: 'chat-123' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -15,13 +15,12 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('ChatDetailAPI')
|
||||
|
||||
// Schema for updating an existing chat
|
||||
const chatUpdateSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required').optional(),
|
||||
subdomain: z
|
||||
identifier: z
|
||||
.string()
|
||||
.min(1, 'Subdomain is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens')
|
||||
.min(1, 'Identifier is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
title: z.string().min(1, 'Title is required').optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -59,19 +58,17 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
// Check if user has access to view this chat
|
||||
const { hasAccess, chat: chatRecord } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (!hasAccess || !chatRecord) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
// Create a new result object without the password
|
||||
const { password, ...safeData } = chatRecord
|
||||
|
||||
const baseDomain = getEmailDomain()
|
||||
const protocol = isDev ? 'http' : 'https'
|
||||
const chatUrl = `${protocol}://${chatRecord.subdomain}.${baseDomain}`
|
||||
const chatUrl = `${protocol}://${baseDomain}/chat/${chatRecord.identifier}`
|
||||
|
||||
const result = {
|
||||
...safeData,
|
||||
@@ -105,19 +102,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
try {
|
||||
const validatedData = chatUpdateSchema.parse(body)
|
||||
|
||||
// Check if user has access to edit this chat
|
||||
const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (!hasAccess || !existingChatRecord) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
const existingChat = [existingChatRecord] // Keep array format for compatibility
|
||||
const existingChat = [existingChatRecord]
|
||||
|
||||
// Extract validated data
|
||||
const {
|
||||
workflowId,
|
||||
subdomain,
|
||||
identifier,
|
||||
title,
|
||||
description,
|
||||
customizations,
|
||||
@@ -127,77 +122,62 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
outputConfigs,
|
||||
} = validatedData
|
||||
|
||||
// Check if subdomain is changing and if it's available
|
||||
if (subdomain && subdomain !== existingChat[0].subdomain) {
|
||||
const existingSubdomain = await db
|
||||
if (identifier && identifier !== existingChat[0].identifier) {
|
||||
const existingIdentifier = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(eq(chat.subdomain, subdomain))
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
|
||||
if (existingSubdomain.length > 0 && existingSubdomain[0].id !== chatId) {
|
||||
return createErrorResponse('Subdomain already in use', 400)
|
||||
if (existingIdentifier.length > 0 && existingIdentifier[0].id !== chatId) {
|
||||
return createErrorResponse('Identifier already in use', 400)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle password update
|
||||
let encryptedPassword
|
||||
|
||||
// Only encrypt and update password if one is provided
|
||||
if (password) {
|
||||
const { encrypted } = await encryptSecret(password)
|
||||
encryptedPassword = encrypted
|
||||
logger.info('Password provided, will be updated')
|
||||
} else if (authType === 'password' && !password) {
|
||||
// If switching to password auth but no password provided,
|
||||
// check if there's an existing password
|
||||
if (existingChat[0].authType !== 'password' || !existingChat[0].password) {
|
||||
// If there's no existing password to reuse, return an error
|
||||
return createErrorResponse('Password is required when using password protection', 400)
|
||||
}
|
||||
logger.info('Keeping existing password')
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// Only include fields that are provided
|
||||
if (workflowId) updateData.workflowId = workflowId
|
||||
if (subdomain) updateData.subdomain = subdomain
|
||||
if (identifier) updateData.identifier = identifier
|
||||
if (title) updateData.title = title
|
||||
if (description !== undefined) updateData.description = description
|
||||
if (customizations) updateData.customizations = customizations
|
||||
|
||||
// Handle auth type update
|
||||
if (authType) {
|
||||
updateData.authType = authType
|
||||
|
||||
// Reset auth-specific fields when changing auth types
|
||||
if (authType === 'public') {
|
||||
updateData.password = null
|
||||
updateData.allowedEmails = []
|
||||
} else if (authType === 'password') {
|
||||
updateData.allowedEmails = []
|
||||
// Password handled separately
|
||||
} else if (authType === 'email') {
|
||||
updateData.password = null
|
||||
// Emails handled separately
|
||||
}
|
||||
}
|
||||
|
||||
// Always update password if provided (not just when changing auth type)
|
||||
if (encryptedPassword) {
|
||||
updateData.password = encryptedPassword
|
||||
}
|
||||
|
||||
// Always update allowed emails if provided
|
||||
if (allowedEmails) {
|
||||
updateData.allowedEmails = allowedEmails
|
||||
}
|
||||
|
||||
// Handle output fields
|
||||
if (outputConfigs) {
|
||||
updateData.outputConfigs = outputConfigs
|
||||
}
|
||||
@@ -210,14 +190,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
outputConfigsCount: updateData.outputConfigs ? updateData.outputConfigs.length : undefined,
|
||||
})
|
||||
|
||||
// Update the chat deployment
|
||||
await db.update(chat).set(updateData).where(eq(chat.id, chatId))
|
||||
|
||||
const updatedSubdomain = subdomain || existingChat[0].subdomain
|
||||
const updatedIdentifier = identifier || existingChat[0].identifier
|
||||
|
||||
const baseDomain = getEmailDomain()
|
||||
const protocol = isDev ? 'http' : 'https'
|
||||
const chatUrl = `${protocol}://${updatedSubdomain}.${baseDomain}`
|
||||
const chatUrl = `${protocol}://${baseDomain}/chat/${updatedIdentifier}`
|
||||
|
||||
logger.info(`Chat "${chatId}" updated successfully`)
|
||||
|
||||
@@ -256,14 +235,12 @@ export async function DELETE(
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
// Check if user has access to delete this chat
|
||||
const { hasAccess } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (!hasAccess) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
}
|
||||
|
||||
// Delete the chat deployment
|
||||
await db.delete(chat).where(eq(chat.id, chatId))
|
||||
|
||||
logger.info(`Chat "${chatId}" deleted successfully`)
|
||||
@@ -37,7 +37,7 @@ describe('Chat API Route', () => {
|
||||
}))
|
||||
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
chat: { userId: 'userId', subdomain: 'subdomain' },
|
||||
chat: { userId: 'userId', identifier: 'identifier' },
|
||||
workflow: { id: 'id', userId: 'userId', isDeployed: 'isDeployed' },
|
||||
}))
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('Chat API Route', () => {
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('should reject if subdomain already exists', async () => {
|
||||
it('should reject if identifier already exists', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
@@ -178,7 +178,7 @@ describe('Chat API Route', () => {
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
@@ -186,7 +186,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Subdomain exists
|
||||
mockLimit.mockResolvedValueOnce([{ id: 'existing-chat' }]) // Identifier exists
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
method: 'POST',
|
||||
@@ -196,7 +196,7 @@ describe('Chat API Route', () => {
|
||||
const response = await POST(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Subdomain already in use', 400)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Identifier already in use', 400)
|
||||
})
|
||||
|
||||
it('should reject if workflow not found', async () => {
|
||||
@@ -208,7 +208,7 @@ describe('Chat API Route', () => {
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
@@ -216,7 +216,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([]) // Identifier is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({ hasAccess: false })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
@@ -254,7 +254,7 @@ describe('Chat API Route', () => {
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
@@ -262,7 +262,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([]) // Identifier is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'user-id', workspaceId: null, isDeployed: true },
|
||||
@@ -299,7 +299,7 @@ describe('Chat API Route', () => {
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
@@ -307,7 +307,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([]) // Identifier is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'other-user-id', workspaceId: 'workspace-123', isDeployed: true },
|
||||
@@ -334,7 +334,7 @@ describe('Chat API Route', () => {
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
@@ -342,7 +342,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([]) // Identifier is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: false,
|
||||
})
|
||||
@@ -371,7 +371,7 @@ describe('Chat API Route', () => {
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
@@ -379,7 +379,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([]) // Identifier is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockRejectedValue(new Error('Permission check failed'))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat', {
|
||||
@@ -402,7 +402,7 @@ describe('Chat API Route', () => {
|
||||
|
||||
const validData = {
|
||||
workflowId: 'workflow-123',
|
||||
subdomain: 'test-chat',
|
||||
identifier: 'test-chat',
|
||||
title: 'Test Chat',
|
||||
customizations: {
|
||||
primaryColor: '#000000',
|
||||
@@ -410,7 +410,7 @@ describe('Chat API Route', () => {
|
||||
},
|
||||
}
|
||||
|
||||
mockLimit.mockResolvedValueOnce([]) // Subdomain is available
|
||||
mockLimit.mockResolvedValueOnce([]) // Identifier is available
|
||||
mockCheckWorkflowAccessForChatCreation.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
workflow: { userId: 'user-id', workspaceId: null, isDeployed: false },
|
||||
|
||||
@@ -16,10 +16,10 @@ const logger = createLogger('ChatAPI')
|
||||
|
||||
const chatSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
subdomain: z
|
||||
identifier: z
|
||||
.string()
|
||||
.min(1, 'Subdomain is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens'),
|
||||
.min(1, 'Identifier is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
customizations: z.object({
|
||||
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
|
||||
// Extract validated data
|
||||
const {
|
||||
workflowId,
|
||||
subdomain,
|
||||
identifier,
|
||||
title,
|
||||
description = '',
|
||||
customizations,
|
||||
@@ -98,15 +98,15 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if subdomain is available
|
||||
const existingSubdomain = await db
|
||||
// Check if identifier is available
|
||||
const existingIdentifier = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(eq(chat.subdomain, subdomain))
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
|
||||
if (existingSubdomain.length > 0) {
|
||||
return createErrorResponse('Subdomain already in use', 400)
|
||||
if (existingIdentifier.length > 0) {
|
||||
return createErrorResponse('Identifier already in use', 400)
|
||||
}
|
||||
|
||||
// Check if user has permission to create chat for this workflow
|
||||
@@ -137,7 +137,7 @@ export async function POST(request: NextRequest) {
|
||||
// Log the values we're inserting
|
||||
logger.info('Creating chat deployment with values:', {
|
||||
workflowId,
|
||||
subdomain,
|
||||
identifier,
|
||||
title,
|
||||
authType,
|
||||
hasPassword: !!encryptedPassword,
|
||||
@@ -156,7 +156,7 @@ export async function POST(request: NextRequest) {
|
||||
id,
|
||||
workflowId,
|
||||
userId: session.user.id,
|
||||
subdomain,
|
||||
identifier,
|
||||
title,
|
||||
description: description || '',
|
||||
customizations: mergedCustomizations,
|
||||
@@ -170,7 +170,7 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
|
||||
// Return successful response with chat URL
|
||||
// Generate chat URL based on the configured base URL
|
||||
// Generate chat URL using path-based routing instead of subdomains
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
let chatUrl: string
|
||||
@@ -180,7 +180,7 @@ export async function POST(request: NextRequest) {
|
||||
if (host.startsWith('www.')) {
|
||||
host = host.substring(4)
|
||||
}
|
||||
chatUrl = `${url.protocol}//${subdomain}.${host}`
|
||||
chatUrl = `${url.protocol}//${host}/chat/${identifier}`
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse baseUrl, falling back to defaults:', {
|
||||
baseUrl,
|
||||
@@ -188,9 +188,9 @@ export async function POST(request: NextRequest) {
|
||||
})
|
||||
// Fallback based on environment
|
||||
if (isDev) {
|
||||
chatUrl = `http://${subdomain}.localhost:3000`
|
||||
chatUrl = `http://localhost:3000/chat/${identifier}`
|
||||
} else {
|
||||
chatUrl = `https://${subdomain}.sim.ai`
|
||||
chatUrl = `https://sim.ai/chat/${identifier}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
/**
|
||||
* Tests for subdomain validation API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Subdomain Validation API Route', () => {
|
||||
// Mock database responses
|
||||
const mockSelect = vi.fn()
|
||||
const mockFrom = vi.fn()
|
||||
const mockWhere = vi.fn()
|
||||
const mockLimit = vi.fn()
|
||||
|
||||
// Mock success and error responses
|
||||
const mockCreateSuccessResponse = vi.fn()
|
||||
const mockCreateErrorResponse = vi.fn()
|
||||
const mockNextResponseJson = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
// Set up database query chain
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
|
||||
// Mock the database
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockSelect,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the schema
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
chat: {
|
||||
subdomain: 'subdomain',
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the logger
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the response utilities
|
||||
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' },
|
||||
})
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the NextResponse json method
|
||||
mockNextResponseJson.mockImplementation((data, options) => {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: options?.status || 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
|
||||
vi.doMock('next/server', () => ({
|
||||
NextRequest: vi.fn(),
|
||||
NextResponse: {
|
||||
json: mockNextResponseJson,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
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/subdomains/validate?subdomain=test')
|
||||
|
||||
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Unauthorized', 401)
|
||||
})
|
||||
|
||||
it('should return 400 when subdomain parameter is missing', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate')
|
||||
|
||||
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith('Missing subdomain parameter', 400)
|
||||
})
|
||||
|
||||
it('should return 400 when subdomain format is invalid', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/chat/subdomains/validate?subdomain=Invalid_Subdomain!'
|
||||
)
|
||||
|
||||
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
|
||||
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('available', false)
|
||||
expect(data).toHaveProperty('error', 'Invalid subdomain format')
|
||||
expect(mockNextResponseJson).toHaveBeenCalledWith(
|
||||
{ available: false, error: 'Invalid subdomain format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return available=true when subdomain is valid and not in use', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockLimit.mockResolvedValue([])
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/chat/subdomains/validate?subdomain=available-subdomain'
|
||||
)
|
||||
|
||||
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
available: true,
|
||||
subdomain: 'available-subdomain',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return available=false when subdomain is reserved', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/chat/subdomains/validate?subdomain=telemetry'
|
||||
)
|
||||
|
||||
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
|
||||
|
||||
const response = await GET(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('available', false)
|
||||
expect(data).toHaveProperty('error', 'This subdomain is reserved')
|
||||
expect(mockNextResponseJson).toHaveBeenCalledWith(
|
||||
{ available: false, error: 'This subdomain is reserved' },
|
||||
{ status: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
it('should return available=false when subdomain is already in use', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockLimit.mockResolvedValue([{ id: 'existing-chat-id' }])
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/chat/subdomains/validate?subdomain=used-subdomain'
|
||||
)
|
||||
|
||||
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockCreateSuccessResponse).toHaveBeenCalledWith({
|
||||
available: false,
|
||||
subdomain: 'used-subdomain',
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 500 when database query fails', async () => {
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
user: { id: 'user-id' },
|
||||
}),
|
||||
}))
|
||||
|
||||
mockLimit.mockRejectedValue(new Error('Database error'))
|
||||
|
||||
const req = new NextRequest(
|
||||
'http://localhost:3000/api/chat/subdomains/validate?subdomain=error-subdomain'
|
||||
)
|
||||
|
||||
const { GET } = await import('@/app/api/chat/subdomains/validate/route')
|
||||
|
||||
const response = await GET(req)
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(mockCreateErrorResponse).toHaveBeenCalledWith(
|
||||
'Failed to check subdomain availability',
|
||||
500
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('SubdomainValidateAPI')
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session || !session.user) {
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const subdomain = searchParams.get('subdomain')
|
||||
|
||||
if (!subdomain) {
|
||||
return createErrorResponse('Missing subdomain parameter', 400)
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(subdomain)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
available: false,
|
||||
error: 'Invalid subdomain format',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const reservedSubdomains = [
|
||||
'telemetry',
|
||||
'docs',
|
||||
'api',
|
||||
'admin',
|
||||
'www',
|
||||
'app',
|
||||
'auth',
|
||||
'blog',
|
||||
'help',
|
||||
'support',
|
||||
'admin',
|
||||
'qa',
|
||||
'agent',
|
||||
]
|
||||
if (reservedSubdomains.includes(subdomain)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
available: false,
|
||||
error: 'This subdomain is reserved',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const existingDeployment = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(eq(chat.subdomain, subdomain))
|
||||
.limit(1)
|
||||
|
||||
return createSuccessResponse({
|
||||
available: existingDeployment.length === 0,
|
||||
subdomain,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error checking subdomain availability:', error)
|
||||
return createErrorResponse('Failed to check subdomain availability', 500)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,6 @@ vi.mock('@/lib/utils', () => ({
|
||||
|
||||
describe('Chat API Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
@@ -61,6 +59,11 @@ describe('Chat API Utils', () => {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/environment', () => ({
|
||||
isDev: true,
|
||||
isHosted: false,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -71,30 +74,30 @@ describe('Chat API Utils', () => {
|
||||
it('should encrypt and validate auth tokens', async () => {
|
||||
const { encryptAuthToken, validateAuthToken } = await import('@/app/api/chat/utils')
|
||||
|
||||
const subdomainId = 'test-subdomain-id'
|
||||
const chatId = 'test-chat-id'
|
||||
const type = 'password'
|
||||
|
||||
const token = encryptAuthToken(subdomainId, type)
|
||||
const token = encryptAuthToken(chatId, type)
|
||||
expect(typeof token).toBe('string')
|
||||
expect(token.length).toBeGreaterThan(0)
|
||||
|
||||
const isValid = validateAuthToken(token, subdomainId)
|
||||
const isValid = validateAuthToken(token, chatId)
|
||||
expect(isValid).toBe(true)
|
||||
|
||||
const isInvalidSubdomain = validateAuthToken(token, 'wrong-subdomain-id')
|
||||
expect(isInvalidSubdomain).toBe(false)
|
||||
const isInvalidChat = validateAuthToken(token, 'wrong-chat-id')
|
||||
expect(isInvalidChat).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject expired tokens', async () => {
|
||||
const { validateAuthToken } = await import('@/app/api/chat/utils')
|
||||
|
||||
const subdomainId = 'test-subdomain-id'
|
||||
const chatId = 'test-chat-id'
|
||||
// Create an expired token by directly constructing it with an old timestamp
|
||||
const expiredToken = Buffer.from(
|
||||
`${subdomainId}:password:${Date.now() - 25 * 60 * 60 * 1000}`
|
||||
`${chatId}:password:${Date.now() - 25 * 60 * 60 * 1000}`
|
||||
).toString('base64')
|
||||
|
||||
const isValid = validateAuthToken(expiredToken, subdomainId)
|
||||
const isValid = validateAuthToken(expiredToken, chatId)
|
||||
expect(isValid).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -110,13 +113,13 @@ describe('Chat API Utils', () => {
|
||||
},
|
||||
} as unknown as NextResponse
|
||||
|
||||
const subdomainId = 'test-subdomain-id'
|
||||
const chatId = 'test-chat-id'
|
||||
const type = 'password'
|
||||
|
||||
setChatAuthCookie(mockResponse, subdomainId, type)
|
||||
setChatAuthCookie(mockResponse, chatId, type)
|
||||
|
||||
expect(mockSet).toHaveBeenCalledWith({
|
||||
name: `chat_auth_${subdomainId}`,
|
||||
name: `chat_auth_${chatId}`,
|
||||
value: expect.any(String),
|
||||
httpOnly: true,
|
||||
secure: false, // Development mode
|
||||
@@ -134,7 +137,7 @@ describe('Chat API Utils', () => {
|
||||
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
get: vi.fn().mockReturnValue('http://test.localhost:3000'),
|
||||
get: vi.fn().mockReturnValue('http://localhost:3000'),
|
||||
},
|
||||
} as any
|
||||
|
||||
@@ -148,7 +151,7 @@ describe('Chat API Utils', () => {
|
||||
|
||||
expect(mockResponse.headers.set).toHaveBeenCalledWith(
|
||||
'Access-Control-Allow-Origin',
|
||||
'http://test.localhost:3000'
|
||||
'http://localhost:3000'
|
||||
)
|
||||
expect(mockResponse.headers.set).toHaveBeenCalledWith(
|
||||
'Access-Control-Allow-Credentials',
|
||||
@@ -169,7 +172,7 @@ describe('Chat API Utils', () => {
|
||||
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
get: vi.fn().mockReturnValue('http://test.localhost:3000'),
|
||||
get: vi.fn().mockReturnValue('http://localhost:3000'),
|
||||
},
|
||||
} as any
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { processStreamingBlockLogs } from '@/lib/tokenization'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { decryptSecret, generateRequestId } from '@/lib/utils'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers'
|
||||
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
|
||||
@@ -102,17 +101,17 @@ export async function checkChatAccess(
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
export const encryptAuthToken = (subdomainId: string, type: string): string => {
|
||||
return Buffer.from(`${subdomainId}:${type}:${Date.now()}`).toString('base64')
|
||||
export const encryptAuthToken = (chatId: string, type: string): string => {
|
||||
return Buffer.from(`${chatId}:${type}:${Date.now()}`).toString('base64')
|
||||
}
|
||||
|
||||
export const validateAuthToken = (token: string, subdomainId: string): boolean => {
|
||||
export const validateAuthToken = (token: string, chatId: string): boolean => {
|
||||
try {
|
||||
const decoded = Buffer.from(token, 'base64').toString()
|
||||
const [storedId, _type, timestamp] = decoded.split(':')
|
||||
|
||||
// Check if token is for this subdomain
|
||||
if (storedId !== subdomainId) {
|
||||
// Check if token is for this chat
|
||||
if (storedId !== chatId) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -132,22 +131,16 @@ export const validateAuthToken = (token: string, subdomainId: string): boolean =
|
||||
}
|
||||
|
||||
// Set cookie helper function
|
||||
export const setChatAuthCookie = (
|
||||
response: NextResponse,
|
||||
subdomainId: string,
|
||||
type: string
|
||||
): void => {
|
||||
const token = encryptAuthToken(subdomainId, type)
|
||||
export const setChatAuthCookie = (response: NextResponse, chatId: string, type: string): void => {
|
||||
const token = encryptAuthToken(chatId, type)
|
||||
// Set cookie with HttpOnly and secure flags
|
||||
response.cookies.set({
|
||||
name: `chat_auth_${subdomainId}`,
|
||||
name: `chat_auth_${chatId}`,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
// Using subdomain for the domain in production
|
||||
domain: isDev ? undefined : `.${getEmailDomain()}`,
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
})
|
||||
}
|
||||
|
||||
49
apps/sim/app/api/chat/validate/route.ts
Normal file
49
apps/sim/app/api/chat/validate/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatValidateAPI')
|
||||
|
||||
/**
|
||||
* GET endpoint to validate chat identifier availability
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const identifier = searchParams.get('identifier')
|
||||
|
||||
if (!identifier) {
|
||||
return createErrorResponse('Identifier parameter is required', 400)
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(identifier)) {
|
||||
return createSuccessResponse({
|
||||
available: false,
|
||||
error: 'Identifier can only contain lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
}
|
||||
|
||||
const existingChat = await db
|
||||
.select({ id: chat.id })
|
||||
.from(chat)
|
||||
.where(eq(chat.identifier, identifier))
|
||||
.limit(1)
|
||||
|
||||
const isAvailable = existingChat.length === 0
|
||||
|
||||
logger.debug(
|
||||
`Identifier "${identifier}" availability check: ${isAvailable ? 'available' : 'taken'}`
|
||||
)
|
||||
|
||||
return createSuccessResponse({
|
||||
available: isAvailable,
|
||||
error: isAvailable ? null : 'This identifier is already in use',
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Error validating chat identifier:', error)
|
||||
return createErrorResponse(error.message || 'Failed to validate identifier', 500)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
const deploymentResults = await db
|
||||
.select({
|
||||
id: chat.id,
|
||||
subdomain: chat.subdomain,
|
||||
identifier: chat.identifier,
|
||||
isActive: chat.isActive,
|
||||
})
|
||||
.from(chat)
|
||||
@@ -33,7 +33,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
deploymentResults.length > 0
|
||||
? {
|
||||
id: deploymentResults[0].id,
|
||||
subdomain: deploymentResults[0].subdomain,
|
||||
identifier: deploymentResults[0].identifier,
|
||||
}
|
||||
: null
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ function throttle<T extends (...args: any[]) => any>(func: T, delay: number): T
|
||||
}) as T
|
||||
}
|
||||
|
||||
export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -190,7 +190,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
|
||||
const fetchChatConfig = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${subdomain}`, {
|
||||
const response = await fetch(`/api/chat/${identifier}`, {
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
@@ -251,7 +251,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
.catch((err) => {
|
||||
logger.error('Failed to fetch GitHub stars:', err)
|
||||
})
|
||||
}, [subdomain])
|
||||
}, [identifier])
|
||||
|
||||
const refreshChat = () => {
|
||||
fetchChatConfig()
|
||||
@@ -309,7 +309,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
|
||||
logger.info('API payload:', payload)
|
||||
|
||||
const response = await fetch(`/api/chat/${subdomain}`, {
|
||||
const response = await fetch(`/api/chat/${identifier}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -433,7 +433,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
if (authRequired === 'password') {
|
||||
return (
|
||||
<PasswordAuth
|
||||
subdomain={subdomain}
|
||||
identifier={identifier}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
@@ -443,7 +443,7 @@ export default function ChatClient({ subdomain }: { subdomain: string }) {
|
||||
if (authRequired === 'email') {
|
||||
return (
|
||||
<EmailAuth
|
||||
subdomain={subdomain}
|
||||
identifier={identifier}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
title={title}
|
||||
primaryColor={primaryColor}
|
||||
6
apps/sim/app/chat/[identifier]/page.tsx
Normal file
6
apps/sim/app/chat/[identifier]/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import ChatClient from '@/app/chat/[identifier]/chat'
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ identifier: string }> }) {
|
||||
const { identifier } = await params
|
||||
return <ChatClient identifier={identifier} />
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Chat Subdomain Light Mode Overrides
|
||||
*
|
||||
* This file overrides dark mode utility classes to force light mode appearance
|
||||
* in the chat subdomain. It uses CSS variables defined in globals.css.
|
||||
*
|
||||
* The layout.tsx already applies the 'light' class which sets all the light
|
||||
* theme CSS variables from globals.css, so we don't need to redefine them here.
|
||||
*/
|
||||
|
||||
/* Background Color Overrides */
|
||||
.chat-light-wrapper :is(.dark\:bg-black) {
|
||||
background-color: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-900) {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-800) {
|
||||
background-color: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-700) {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-600) {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:bg-gray-300) {
|
||||
background-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Text Color Overrides */
|
||||
.chat-light-wrapper :is(.dark\:text-gray-100) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-gray-200) {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-gray-300) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-gray-400) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-neutral-600) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:text-blue-400) {
|
||||
color: var(--brand-accent-hex);
|
||||
}
|
||||
|
||||
/* Border Color Overrides */
|
||||
.chat-light-wrapper :is(.dark\:border-gray-700) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:border-gray-800) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:border-gray-600) {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.chat-light-wrapper :is(.dark\:divide-gray-700) > * + * {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Hover State Overrides */
|
||||
.chat-light-wrapper :is(.dark\:hover\:bg-gray-800\/60:hover) {
|
||||
background-color: hsl(var(--card-hover));
|
||||
}
|
||||
|
||||
/* Code Block Overrides */
|
||||
.chat-light-wrapper pre:is(.dark\:bg-black) {
|
||||
background-color: hsl(var(--workflow-dots));
|
||||
}
|
||||
|
||||
.chat-light-wrapper code:is(.dark\:bg-gray-700) {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.chat-light-wrapper code:is(.dark\:text-gray-200) {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* Special Components */
|
||||
/* Tooltip overrides - keep tooltips dark with light text for consistency */
|
||||
.chat-light-wrapper [data-radix-tooltip-content] {
|
||||
background-color: hsl(0 0% 3.9%) !important;
|
||||
color: hsl(0 0% 98%) !important;
|
||||
}
|
||||
|
||||
/* Force light color scheme */
|
||||
.chat-light-wrapper {
|
||||
color-scheme: light !important;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import './chat.css'
|
||||
|
||||
export default function ChatLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
forcedTheme='light'
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<div className='light chat-light-wrapper' style={{ colorScheme: 'light' }}>
|
||||
{children}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import ChatClient from '@/app/chat/[subdomain]/chat'
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) {
|
||||
const { subdomain } = await params
|
||||
return <ChatClient subdomain={subdomain} />
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
const logger = createLogger('EmailAuth')
|
||||
|
||||
interface EmailAuthProps {
|
||||
subdomain: string
|
||||
identifier: string
|
||||
onAuthSuccess: () => void
|
||||
title?: string
|
||||
primaryColor?: string
|
||||
@@ -39,7 +39,7 @@ const validateEmailField = (emailValue: string): string[] => {
|
||||
}
|
||||
|
||||
export default function EmailAuth({
|
||||
subdomain,
|
||||
identifier,
|
||||
onAuthSuccess,
|
||||
title = 'chat',
|
||||
primaryColor = 'var(--brand-primary-hover-hex)',
|
||||
@@ -133,7 +133,7 @@ export default function EmailAuth({
|
||||
setIsSendingOtp(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${subdomain}/otp`, {
|
||||
const response = await fetch(`/api/chat/${identifier}/otp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -170,7 +170,7 @@ export default function EmailAuth({
|
||||
setIsVerifyingOtp(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${subdomain}/otp`, {
|
||||
const response = await fetch(`/api/chat/${identifier}/otp`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -201,7 +201,7 @@ export default function EmailAuth({
|
||||
setCountdown(30)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chat/${subdomain}/otp`, {
|
||||
const response = await fetch(`/api/chat/${identifier}/otp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -14,14 +14,14 @@ import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
const logger = createLogger('PasswordAuth')
|
||||
|
||||
interface PasswordAuthProps {
|
||||
subdomain: string
|
||||
identifier: string
|
||||
onAuthSuccess: () => void
|
||||
title?: string
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
export default function PasswordAuth({
|
||||
subdomain,
|
||||
identifier,
|
||||
onAuthSuccess,
|
||||
title = 'chat',
|
||||
primaryColor = 'var(--brand-primary-hover-hex)',
|
||||
@@ -94,7 +94,7 @@ export default function PasswordAuth({
|
||||
try {
|
||||
const payload = { password }
|
||||
|
||||
const response = await fetch(`/api/chat/${subdomain}`, {
|
||||
const response = await fetch(`/api/chat/${identifier}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ConditionalThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Force light mode for certain pages
|
||||
const forcedTheme =
|
||||
pathname === '/' ||
|
||||
pathname === '/homepage' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
pathname.startsWith('/invite') ||
|
||||
pathname.startsWith('/verify') ||
|
||||
pathname.startsWith('/changelog')
|
||||
? 'light'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import '@/app/globals.css'
|
||||
|
||||
import { SessionProvider } from '@/lib/session/session-context'
|
||||
import { ConditionalThemeProvider } from '@/app/conditional-theme-provider'
|
||||
import { ThemeProvider } from '@/app/theme-provider'
|
||||
import { ZoomPrevention } from '@/app/zoom-prevention'
|
||||
|
||||
const logger = createLogger('RootLayout')
|
||||
@@ -91,7 +91,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<ConditionalThemeProvider>
|
||||
<ThemeProvider>
|
||||
<SessionProvider>
|
||||
<BrandedLayout>
|
||||
<ZoomPrevention />
|
||||
@@ -103,7 +103,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
)}
|
||||
</BrandedLayout>
|
||||
</SessionProvider>
|
||||
</ConditionalThemeProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Force light mode for certain pages
|
||||
const forcedTheme =
|
||||
pathname === '/' ||
|
||||
pathname === '/homepage' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
pathname.startsWith('/invite') ||
|
||||
pathname.startsWith('/verify') ||
|
||||
pathname.startsWith('/changelog') ||
|
||||
pathname.startsWith('/chat')
|
||||
? 'light'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
@@ -11,6 +29,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { AuthSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/auth-selector'
|
||||
import { SubdomainInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/subdomain-input'
|
||||
import { IdentifierInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/identifier-input'
|
||||
import { SuccessView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/components/success-view'
|
||||
import { useChatDeployment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-deployment'
|
||||
import { useChatForm } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-chat-form'
|
||||
@@ -50,7 +50,7 @@ interface ChatDeployProps {
|
||||
|
||||
interface ExistingChat {
|
||||
id: string
|
||||
subdomain: string
|
||||
identifier: string
|
||||
title: string
|
||||
description: string
|
||||
authType: 'public' | 'password' | 'email'
|
||||
@@ -96,9 +96,9 @@ export function ChatDeploy({
|
||||
const { formData, errors, updateField, setError, validateForm, setFormData } = useChatForm()
|
||||
const { deployedUrl, deployChat } = useChatDeployment()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const [isSubdomainValid, setIsSubdomainValid] = useState(false)
|
||||
const [isIdentifierValid, setIsIdentifierValid] = useState(false)
|
||||
const isFormValid =
|
||||
isSubdomainValid &&
|
||||
isIdentifierValid &&
|
||||
Boolean(formData.title.trim()) &&
|
||||
formData.selectedOutputBlocks.length > 0 &&
|
||||
(formData.authType !== 'password' ||
|
||||
@@ -125,14 +125,14 @@ export function ChatDeploy({
|
||||
const data = await response.json()
|
||||
|
||||
if (data.isDeployed && data.deployment) {
|
||||
const detailResponse = await fetch(`/api/chat/edit/${data.deployment.id}`)
|
||||
const detailResponse = await fetch(`/api/chat/manage/${data.deployment.id}`)
|
||||
|
||||
if (detailResponse.ok) {
|
||||
const chatDetail = await detailResponse.json()
|
||||
setExistingChat(chatDetail)
|
||||
|
||||
setFormData({
|
||||
subdomain: chatDetail.subdomain || '',
|
||||
identifier: chatDetail.identifier || '',
|
||||
title: chatDetail.title || '',
|
||||
description: chatDetail.description || '',
|
||||
authType: chatDetail.authType || 'public',
|
||||
@@ -185,8 +185,8 @@ export function ChatDeploy({
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSubdomainValid && formData.subdomain !== existingChat?.subdomain) {
|
||||
setError('subdomain', 'Please wait for subdomain validation to complete')
|
||||
if (!isIdentifierValid && formData.identifier !== existingChat?.identifier) {
|
||||
setError('identifier', 'Please wait for identifier validation to complete')
|
||||
setChatSubmitting(false)
|
||||
return
|
||||
}
|
||||
@@ -201,8 +201,8 @@ export function ChatDeploy({
|
||||
// This ensures existingChat is available when switching back to edit mode
|
||||
await fetchExistingChat()
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('subdomain')) {
|
||||
setError('subdomain', error.message)
|
||||
if (error.message?.includes('identifier')) {
|
||||
setError('identifier', error.message)
|
||||
} else {
|
||||
setError('general', error.message)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export function ChatDeploy({
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
|
||||
const response = await fetch(`/api/chat/edit/${existingChat.id}`, {
|
||||
const response = await fetch(`/api/chat/manage/${existingChat.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
@@ -267,7 +267,7 @@ export function ChatDeploy({
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your chat deployment at{' '}
|
||||
<span className='font-mono text-destructive'>
|
||||
{existingChat?.subdomain}.{getEmailDomain()}
|
||||
{getEmailDomain()}/chat/{existingChat?.identifier}
|
||||
</span>
|
||||
.
|
||||
<span className='mt-2 block'>
|
||||
@@ -316,12 +316,12 @@ export function ChatDeploy({
|
||||
)}
|
||||
|
||||
<div className='space-y-4'>
|
||||
<SubdomainInput
|
||||
value={formData.subdomain}
|
||||
onChange={(value) => updateField('subdomain', value)}
|
||||
originalSubdomain={existingChat?.subdomain || undefined}
|
||||
<IdentifierInput
|
||||
value={formData.identifier}
|
||||
onChange={(value) => updateField('identifier', value)}
|
||||
originalIdentifier={existingChat?.identifier || undefined}
|
||||
disabled={chatSubmitting}
|
||||
onValidationChange={setIsSubdomainValid}
|
||||
onValidationChange={setIsIdentifierValid}
|
||||
isEditingExisting={!!existingChat}
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
@@ -445,7 +445,7 @@ export function ChatDeploy({
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete your chat deployment at{' '}
|
||||
<span className='font-mono text-destructive'>
|
||||
{existingChat?.subdomain}.{getEmailDomain()}
|
||||
{getEmailDomain()}/chat/{existingChat?.identifier}
|
||||
</span>
|
||||
.
|
||||
<span className='mt-2 block'>
|
||||
|
||||
@@ -2,33 +2,33 @@ import { useEffect } from 'react'
|
||||
import { Input, Label } from '@/components/ui'
|
||||
import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubdomainValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-subdomain-validation'
|
||||
import { useIdentifierValidation } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/hooks/use-identifier-validation'
|
||||
|
||||
interface SubdomainInputProps {
|
||||
interface IdentifierInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
originalSubdomain?: string
|
||||
originalIdentifier?: string
|
||||
disabled?: boolean
|
||||
onValidationChange?: (isValid: boolean) => void
|
||||
isEditingExisting?: boolean
|
||||
}
|
||||
|
||||
const getDomainSuffix = (() => {
|
||||
const suffix = `.${getEmailDomain()}`
|
||||
return () => suffix
|
||||
const getDomainPrefix = (() => {
|
||||
const prefix = `${getEmailDomain()}/chat/`
|
||||
return () => prefix
|
||||
})()
|
||||
|
||||
export function SubdomainInput({
|
||||
export function IdentifierInput({
|
||||
value,
|
||||
onChange,
|
||||
originalSubdomain,
|
||||
originalIdentifier,
|
||||
disabled = false,
|
||||
onValidationChange,
|
||||
isEditingExisting = false,
|
||||
}: SubdomainInputProps) {
|
||||
const { isChecking, error, isValid } = useSubdomainValidation(
|
||||
}: IdentifierInputProps) {
|
||||
const { isChecking, error, isValid } = useIdentifierValidation(
|
||||
value,
|
||||
originalSubdomain,
|
||||
originalIdentifier,
|
||||
isEditingExisting
|
||||
)
|
||||
|
||||
@@ -44,20 +44,23 @@ export function SubdomainInput({
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='subdomain' className='font-medium text-sm'>
|
||||
Subdomain
|
||||
<Label htmlFor='identifier' className='font-medium text-sm'>
|
||||
Identifier
|
||||
</Label>
|
||||
<div className='relative flex items-center rounded-md ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'>
|
||||
<div className='flex h-10 items-center whitespace-nowrap rounded-l-md border border-r-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
|
||||
{getDomainPrefix()}
|
||||
</div>
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
id='subdomain'
|
||||
id='identifier'
|
||||
placeholder='company-name'
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
required
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'rounded-r-none border-r-0 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
'rounded-l-none border-l-0 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
isChecking && 'pr-8',
|
||||
error && 'border-destructive focus-visible:border-destructive'
|
||||
)}
|
||||
@@ -68,9 +71,6 @@ export function SubdomainInput({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex h-10 items-center whitespace-nowrap rounded-r-md border border-l-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
|
||||
{getDomainSuffix()}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className='mt-1 text-destructive text-sm'>{error}</p>}
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@ import { getBaseDomain, getEmailDomain } from '@/lib/urls/utils'
|
||||
|
||||
interface ExistingChat {
|
||||
id: string
|
||||
subdomain: string
|
||||
identifier: string
|
||||
title: string
|
||||
description: string
|
||||
authType: 'public' | 'password' | 'email'
|
||||
@@ -27,21 +27,20 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
|
||||
const hostname = url.hostname
|
||||
const isDevelopmentUrl = hostname.includes('localhost')
|
||||
|
||||
let domainSuffix
|
||||
// Extract identifier from path-based URL format (e.g., sim.ai/chat/identifier)
|
||||
const pathParts = url.pathname.split('/')
|
||||
const identifierPart = pathParts[2] || '' // /chat/identifier
|
||||
|
||||
let domainPrefix
|
||||
if (isDevelopmentUrl) {
|
||||
const baseDomain = getBaseDomain()
|
||||
const baseHost = baseDomain.split(':')[0]
|
||||
const port = url.port || (baseDomain.includes(':') ? baseDomain.split(':')[1] : '3000')
|
||||
domainSuffix = `.${baseHost}:${port}`
|
||||
domainPrefix = `${baseHost}:${port}/chat/`
|
||||
} else {
|
||||
domainSuffix = `.${getEmailDomain()}`
|
||||
domainPrefix = `${getEmailDomain()}/chat/`
|
||||
}
|
||||
|
||||
const baseDomainForSplit = getEmailDomain()
|
||||
const subdomainPart = isDevelopmentUrl
|
||||
? hostname.split('.')[0]
|
||||
: hostname.split(`.${baseDomainForSplit}`)[0]
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
@@ -49,17 +48,17 @@ export function SuccessView({ deployedUrl, existingChat, onDelete, onUpdate }: S
|
||||
Chat {existingChat ? 'Update' : 'Deployment'} Successful
|
||||
</Label>
|
||||
<div className='relative flex items-center rounded-md ring-offset-background'>
|
||||
<div className='flex h-10 items-center whitespace-nowrap rounded-l-md border border-r-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
|
||||
{domainPrefix}
|
||||
</div>
|
||||
<a
|
||||
href={deployedUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex h-10 flex-1 items-center break-all rounded-l-md border border-r-0 p-2 font-medium text-foreground text-sm'
|
||||
className='flex h-10 flex-1 items-center break-all rounded-r-md border border-l-0 p-2 font-medium text-foreground text-sm'
|
||||
>
|
||||
{subdomainPart}
|
||||
{identifierPart}
|
||||
</a>
|
||||
<div className='flex h-10 items-center whitespace-nowrap rounded-r-md border border-l-0 bg-muted px-3 font-medium text-muted-foreground text-sm'>
|
||||
{domainSuffix}
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Your chat is now live at{' '}
|
||||
|
||||
@@ -14,10 +14,10 @@ export interface ChatDeploymentState {
|
||||
|
||||
const chatSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
subdomain: z
|
||||
identifier: z
|
||||
.string()
|
||||
.min(1, 'Subdomain is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Subdomain can only contain lowercase letters, numbers, and hyphens'),
|
||||
.min(1, 'Identifier is required')
|
||||
.regex(/^[a-z0-9-]+$/, 'Identifier can only contain lowercase letters, numbers, and hyphens'),
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
customizations: z.object({
|
||||
@@ -75,7 +75,7 @@ export function useChatDeployment() {
|
||||
// Create request payload
|
||||
const payload = {
|
||||
workflowId,
|
||||
subdomain: formData.subdomain.trim(),
|
||||
identifier: formData.identifier.trim(),
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim(),
|
||||
customizations: {
|
||||
@@ -95,7 +95,7 @@ export function useChatDeployment() {
|
||||
chatSchema.parse(payload)
|
||||
|
||||
// Determine endpoint and method
|
||||
const endpoint = existingChatId ? `/api/chat/edit/${existingChatId}` : '/api/chat'
|
||||
const endpoint = existingChatId ? `/api/chat/manage/${existingChatId}` : '/api/chat'
|
||||
const method = existingChatId ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
@@ -107,9 +107,9 @@ export function useChatDeployment() {
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle subdomain conflict specifically
|
||||
if (result.error === 'Subdomain already in use') {
|
||||
throw new Error('This subdomain is already in use')
|
||||
// Handle identifier conflict specifically
|
||||
if (result.error === 'Identifier already in use') {
|
||||
throw new Error('This identifier is already in use')
|
||||
}
|
||||
throw new Error(result.error || `Failed to ${existingChatId ? 'update' : 'deploy'} chat`)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useState } from 'react'
|
||||
export type AuthType = 'public' | 'password' | 'email'
|
||||
|
||||
export interface ChatFormData {
|
||||
subdomain: string
|
||||
identifier: string
|
||||
title: string
|
||||
description: string
|
||||
authType: AuthType
|
||||
@@ -14,7 +14,7 @@ export interface ChatFormData {
|
||||
}
|
||||
|
||||
export interface ChatFormErrors {
|
||||
subdomain?: string
|
||||
identifier?: string
|
||||
title?: string
|
||||
password?: string
|
||||
emails?: string
|
||||
@@ -23,7 +23,7 @@ export interface ChatFormErrors {
|
||||
}
|
||||
|
||||
const initialFormData: ChatFormData = {
|
||||
subdomain: '',
|
||||
identifier: '',
|
||||
title: '',
|
||||
description: '',
|
||||
authType: 'public',
|
||||
@@ -67,10 +67,10 @@ export function useChatForm(initialData?: Partial<ChatFormData>) {
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const newErrors: ChatFormErrors = {}
|
||||
|
||||
if (!formData.subdomain.trim()) {
|
||||
newErrors.subdomain = 'Subdomain is required'
|
||||
} else if (!/^[a-z0-9-]+$/.test(formData.subdomain)) {
|
||||
newErrors.subdomain = 'Subdomain can only contain lowercase letters, numbers, and hyphens'
|
||||
if (!formData.identifier.trim()) {
|
||||
newErrors.identifier = 'Identifier is required'
|
||||
} else if (!/^[a-z0-9-]+$/.test(formData.identifier)) {
|
||||
newErrors.identifier = 'Identifier can only contain lowercase letters, numbers, and hyphens'
|
||||
}
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useSubdomainValidation(
|
||||
subdomain: string,
|
||||
originalSubdomain?: string,
|
||||
export function useIdentifierValidation(
|
||||
identifier: string,
|
||||
originalIdentifier?: string,
|
||||
isEditingExisting?: boolean
|
||||
) {
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
@@ -12,71 +12,72 @@ export function useSubdomainValidation(
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Clear previous timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
// Reset states immediately when subdomain changes
|
||||
// Reset states immediately when identifier changes
|
||||
setError(null)
|
||||
setIsValid(false)
|
||||
setIsChecking(false)
|
||||
|
||||
// Skip validation if empty
|
||||
if (!subdomain.trim()) {
|
||||
if (!identifier.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip validation if same as original (existing deployment)
|
||||
if (originalSubdomain && subdomain === originalSubdomain) {
|
||||
if (originalIdentifier && identifier === originalIdentifier) {
|
||||
setIsValid(true)
|
||||
return
|
||||
}
|
||||
|
||||
// If we're editing an existing deployment but originalSubdomain isn't available yet,
|
||||
// If we're editing an existing deployment but originalIdentifier isn't available yet,
|
||||
// assume it's valid and wait for the data to load
|
||||
if (isEditingExisting && !originalSubdomain) {
|
||||
if (isEditingExisting && !originalIdentifier) {
|
||||
setIsValid(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate format first
|
||||
if (!/^[a-z0-9-]+$/.test(subdomain)) {
|
||||
setError('Subdomain can only contain lowercase letters, numbers, and hyphens')
|
||||
// Validate format first - client-side validation
|
||||
if (!/^[a-z0-9-]+$/.test(identifier)) {
|
||||
setError('Identifier can only contain lowercase letters, numbers, and hyphens')
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce API call
|
||||
// Check availability with server
|
||||
setIsChecking(true)
|
||||
timeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/chat/subdomains/validate?subdomain=${encodeURIComponent(subdomain)}`
|
||||
`/api/chat/validate?identifier=${encodeURIComponent(identifier)}`
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok || !data.available) {
|
||||
setError(data.error || 'This subdomain is already in use')
|
||||
if (!response.ok) {
|
||||
setError('Error checking identifier availability')
|
||||
setIsValid(false)
|
||||
} else if (!data.available) {
|
||||
setError(data.error || 'This identifier is already in use')
|
||||
setIsValid(false)
|
||||
} else {
|
||||
setError(null)
|
||||
setIsValid(true)
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Error checking subdomain availability')
|
||||
setError('Error checking identifier availability')
|
||||
setIsValid(false)
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [subdomain, originalSubdomain, isEditingExisting])
|
||||
}, [identifier, originalIdentifier, isEditingExisting])
|
||||
|
||||
return { isChecking, error, isValid }
|
||||
}
|
||||
@@ -16,7 +16,10 @@ const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn('h-full w-full flex-1 bg-primary transition-all', indicatorClassName)}
|
||||
className={cn(
|
||||
'h-full w-full flex-1 bg-primary transition-all dark:bg-white',
|
||||
indicatorClassName
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { getSessionCookie } from 'better-auth/cookies'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { isDev, isHosted } from './lib/environment'
|
||||
import { isHosted } from './lib/environment'
|
||||
import { createLogger } from './lib/logs/console/logger'
|
||||
import { generateRuntimeCSP } from './lib/security/csp'
|
||||
import { getBaseDomain } from './lib/urls/utils'
|
||||
|
||||
const logger = createLogger('Middleware')
|
||||
|
||||
@@ -13,143 +12,98 @@ const SUSPICIOUS_UA_PATTERNS = [
|
||||
/<\s*script/i, // Potential XSS payloads
|
||||
/^\(\)\s*{/, // Command execution attempt
|
||||
/\b(sqlmap|nikto|gobuster|dirb|nmap)\b/i, // Known scanning tools
|
||||
]
|
||||
|
||||
const BASE_DOMAIN = getBaseDomain()
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
// Check for active session
|
||||
const sessionCookie = getSessionCookie(request)
|
||||
const hasActiveSession = !!sessionCookie
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Handles authentication-based redirects for root paths
|
||||
*/
|
||||
function handleRootPathRedirects(
|
||||
request: NextRequest,
|
||||
hasActiveSession: boolean
|
||||
): NextResponse | null {
|
||||
const url = request.nextUrl
|
||||
const hostname = request.headers.get('host') || ''
|
||||
|
||||
// Extract subdomain - handle nested subdomains for any domain
|
||||
const isCustomDomain = (() => {
|
||||
// Standard check for non-base domains
|
||||
if (hostname === BASE_DOMAIN || hostname.startsWith('www.')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract root domain from BASE_DOMAIN (e.g., "sim.ai" from "staging.sim.ai")
|
||||
const baseParts = BASE_DOMAIN.split('.')
|
||||
const rootDomain = isDev
|
||||
? 'localhost'
|
||||
: baseParts.length >= 2
|
||||
? baseParts
|
||||
.slice(-2)
|
||||
.join('.') // Last 2 parts: ["simstudio", "ai"] -> "sim.ai"
|
||||
: BASE_DOMAIN
|
||||
|
||||
// Check if hostname is under the same root domain
|
||||
if (!hostname.includes(rootDomain)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// For nested subdomain environments: handle cases like myapp.staging.example.com
|
||||
const hostParts = hostname.split('.')
|
||||
const basePartCount = BASE_DOMAIN.split('.').length
|
||||
|
||||
// If hostname has more parts than base domain, it's a nested subdomain
|
||||
if (hostParts.length > basePartCount) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For single-level subdomains: regular subdomain logic
|
||||
return hostname !== BASE_DOMAIN
|
||||
})()
|
||||
|
||||
const subdomain = isCustomDomain ? hostname.split('.')[0] : null
|
||||
|
||||
// Handle chat subdomains
|
||||
if (subdomain && isCustomDomain) {
|
||||
if (url.pathname.startsWith('/api/chat/') || url.pathname.startsWith('/api/proxy/')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Rewrite to the chat page but preserve the URL in browser
|
||||
return NextResponse.rewrite(new URL(`/chat/${subdomain}${url.pathname}`, request.url))
|
||||
if (url.pathname !== '/' && url.pathname !== '/homepage') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle root path redirects based on session status and hosting type
|
||||
// Only apply redirects to the main domain, not subdomains
|
||||
if (!isCustomDomain && (url.pathname === '/' || url.pathname === '/homepage')) {
|
||||
if (!isHosted) {
|
||||
// Self-hosted: Always redirect based on session
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
// Hosted: Allow access to /homepage route even for authenticated users
|
||||
if (url.pathname === '/homepage') {
|
||||
return NextResponse.rewrite(new URL('/', request.url))
|
||||
}
|
||||
|
||||
// For root path, redirect authenticated users to workspace
|
||||
if (hasActiveSession && url.pathname === '/') {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle login page - redirect authenticated users to workspace
|
||||
if (url.pathname === '/login' || url.pathname === '/signup') {
|
||||
if (!isHosted) {
|
||||
// Self-hosted: Always redirect based on session
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
// Handle protected routes that require authentication
|
||||
if (url.pathname.startsWith('/workspace')) {
|
||||
if (!hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
// Hosted: Allow access to /homepage route even for authenticated users
|
||||
if (url.pathname === '/homepage') {
|
||||
return NextResponse.rewrite(new URL('/', request.url))
|
||||
}
|
||||
|
||||
// For root path, redirect authenticated users to workspace
|
||||
if (hasActiveSession && url.pathname === '/') {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles invitation link redirects for unauthenticated users
|
||||
*/
|
||||
function handleInvitationRedirects(
|
||||
request: NextRequest,
|
||||
hasActiveSession: boolean
|
||||
): NextResponse | null {
|
||||
if (!request.nextUrl.pathname.startsWith('/invite/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!hasActiveSession &&
|
||||
!request.nextUrl.pathname.endsWith('/login') &&
|
||||
!request.nextUrl.pathname.endsWith('/signup') &&
|
||||
!request.nextUrl.search.includes('callbackUrl')
|
||||
) {
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
const inviteId = request.nextUrl.pathname.split('/').pop()
|
||||
const callbackParam = encodeURIComponent(`/invite/${inviteId}${token ? `?token=${token}` : ''}`)
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
|
||||
)
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles workspace invitation API endpoint access
|
||||
*/
|
||||
function handleWorkspaceInvitationAPI(
|
||||
request: NextRequest,
|
||||
hasActiveSession: boolean
|
||||
): NextResponse | null {
|
||||
if (!request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
if (token) {
|
||||
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
|
||||
}
|
||||
|
||||
// Email verification is enforced by Better Auth (server-side). No cookie gating here.
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Allow access to invitation links
|
||||
if (request.nextUrl.pathname.startsWith('/invite/')) {
|
||||
if (
|
||||
!hasActiveSession &&
|
||||
!request.nextUrl.pathname.endsWith('/login') &&
|
||||
!request.nextUrl.pathname.endsWith('/signup') &&
|
||||
!request.nextUrl.search.includes('callbackUrl')
|
||||
) {
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
const inviteId = request.nextUrl.pathname.split('/').pop()
|
||||
const callbackParam = encodeURIComponent(
|
||||
`/invite/${inviteId}${token ? `?token=${token}` : ''}`
|
||||
)
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?callbackUrl=${callbackParam}&invite_flow=true`, request.url)
|
||||
)
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Allow access to workspace invitation API endpoint
|
||||
if (request.nextUrl.pathname.startsWith('/api/workspaces/invitations')) {
|
||||
if (request.nextUrl.pathname.includes('/accept') && !hasActiveSession) {
|
||||
const token = request.nextUrl.searchParams.get('token')
|
||||
if (token) {
|
||||
return NextResponse.redirect(new URL(`/invite/${token}?token=${token}`, request.url))
|
||||
}
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles security filtering for suspicious user agents
|
||||
*/
|
||||
function handleSecurityFiltering(request: NextRequest): NextResponse | null {
|
||||
const userAgent = request.headers.get('user-agent') || ''
|
||||
|
||||
// Check if this is a webhook endpoint that should be exempt from User-Agent validation
|
||||
const isWebhookEndpoint = url.pathname.startsWith('/api/webhooks/trigger/')
|
||||
|
||||
const isWebhookEndpoint = request.nextUrl.pathname.startsWith('/api/webhooks/trigger/')
|
||||
const isSuspicious = SUSPICIOUS_UA_PATTERNS.some((pattern) => pattern.test(userAgent))
|
||||
|
||||
// Block suspicious requests, but exempt webhook endpoints from User-Agent validation only
|
||||
// Block suspicious requests, but exempt webhook endpoints from User-Agent validation
|
||||
if (isSuspicious && !isWebhookEndpoint) {
|
||||
logger.warn('Blocked suspicious request', {
|
||||
userAgent,
|
||||
@@ -158,6 +112,7 @@ export async function middleware(request: NextRequest) {
|
||||
method: request.method,
|
||||
pattern: SUSPICIOUS_UA_PATTERNS.find((pattern) => pattern.test(userAgent))?.toString(),
|
||||
})
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
@@ -173,10 +128,44 @@ export async function middleware(request: NextRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const url = request.nextUrl
|
||||
|
||||
const sessionCookie = getSessionCookie(request)
|
||||
const hasActiveSession = !!sessionCookie
|
||||
|
||||
const redirect = handleRootPathRedirects(request, hasActiveSession)
|
||||
if (redirect) return redirect
|
||||
|
||||
if (url.pathname === '/login' || url.pathname === '/signup') {
|
||||
if (hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/workspace')) {
|
||||
if (!hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
|
||||
if (invitationRedirect) return invitationRedirect
|
||||
|
||||
const workspaceInvitationRedirect = handleWorkspaceInvitationAPI(request, hasActiveSession)
|
||||
if (workspaceInvitationRedirect) return workspaceInvitationRedirect
|
||||
|
||||
const securityBlock = handleSecurityFiltering(request)
|
||||
if (securityBlock) return securityBlock
|
||||
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('Vary', 'User-Agent')
|
||||
|
||||
// Generate runtime CSP for main application routes that need dynamic environment variables
|
||||
if (
|
||||
url.pathname.startsWith('/workspace') ||
|
||||
url.pathname.startsWith('/chat') ||
|
||||
@@ -188,7 +177,6 @@ export async function middleware(request: NextRequest) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Update matcher to include invitation routes and root path
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/', // Root path for self-hosted redirect logic
|
||||
|
||||
@@ -194,16 +194,9 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
// If parsing fails, continue to load from DB
|
||||
}
|
||||
}
|
||||
// Skip loading if on a subdomain or chat path
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.pathname.startsWith('/chat/') ||
|
||||
(window.location.hostname !== 'sim.ai' &&
|
||||
window.location.hostname !== 'localhost' &&
|
||||
window.location.hostname !== '127.0.0.1' &&
|
||||
!window.location.hostname.startsWith('www.')))
|
||||
) {
|
||||
logger.debug('Skipping settings load - on chat or subdomain page')
|
||||
// Skip loading if on a chat path
|
||||
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/chat/')) {
|
||||
logger.debug('Skipping settings load - on chat page')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -258,15 +251,8 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
},
|
||||
|
||||
updateSetting: async (key, value) => {
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.pathname.startsWith('/chat/') ||
|
||||
(window.location.hostname !== 'sim.ai' &&
|
||||
window.location.hostname !== 'localhost' &&
|
||||
window.location.hostname !== '127.0.0.1' &&
|
||||
!window.location.hostname.startsWith('www.')))
|
||||
) {
|
||||
logger.debug(`Skipping setting update for ${key} on chat or subdomain page`)
|
||||
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/chat/')) {
|
||||
logger.debug(`Skipping setting update for ${key} on chat page`)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
3
packages/db/migrations/0094_perpetual_the_watchers.sql
Normal file
3
packages/db/migrations/0094_perpetual_the_watchers.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "chat" RENAME COLUMN "subdomain" TO "identifier";--> statement-breakpoint
|
||||
DROP INDEX "subdomain_idx";--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "identifier_idx" ON "chat" USING btree ("identifier");
|
||||
6818
packages/db/migrations/meta/0094_snapshot.json
Normal file
6818
packages/db/migrations/meta/0094_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -652,6 +652,13 @@
|
||||
"when": 1758751182653,
|
||||
"tag": "0093_medical_sentinel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 94,
|
||||
"version": "7",
|
||||
"when": 1758998206326,
|
||||
"tag": "0094_perpetual_the_watchers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -633,7 +633,7 @@ export const chat = pgTable(
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
subdomain: text('subdomain').notNull(),
|
||||
identifier: text('identifier').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
@@ -652,8 +652,8 @@ export const chat = pgTable(
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
// Ensure subdomains are unique
|
||||
subdomainIdx: uniqueIndex('subdomain_idx').on(table.subdomain),
|
||||
// Ensure identifiers are unique
|
||||
identifierIdx: uniqueIndex('identifier_idx').on(table.identifier),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user