mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(hygiene): refactored routes to be more restful, reduced code surface area and removed redundant code (#1217)
* improvement(invitations): consolidate invite-error and invite pages, made API endpoints more restful and reduced code surface area for invitations by 50% * refactored logs API routes * refactor rate limit api route, consolidate usage check api endpoint * refactored chat page and invitations page * consolidate ollama and openrouter stores to just providers store * removed unused route * removed legacy envvar methods * remove dead, legacy routes for invitations PUT and workflow SYNC * improvement(copilot): improve context inputs and fix some bugs (#1216) * Add logs v1 * Update * Updates * Updates * Fixes * Fix current workflow in context * Fix mentions * Error handling * Fix chat loading * Hide current workflow from context * Run workflow fix * Lint * updated invitation log * styling for invitation pages --------- Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
This commit is contained in:
@@ -10,7 +10,6 @@ import type { EnvironmentVariable } from '@/stores/settings/environment/types'
|
||||
|
||||
const logger = createLogger('EnvironmentAPI')
|
||||
|
||||
// Schema for environment variable updates
|
||||
const EnvVarSchema = z.object({
|
||||
variables: z.record(z.string()),
|
||||
})
|
||||
@@ -30,7 +29,6 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { variables } = EnvVarSchema.parse(body)
|
||||
|
||||
// Encrypt all variables
|
||||
const encryptedVariables = await Promise.all(
|
||||
Object.entries(variables).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
@@ -38,7 +36,6 @@ export async function POST(req: NextRequest) {
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
// Replace all environment variables for user
|
||||
await db
|
||||
.insert(environment)
|
||||
.values({
|
||||
@@ -78,7 +75,6 @@ export async function GET(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// Get the session directly in the API route
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
|
||||
@@ -97,18 +93,15 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ data: {} }, { status: 200 })
|
||||
}
|
||||
|
||||
// Decrypt the variables for client-side use
|
||||
const encryptedVariables = result[0].variables as Record<string, string>
|
||||
const decryptedVariables: Record<string, EnvironmentVariable> = {}
|
||||
|
||||
// Decrypt each variable
|
||||
for (const [key, encryptedValue] of Object.entries(encryptedVariables)) {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(encryptedValue)
|
||||
decryptedVariables[key] = { key, value: decrypted }
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error decrypting variable ${key}`, error)
|
||||
// If decryption fails, provide a placeholder
|
||||
decryptedVariables[key] = { key, value: '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getEnvironmentVariableKeys } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/utils'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('EnvironmentVariablesAPI')
|
||||
|
||||
// Schema for environment variable updates
|
||||
const EnvVarSchema = z.object({
|
||||
variables: z.record(z.string()),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
// For GET requests, check for workflowId in query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
|
||||
// Use dual authentication pattern like other copilot tools
|
||||
const userId = await getUserId(requestId, workflowId || undefined)
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get only the variable names (keys), not values
|
||||
const result = await getEnvironmentVariableKeys(userId)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: result,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Environment variables fetch error`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Failed to get environment variables',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { workflowId, variables } = body
|
||||
|
||||
// Use dual authentication pattern like other copilot tools
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables set attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { variables: validatedVariables } = EnvVarSchema.parse({ variables })
|
||||
|
||||
// Get existing environment variables for this user
|
||||
const existingData = await db
|
||||
.select()
|
||||
.from(environment)
|
||||
.where(eq(environment.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
// Start with existing encrypted variables or empty object
|
||||
const existingEncryptedVariables =
|
||||
(existingData[0]?.variables as Record<string, string>) || {}
|
||||
|
||||
// Determine which variables are new or changed by comparing with decrypted existing values
|
||||
const variablesToEncrypt: Record<string, string> = {}
|
||||
const addedVariables: string[] = []
|
||||
const updatedVariables: string[] = []
|
||||
|
||||
for (const [key, newValue] of Object.entries(validatedVariables)) {
|
||||
if (!(key in existingEncryptedVariables)) {
|
||||
// New variable
|
||||
variablesToEncrypt[key] = newValue
|
||||
addedVariables.push(key)
|
||||
} else {
|
||||
// Check if the value has actually changed by decrypting the existing value
|
||||
try {
|
||||
const { decrypted: existingValue } = await decryptSecret(
|
||||
existingEncryptedVariables[key]
|
||||
)
|
||||
|
||||
if (existingValue !== newValue) {
|
||||
// Value changed, needs re-encryption
|
||||
variablesToEncrypt[key] = newValue
|
||||
updatedVariables.push(key)
|
||||
}
|
||||
// If values are the same, keep the existing encrypted value
|
||||
} catch (decryptError) {
|
||||
// If we can't decrypt the existing value, treat as changed and re-encrypt
|
||||
logger.warn(
|
||||
`[${requestId}] Could not decrypt existing variable ${key}, re-encrypting`,
|
||||
{
|
||||
error: decryptError,
|
||||
}
|
||||
)
|
||||
variablesToEncrypt[key] = newValue
|
||||
updatedVariables.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only encrypt the variables that are new or changed
|
||||
const newlyEncryptedVariables = await Promise.all(
|
||||
Object.entries(variablesToEncrypt).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
// Merge existing encrypted variables with newly encrypted ones
|
||||
const finalEncryptedVariables = { ...existingEncryptedVariables, ...newlyEncryptedVariables }
|
||||
|
||||
// Update or insert environment variables for user
|
||||
await db
|
||||
.insert(environment)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: userId,
|
||||
variables: finalEncryptedVariables,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [environment.userId],
|
||||
set: {
|
||||
variables: finalEncryptedVariables,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully processed ${Object.keys(validatedVariables).length} environment variable(s): ${addedVariables.length} added, ${updatedVariables.length} updated`,
|
||||
variableCount: Object.keys(validatedVariables).length,
|
||||
variableNames: Object.keys(validatedVariables),
|
||||
totalVariableCount: Object.keys(finalEncryptedVariables).length,
|
||||
addedVariables,
|
||||
updatedVariables,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (validationError) {
|
||||
if (validationError instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid environment variables data`, {
|
||||
errors: validationError.errors,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: validationError.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
throw validationError
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Environment variables set error`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Failed to set environment variables',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { workflowId } = body
|
||||
|
||||
// Use dual authentication pattern like other copilot tools
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized environment variables access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get only the variable names (keys), not values
|
||||
const result = await getEnvironmentVariableKeys(userId)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
output: result,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Environment variables fetch error`, error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message || 'Failed to get environment variables',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setupFileApiMocks } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
/**
|
||||
* Tests for file presigned API route
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
describe('/api/files/presigned', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -19,7 +25,7 @@ describe('/api/files/presigned', () => {
|
||||
})
|
||||
|
||||
describe('POST', () => {
|
||||
test('should return error when cloud storage is not enabled', async () => {
|
||||
it('should return error when cloud storage is not enabled', async () => {
|
||||
setupFileApiMocks({
|
||||
cloudEnabled: false,
|
||||
storageProvider: 's3',
|
||||
@@ -39,7 +45,7 @@ describe('/api/files/presigned', () => {
|
||||
const response = await POST(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500) // Changed from 400 to 500 (StorageConfigError)
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.error).toBe('Direct uploads are only available when cloud storage is enabled')
|
||||
expect(data.code).toBe('STORAGE_CONFIG_ERROR')
|
||||
expect(data.directUploadSupported).toBe(false)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { workflowExecutionLogs, workflowExecutionSnapshots } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('FrozenCanvasAPI')
|
||||
const logger = createLogger('LogsByExecutionIdAPI')
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
@@ -13,7 +13,7 @@ export async function GET(
|
||||
try {
|
||||
const { executionId } = await params
|
||||
|
||||
logger.debug(`Fetching frozen canvas data for execution: ${executionId}`)
|
||||
logger.debug(`Fetching execution data for: ${executionId}`)
|
||||
|
||||
// Get the workflow execution log to find the snapshot
|
||||
const [workflowLog] = await db
|
||||
@@ -50,14 +50,14 @@ export async function GET(
|
||||
},
|
||||
}
|
||||
|
||||
logger.debug(`Successfully fetched frozen canvas data for execution: ${executionId}`)
|
||||
logger.debug(`Successfully fetched execution data for: ${executionId}`)
|
||||
logger.debug(
|
||||
`Workflow state contains ${Object.keys((snapshot.stateData as any)?.blocks || {}).length} blocks`
|
||||
)
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching frozen canvas data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch frozen canvas data' }, { status: 500 })
|
||||
logger.error('Error fetching execution data:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch execution data' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
permissions,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitation')
|
||||
|
||||
// Get invitation details
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; invitationId: string }> }
|
||||
) {
|
||||
const { id: organizationId, invitationId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const orgInvitation = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!orgInvitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const org = await db
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!org) {
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
invitation: orgInvitation,
|
||||
organization: org,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organization invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; invitationId: string }> }
|
||||
) {
|
||||
const { id: organizationId, invitationId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { status } = await req.json()
|
||||
|
||||
if (!status || !['accepted', 'rejected', 'cancelled'].includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid status. Must be "accepted", "rejected", or "cancelled"' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const orgInvitation = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.id, invitationId), eq(invitation.organizationId, organizationId)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!orgInvitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (status === 'accepted') {
|
||||
const userData = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!userData || userData.email.toLowerCase() !== orgInvitation.email.toLowerCase()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Email mismatch. You can only accept invitations sent to your email address.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'cancelled') {
|
||||
const isAdmin = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, organizationId),
|
||||
eq(member.userId, session.user.id),
|
||||
eq(member.role, 'admin')
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.length > 0)
|
||||
|
||||
if (!isAdmin) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only organization admins can cancel invitations' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
|
||||
|
||||
if (status === 'accepted') {
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
const linkedWorkspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.orgInvitationId, invitationId),
|
||||
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus)
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of linkedWorkspaceInvitations) {
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted' as WorkspaceInvitationStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: wsInvitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
|
||||
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`Organization invitation ${status}`, {
|
||||
organizationId,
|
||||
invitationId,
|
||||
userId: session.user.id,
|
||||
email: orgInvitation.email,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Invitation ${status} successfully`,
|
||||
invitation: { ...orgInvitation, status },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Error updating organization invitation:`, error)
|
||||
return NextResponse.json({ error: 'Failed to update invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,17 @@ import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, organization, user, workspace, workspaceInvitation } from '@/db/schema'
|
||||
import {
|
||||
invitation,
|
||||
member,
|
||||
organization,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationsAPI')
|
||||
const logger = createLogger('OrganizationInvitations')
|
||||
|
||||
interface WorkspaceInvitation {
|
||||
workspaceId: string
|
||||
@@ -40,7 +48,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { id: organizationId } = await params
|
||||
|
||||
// Verify user has access to this organization
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -61,7 +68,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all pending invitations for the organization
|
||||
const invitations = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
@@ -118,10 +124,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const body = await request.json()
|
||||
const { email, emails, role = 'member', workspaceInvitations } = body
|
||||
|
||||
// Handle single invitation vs batch
|
||||
const invitationEmails = email ? [email] : emails
|
||||
|
||||
// Validate input
|
||||
if (!invitationEmails || !Array.isArray(invitationEmails) || invitationEmails.length === 0) {
|
||||
return NextResponse.json({ error: 'Email or emails array is required' }, { status: 400 })
|
||||
}
|
||||
@@ -130,7 +134,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -148,7 +151,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Handle validation-only requests
|
||||
if (validateOnly) {
|
||||
const validationResult = await validateBulkInvitations(organizationId, invitationEmails)
|
||||
|
||||
@@ -167,7 +169,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
// Validate seat availability
|
||||
const seatValidation = await validateSeatAvailability(organizationId, invitationEmails.length)
|
||||
|
||||
if (!seatValidation.canInvite) {
|
||||
@@ -185,7 +186,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Get organization details
|
||||
const organizationEntry = await db
|
||||
.select({ name: organization.name })
|
||||
.from(organization)
|
||||
@@ -196,7 +196,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate and normalize emails
|
||||
const processedEmails = invitationEmails
|
||||
.map((email: string) => {
|
||||
const normalized = email.trim().toLowerCase()
|
||||
@@ -209,11 +208,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'No valid emails provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Handle batch workspace invitations if provided
|
||||
const validWorkspaceInvitations: WorkspaceInvitation[] = []
|
||||
if (isBatch && workspaceInvitations && workspaceInvitations.length > 0) {
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
// Check if user has admin permission on this workspace
|
||||
const canInvite = await hasWorkspaceAdminAccess(session.user.id, wsInvitation.workspaceId)
|
||||
|
||||
if (!canInvite) {
|
||||
@@ -229,7 +226,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing members
|
||||
const existingMembers = await db
|
||||
.select({ userEmail: user.email })
|
||||
.from(member)
|
||||
@@ -239,7 +235,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const existingEmails = existingMembers.map((m) => m.userEmail)
|
||||
const newEmails = processedEmails.filter((email: string) => !existingEmails.includes(email))
|
||||
|
||||
// Check for existing pending invitations
|
||||
const existingInvitations = await db
|
||||
.select({ email: invitation.email })
|
||||
.from(invitation)
|
||||
@@ -265,7 +260,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Create invitations
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||
const invitationsToCreate = emailsToInvite.map((email: string) => ({
|
||||
id: randomUUID(),
|
||||
@@ -280,7 +274,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
await db.insert(invitation).values(invitationsToCreate)
|
||||
|
||||
// Create workspace invitations if batch mode
|
||||
const workspaceInvitationIds: string[] = []
|
||||
if (isBatch && validWorkspaceInvitations.length > 0) {
|
||||
for (const email of emailsToInvite) {
|
||||
@@ -309,7 +302,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Send invitation emails
|
||||
const inviter = await db
|
||||
.select({ name: user.name })
|
||||
.from(user)
|
||||
@@ -322,7 +314,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
let emailResult
|
||||
if (isBatch && validWorkspaceInvitations.length > 0) {
|
||||
// Get workspace details for batch email
|
||||
const workspaceDetails = await db
|
||||
.select({
|
||||
id: workspace.id,
|
||||
@@ -348,7 +339,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
role,
|
||||
workspaceInvitationsWithNames,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`
|
||||
)
|
||||
|
||||
emailResult = await sendEmail({
|
||||
@@ -361,7 +352,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${orgInvitation.id}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${orgInvitation.id}`,
|
||||
email
|
||||
)
|
||||
|
||||
@@ -448,7 +439,6 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Verify user has admin access
|
||||
const memberEntry = await db
|
||||
.select()
|
||||
.from(member)
|
||||
@@ -466,7 +456,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Cancel the invitation
|
||||
const result = await db
|
||||
.update(invitation)
|
||||
.set({ status: 'cancelled' })
|
||||
@@ -486,22 +475,19 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Also cancel any linked workspace invitations created as part of the batch
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' })
|
||||
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
|
||||
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
|
||||
|
||||
// Legacy fallback: cancel any pending workspace invitations for the same email
|
||||
// that do not have an orgInvitationId and were created by the same inviter
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' })
|
||||
.set({ status: 'cancelled' as WorkspaceInvitationStatus })
|
||||
.where(
|
||||
and(
|
||||
isNull(workspaceInvitation.orgInvitationId),
|
||||
eq(workspaceInvitation.email, result[0].email),
|
||||
eq(workspaceInvitation.status, 'pending'),
|
||||
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus),
|
||||
eq(workspaceInvitation.inviterId, session.user.id)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -260,7 +260,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailHtml = await renderInvitationEmail(
|
||||
inviter[0]?.name || 'Someone',
|
||||
organizationEntry[0]?.name || 'organization',
|
||||
`${env.NEXT_PUBLIC_APP_URL}/api/organizations/invitations/accept?id=${invitationId}`,
|
||||
`${env.NEXT_PUBLIC_APP_URL}/invite/organization?id=${invitationId}`,
|
||||
normalizedEmail
|
||||
)
|
||||
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { invitation, member, permissions, user, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('OrganizationInvitationAcceptanceAPI')
|
||||
|
||||
// Accept an organization invitation and any associated workspace invitations
|
||||
export async function GET(req: NextRequest) {
|
||||
const invitationId = req.nextUrl.searchParams.get('id')
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=missing-invitation-id',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// Redirect to login, user will be redirected back after login
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/organization?id=${invitationId}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the organization invitation
|
||||
const invitationResult = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(eq(invitation.id, invitationId))
|
||||
.limit(1)
|
||||
|
||||
if (invitationResult.length === 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=invalid-invitation',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const orgInvitation = invitationResult[0]
|
||||
|
||||
// Check if invitation has expired
|
||||
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invitation is still pending
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-processed',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Get user data to check email verification status
|
||||
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (userData.length === 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=user-not-found',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the email matches the current user
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${orgInvitation.email}, but you're logged in as ${userData[0].email}`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is already a member of the organization
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgInvitation.organizationId),
|
||||
eq(member.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-member',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Start transaction to accept both organization and workspace invitations
|
||||
await db.transaction(async (tx) => {
|
||||
// Accept organization invitation - add user as member
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark organization invitation as accepted
|
||||
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
|
||||
|
||||
// Find and accept any pending workspace invitations linked to this org invite.
|
||||
// For backward compatibility, also include legacy pending invites by email with no org link.
|
||||
const workspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.status, 'pending'),
|
||||
or(
|
||||
eq(workspaceInvitation.orgInvitationId, invitationId),
|
||||
and(
|
||||
isNull(workspaceInvitation.orgInvitationId),
|
||||
eq(workspaceInvitation.email, orgInvitation.email)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
// Check if invitation hasn't expired
|
||||
if (
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
// Check if user doesn't already have permissions on the workspace
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, wsInvitation.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingPermission.length === 0) {
|
||||
// Add workspace permissions
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
permissionType: wsInvitation.permissions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark workspace invitation as accepted
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'accepted' })
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
logger.info('Accepted workspace invitation', {
|
||||
workspaceId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permission: wsInvitation.permissions,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Successfully accepted batch invitation', {
|
||||
organizationId: orgInvitation.organizationId,
|
||||
userId: session.user.id,
|
||||
role: orgInvitation.role,
|
||||
})
|
||||
|
||||
// Redirect to success page or main app
|
||||
return NextResponse.redirect(
|
||||
new URL('/workspaces?invite=accepted', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept organization invitation', {
|
||||
invitationId,
|
||||
userId: session.user.id,
|
||||
error,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=server-error',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST endpoint for programmatic acceptance (for API use)
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { invitationId } = await req.json()
|
||||
|
||||
if (!invitationId) {
|
||||
return NextResponse.json({ error: 'Missing invitationId' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Similar logic to GET but return JSON response
|
||||
const invitationResult = await db
|
||||
.select()
|
||||
.from(invitation)
|
||||
.where(eq(invitation.id, invitationId))
|
||||
.limit(1)
|
||||
|
||||
if (invitationResult.length === 0) {
|
||||
return NextResponse.json({ error: 'Invalid invitation' }, { status: 404 })
|
||||
}
|
||||
|
||||
const orgInvitation = invitationResult[0]
|
||||
|
||||
if (orgInvitation.expiresAt && new Date() > orgInvitation.expiresAt) {
|
||||
return NextResponse.json({ error: 'Invitation expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (orgInvitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Invitation already processed' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get user data to check email verification status
|
||||
const userData = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
|
||||
if (userData.length === 0) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMember = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, orgInvitation.organizationId),
|
||||
eq(member.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember.length > 0) {
|
||||
return NextResponse.json({ error: 'Already a member' }, { status: 400 })
|
||||
}
|
||||
|
||||
let acceptedWorkspaces = 0
|
||||
|
||||
// Accept invitations in transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Accept organization invitation
|
||||
await tx.insert(member).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
role: orgInvitation.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
|
||||
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
|
||||
|
||||
// Accept workspace invitations
|
||||
const workspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.email, orgInvitation.email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInvitation of workspaceInvitations) {
|
||||
if (
|
||||
wsInvitation.expiresAt &&
|
||||
new Date().toISOString() <= wsInvitation.expiresAt.toISOString()
|
||||
) {
|
||||
const existingPermission = await tx
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, session.user.id),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, wsInvitation.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingPermission.length === 0) {
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
userId: session.user.id,
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
permissionType: wsInvitation.permissions,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'accepted' })
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
acceptedWorkspaces++
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Successfully joined organization and ${acceptedWorkspaces} workspace(s)`,
|
||||
organizationId: orgInvitation.organizationId,
|
||||
workspacesJoined: acceptedWorkspaces,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept organization invitation via API', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UsageCheckAPI')
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
const session = await getSession()
|
||||
try {
|
||||
const userId = session?.user?.id
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const result = await checkServerSideUsageLimits(userId)
|
||||
// Normalize to client usage shape
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
percentUsed:
|
||||
result.limit > 0
|
||||
? Math.min(Math.floor((result.currentUsage / result.limit) * 100), 100)
|
||||
: 0,
|
||||
isWarning:
|
||||
result.limit > 0
|
||||
? (result.currentUsage / result.limit) * 100 >= 80 &&
|
||||
(result.currentUsage / result.limit) * 100 < 100
|
||||
: false,
|
||||
isExceeded: result.isExceeded,
|
||||
currentUsage: result.currentUsage,
|
||||
limit: result.limit,
|
||||
message: result.message,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed usage check', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
} from '@/lib/billing/core/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('UnifiedUsageLimitsAPI')
|
||||
const logger = createLogger('UnifiedUsageAPI')
|
||||
|
||||
/**
|
||||
* Unified Usage Limits Endpoint
|
||||
* GET/PUT /api/usage-limits?context=user|organization&userId=<id>&organizationId=<id>
|
||||
* Unified Usage Endpoint
|
||||
* GET/PUT /api/usage?context=user|organization&userId=<id>&organizationId=<id>
|
||||
*
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -11,15 +11,12 @@ const logger = createLogger('RateLimitAPI')
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Try session auth first (for web UI)
|
||||
const session = await getSession()
|
||||
let authenticatedUserId: string | null = session?.user?.id || null
|
||||
|
||||
// If no session, check for API key auth
|
||||
if (!authenticatedUserId) {
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
// Verify API key
|
||||
const [apiKeyRecord] = await db
|
||||
.select({ userId: apiKeyTable.userId })
|
||||
.from(apiKeyTable)
|
||||
@@ -36,7 +33,6 @@ export async function GET(request: NextRequest) {
|
||||
return createErrorResponse('Authentication required', 401)
|
||||
}
|
||||
|
||||
// Get user subscription
|
||||
const [subscriptionRecord] = await db
|
||||
.select({ plan: subscription.plan })
|
||||
.from(subscription)
|
||||
@@ -1,10 +1,12 @@
|
||||
import crypto from 'crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workflowBlocks } from '@/db/schema'
|
||||
import { workflow, workflowBlocks, workspace } from '@/db/schema'
|
||||
import { verifyWorkspaceMembership } from './utils'
|
||||
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
|
||||
@@ -16,6 +18,68 @@ const CreateWorkflowSchema = z.object({
|
||||
folderId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
|
||||
export async function GET(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const startTime = Date.now()
|
||||
const url = new URL(request.url)
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
if (workspaceId) {
|
||||
const workspaceExists = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then((rows) => rows.length > 0)
|
||||
|
||||
if (!workspaceExists) {
|
||||
logger.warn(
|
||||
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
|
||||
|
||||
if (!userRole) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let workflows
|
||||
|
||||
if (workspaceId) {
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
|
||||
} else {
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: workflows }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/workflows - Create a new workflow
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
@@ -36,114 +100,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
|
||||
|
||||
// Create initial state with start block
|
||||
const initialState = {
|
||||
blocks: {
|
||||
[starterId]: {
|
||||
id: starterId,
|
||||
type: 'starter',
|
||||
name: 'Start',
|
||||
position: { x: 100, y: 100 },
|
||||
subBlocks: {
|
||||
startWorkflow: {
|
||||
id: 'startWorkflow',
|
||||
type: 'dropdown',
|
||||
value: 'manual',
|
||||
},
|
||||
webhookPath: {
|
||||
id: 'webhookPath',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
webhookSecret: {
|
||||
id: 'webhookSecret',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
scheduleType: {
|
||||
id: 'scheduleType',
|
||||
type: 'dropdown',
|
||||
value: 'daily',
|
||||
},
|
||||
minutesInterval: {
|
||||
id: 'minutesInterval',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
minutesStartingAt: {
|
||||
id: 'minutesStartingAt',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
hourlyMinute: {
|
||||
id: 'hourlyMinute',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
dailyTime: {
|
||||
id: 'dailyTime',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
weeklyDay: {
|
||||
id: 'weeklyDay',
|
||||
type: 'dropdown',
|
||||
value: 'MON',
|
||||
},
|
||||
weeklyDayTime: {
|
||||
id: 'weeklyDayTime',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
monthlyDay: {
|
||||
id: 'monthlyDay',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
monthlyTime: {
|
||||
id: 'monthlyTime',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
cronExpression: {
|
||||
id: 'cronExpression',
|
||||
type: 'short-input',
|
||||
value: '',
|
||||
},
|
||||
timezone: {
|
||||
id: 'timezone',
|
||||
type: 'dropdown',
|
||||
value: 'UTC',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
response: {
|
||||
type: {
|
||||
input: 'any',
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
height: 95,
|
||||
},
|
||||
},
|
||||
edges: [],
|
||||
subflows: {},
|
||||
variables: {},
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
// Create the workflow and start block in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Create the workflow
|
||||
await tx.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: session.user.id,
|
||||
@@ -163,7 +120,6 @@ export async function POST(req: NextRequest) {
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
// Insert the start block into workflow_blocks table
|
||||
await tx.insert(workflowBlocks).values({
|
||||
id: starterId,
|
||||
workflowId: workflowId,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import crypto from 'crypto'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workflow, workspace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowAPI')
|
||||
|
||||
/**
|
||||
* Verifies user's workspace permissions using the permissions table
|
||||
* @param userId User ID to check
|
||||
* @param workspaceId Workspace ID to check
|
||||
* @returns Permission type if user has access, null otherwise
|
||||
*/
|
||||
async function verifyWorkspaceMembership(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
|
||||
return permission
|
||||
} catch (error) {
|
||||
logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const startTime = Date.now()
|
||||
const url = new URL(request.url)
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
// Get the session directly in the API route
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// If workspaceId is provided, verify it exists and user is a member
|
||||
if (workspaceId) {
|
||||
// Check workspace exists first
|
||||
const workspaceExists = await db
|
||||
.select({ id: workspace.id })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then((rows) => rows.length > 0)
|
||||
|
||||
if (!workspaceExists) {
|
||||
logger.warn(
|
||||
`[${requestId}] Attempt to fetch workflows for non-existent workspace: ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace not found', code: 'WORKSPACE_NOT_FOUND' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the user is a member of the workspace using our optimized function
|
||||
const userRole = await verifyWorkspaceMembership(userId, workspaceId)
|
||||
|
||||
if (!userRole) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to access workspace ${workspaceId} without membership`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Migrate any orphaned workflows to this workspace (in background)
|
||||
migrateOrphanedWorkflows(userId, workspaceId).catch((error) => {
|
||||
logger.error(`[${requestId}] Error migrating orphaned workflows:`, error)
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch workflows for the user
|
||||
let workflows
|
||||
|
||||
if (workspaceId) {
|
||||
// Filter by workspace ID only, not user ID
|
||||
// This allows sharing workflows across workspace members
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.workspaceId, workspaceId))
|
||||
} else {
|
||||
// Filter by user ID only, including workflows without workspace IDs
|
||||
workflows = await db.select().from(workflow).where(eq(workflow.userId, userId))
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
// Return the workflows
|
||||
return NextResponse.json({ data: workflows }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.error(`[${requestId}] Workflow fetch error after ${elapsed}ms`, error)
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to migrate orphaned workflows to a workspace
|
||||
async function migrateOrphanedWorkflows(userId: string, workspaceId: string) {
|
||||
try {
|
||||
// Find workflows without workspace IDs for this user
|
||||
const orphanedWorkflows = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
|
||||
|
||||
if (orphanedWorkflows.length === 0) {
|
||||
return // No orphaned workflows to migrate
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Migrating ${orphanedWorkflows.length} orphaned workflows to workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
// Update workflows in batch if possible
|
||||
try {
|
||||
// Batch update all orphaned workflows
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
workspaceId: workspaceId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId)))
|
||||
|
||||
logger.info(
|
||||
`Successfully migrated ${orphanedWorkflows.length} workflows to workspace ${workspaceId}`
|
||||
)
|
||||
} catch (batchError) {
|
||||
logger.warn('Batch migration failed, falling back to individual updates:', batchError)
|
||||
|
||||
// Fallback to individual updates if batch update fails
|
||||
for (const { id } of orphanedWorkflows) {
|
||||
try {
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
workspaceId: workspaceId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, id))
|
||||
} catch (updateError) {
|
||||
logger.error(`Failed to migrate workflow ${id}:`, updateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error migrating orphaned workflows:', error)
|
||||
// Continue execution even if migration fails
|
||||
}
|
||||
}
|
||||
|
||||
// POST method removed - workflow operations now handled by:
|
||||
// - POST /api/workflows (create)
|
||||
// - DELETE /api/workflows/[id] (delete)
|
||||
// - Socket.IO collaborative operations (real-time updates)
|
||||
@@ -1,4 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
export function createErrorResponse(error: string, status: number, code?: string) {
|
||||
return NextResponse.json(
|
||||
@@ -13,3 +17,23 @@ export function createErrorResponse(error: string, status: number, code?: string
|
||||
export function createSuccessResponse(data: any) {
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies user's workspace permissions using the permissions table
|
||||
* @param userId User ID to check
|
||||
* @param workspaceId Workspace ID to check
|
||||
* @returns Permission type if user has access, null otherwise
|
||||
*/
|
||||
export async function verifyWorkspaceMembership(
|
||||
userId: string,
|
||||
workspaceId: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
|
||||
return permission
|
||||
} catch (error) {
|
||||
logger.error(`Error verifying workspace permissions for ${userId} in ${workspaceId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { DELETE } from '@/app/api/workspaces/invitations/[id]/route'
|
||||
import { db } from '@/db'
|
||||
import { workspaceInvitation } from '@/db/schema'
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/db', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/db/schema', () => ({
|
||||
workspaceInvitation: {
|
||||
id: 'id',
|
||||
workspaceId: 'workspaceId',
|
||||
email: 'email',
|
||||
inviterId: 'inviterId',
|
||||
status: 'status',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
|
||||
}))
|
||||
|
||||
describe('DELETE /api/workspaces/invitations/[id]', () => {
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: 'user123',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
image: null,
|
||||
stripeCustomerId: null,
|
||||
},
|
||||
session: {
|
||||
id: 'session123',
|
||||
token: 'token123',
|
||||
userId: 'user123',
|
||||
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
ipAddress: null,
|
||||
userAgent: null,
|
||||
activeOrganizationId: null,
|
||||
},
|
||||
}
|
||||
|
||||
const mockInvitation = {
|
||||
id: 'invitation123',
|
||||
workspaceId: 'workspace456',
|
||||
email: 'invited@example.com',
|
||||
inviterId: 'inviter789',
|
||||
status: 'pending',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 404 when invitation does not exist', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation not found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Simulate empty rows array
|
||||
return Promise.resolve(callback([]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'non-existent' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toEqual({ error: 'Invitation not found' })
|
||||
})
|
||||
|
||||
it('should return 403 when user does not have admin access', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([mockInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user does not have admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(false)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'Insufficient permissions' })
|
||||
expect(hasWorkspaceAdminAccess).toHaveBeenCalledWith('user123', 'workspace456')
|
||||
})
|
||||
|
||||
it('should return 400 when trying to delete non-pending invitation', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation with accepted status
|
||||
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([acceptedInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user has admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: 'Can only delete pending invitations' })
|
||||
})
|
||||
|
||||
it('should successfully delete pending invitation when user has admin access', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock invitation found
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn((callback: (rows: any[]) => any) => {
|
||||
// Return the first invitation from the array
|
||||
return Promise.resolve(callback([mockInvitation]))
|
||||
}),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
// Mock user has admin access
|
||||
vi.mocked(hasWorkspaceAdminAccess).mockResolvedValue(true)
|
||||
|
||||
// Mock successful deletion
|
||||
const mockDelete = {
|
||||
where: vi.fn().mockResolvedValue({ rowCount: 1 }),
|
||||
}
|
||||
vi.mocked(db.delete).mockReturnValue(mockDelete as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({ success: true })
|
||||
expect(db.delete).toHaveBeenCalledWith(workspaceInvitation)
|
||||
expect(mockDelete.where).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return 500 when database error occurs', async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(mockSession)
|
||||
|
||||
// Mock database error
|
||||
const mockQuery = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockRejectedValue(new Error('Database connection failed')),
|
||||
}
|
||||
vi.mocked(db.select).mockReturnValue(mockQuery as any)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/workspaces/invitations/invitation123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
const params = Promise.resolve({ id: 'invitation123' })
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response).toBeInstanceOf(NextResponse)
|
||||
const data = await response.json()
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toEqual({ error: 'Failed to delete invitation' })
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// DELETE /api/workspaces/invitations/[id] - Delete a workspace invitation
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the invitation to delete
|
||||
const invitation = await db
|
||||
.select({
|
||||
id: workspaceInvitation.id,
|
||||
workspaceId: workspaceInvitation.workspaceId,
|
||||
email: workspaceInvitation.email,
|
||||
inviterId: workspaceInvitation.inviterId,
|
||||
status: workspaceInvitation.status,
|
||||
})
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.id, id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if current user has admin access to the workspace
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Only allow deleting pending invitations
|
||||
if (invitation.status !== 'pending') {
|
||||
return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Delete the invitation
|
||||
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mockAuth, mockConsoleLogger } from '@/app/api/__test-utils__/utils'
|
||||
|
||||
/**
|
||||
* Tests for workspace invitation by ID API route
|
||||
* Tests GET (details + token acceptance), DELETE (cancellation)
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
describe('Workspace Invitation [invitationId] API Route', () => {
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
}
|
||||
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-456',
|
||||
name: 'Test Workspace',
|
||||
}
|
||||
|
||||
const mockInvitation = {
|
||||
id: 'invitation-789',
|
||||
workspaceId: 'workspace-456',
|
||||
email: 'invited@example.com',
|
||||
inviterId: 'inviter-321',
|
||||
status: 'pending',
|
||||
token: 'token-abc123',
|
||||
permissions: 'read',
|
||||
expiresAt: new Date(Date.now() + 86400000), // 1 day from now
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
let mockDbResults: any[] = []
|
||||
let mockGetSession: any
|
||||
let mockHasWorkspaceAdminAccess: any
|
||||
let mockTransaction: any
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.resetAllMocks()
|
||||
|
||||
mockDbResults = []
|
||||
mockConsoleLogger()
|
||||
mockAuth(mockUser)
|
||||
|
||||
vi.doMock('crypto', () => ({
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234'),
|
||||
}))
|
||||
|
||||
mockGetSession = vi.fn()
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
mockHasWorkspaceAdminAccess = vi.fn()
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/env', () => ({
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
|
||||
},
|
||||
}))
|
||||
|
||||
mockTransaction = vi.fn()
|
||||
const mockDbChain = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockImplementation((callback: any) => {
|
||||
const result = mockDbResults.shift() || []
|
||||
return callback ? callback(result) : Promise.resolve(result)
|
||||
}),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
transaction: mockTransaction,
|
||||
}
|
||||
|
||||
vi.doMock('@/db', () => ({
|
||||
db: mockDbChain,
|
||||
}))
|
||||
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
workspaceInvitation: {
|
||||
id: 'id',
|
||||
workspaceId: 'workspaceId',
|
||||
email: 'email',
|
||||
inviterId: 'inviterId',
|
||||
status: 'status',
|
||||
token: 'token',
|
||||
permissions: 'permissions',
|
||||
expiresAt: 'expiresAt',
|
||||
},
|
||||
workspace: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
user: {
|
||||
id: 'id',
|
||||
email: 'email',
|
||||
},
|
||||
permissions: {
|
||||
id: 'id',
|
||||
entityType: 'entityType',
|
||||
entityId: 'entityId',
|
||||
userId: 'userId',
|
||||
permissionType: 'permissionType',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn((a, b) => ({ type: 'eq', a, b })),
|
||||
and: vi.fn((...args) => ({ type: 'and', args })),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('GET /api/workspaces/invitations/[invitationId]', () => {
|
||||
it('should return invitation details when called without token', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/invitation-789')
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toMatchObject({
|
||||
id: 'invitation-789',
|
||||
email: 'invited@example.com',
|
||||
status: 'pending',
|
||||
workspaceName: 'Test Workspace',
|
||||
})
|
||||
})
|
||||
|
||||
it('should redirect to login when unauthenticated with token', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/token-abc123?token=token-abc123'
|
||||
)
|
||||
})
|
||||
|
||||
it('should accept invitation when called with valid token', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'invited@example.com' },
|
||||
})
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
mockDbResults.push([{ ...mockUser, email: 'invited@example.com' }])
|
||||
mockDbResults.push([])
|
||||
|
||||
mockTransaction.mockImplementation(async (callback: any) => {
|
||||
await callback({
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
})
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe('https://test.sim.ai/workspace/workspace-456/w')
|
||||
})
|
||||
|
||||
it('should redirect to error page when invitation expired', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'invited@example.com' },
|
||||
})
|
||||
|
||||
const expiredInvitation = {
|
||||
...mockInvitation,
|
||||
expiresAt: new Date(Date.now() - 86400000), // 1 day ago
|
||||
}
|
||||
|
||||
mockDbResults.push([expiredInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=expired'
|
||||
)
|
||||
})
|
||||
|
||||
it('should redirect to error page when email mismatch', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { ...mockUser, email: 'wrong@example.com' },
|
||||
})
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
mockDbResults.push([mockWorkspace])
|
||||
mockDbResults.push([{ ...mockUser, email: 'wrong@example.com' }])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/token-abc123?token=token-abc123'
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'token-abc123' })
|
||||
|
||||
const response = await GET(request, { params })
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.headers.get('location')).toBe(
|
||||
'https://test.sim.ai/invite/invitation-789?error=email-mismatch'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/workspaces/invitations/[invitationId]', () => {
|
||||
it('should return 401 when user is not authenticated', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('should return 404 when invitation does not exist', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
|
||||
mockDbResults.push([])
|
||||
|
||||
const request = new NextRequest('http://localhost/api/workspaces/invitations/non-existent', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ invitationId: 'non-existent' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toEqual({ error: 'Invitation not found' })
|
||||
})
|
||||
|
||||
it('should return 403 when user lacks admin access', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(false)
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toEqual({ error: 'Insufficient permissions' })
|
||||
expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-123', 'workspace-456')
|
||||
})
|
||||
|
||||
it('should return 400 when trying to delete non-pending invitation', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
|
||||
const acceptedInvitation = { ...mockInvitation, status: 'accepted' }
|
||||
mockDbResults.push([acceptedInvitation])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toEqual({ error: 'Can only delete pending invitations' })
|
||||
})
|
||||
|
||||
it('should successfully delete pending invitation when user has admin access', async () => {
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
mockGetSession.mockResolvedValue({ user: mockUser })
|
||||
mockHasWorkspaceAdminAccess.mockResolvedValue(true)
|
||||
|
||||
mockDbResults.push([mockInvitation])
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('should return 500 when database error occurs', async () => {
|
||||
vi.resetModules()
|
||||
|
||||
const mockErrorDb = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: vi.fn().mockRejectedValue(new Error('Database connection failed')),
|
||||
}
|
||||
|
||||
vi.doMock('@/db', () => ({ db: mockErrorDb }))
|
||||
vi.doMock('@/lib/auth', () => ({
|
||||
getSession: vi.fn().mockResolvedValue({ user: mockUser }),
|
||||
}))
|
||||
vi.doMock('@/lib/permissions/utils', () => ({
|
||||
hasWorkspaceAdminAccess: vi.fn(),
|
||||
}))
|
||||
vi.doMock('@/db/schema', () => ({
|
||||
workspaceInvitation: { id: 'id' },
|
||||
}))
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
}))
|
||||
|
||||
const { DELETE } = await import('./route')
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/workspaces/invitations/invitation-789',
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
const params = Promise.resolve({ invitationId: 'invitation-789' })
|
||||
|
||||
const response = await DELETE(request, { params })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data).toEqual({ error: 'Failed to delete invitation' })
|
||||
})
|
||||
})
|
||||
})
|
||||
236
apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
Normal file
236
apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
permissions,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
|
||||
// GET /api/workspaces/invitations/[invitationId] - Get invitation details OR accept via token
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
const session = await getSession()
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
const isAcceptFlow = !!token // If token is provided, this is an acceptance flow
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// For token-based acceptance flows, redirect to login
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitationId}?token=${token}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const whereClause = token
|
||||
? eq(workspaceInvitation.token, token)
|
||||
: eq(workspaceInvitation.id, invitationId)
|
||||
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(whereClause)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=expired`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
if (isAcceptFlow) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=workspace-not-found`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (isAcceptFlow) {
|
||||
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=already-processed`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const userEmail = session.user.email.toLowerCase()
|
||||
const invitationEmail = invitation.email.toLowerCase()
|
||||
|
||||
const userData = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!userData) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=user-not-found`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const isValidMatch = userEmail === invitationEmail
|
||||
|
||||
if (!isValidMatch) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/${invitation.id}?error=email-mismatch`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const existingPermission = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, invitation.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingPermission) {
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted' as WorkspaceInvitationStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/workspace/${invitation.workspaceId}/w`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: invitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted' as WorkspaceInvitationStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/workspace/${invitation.workspaceId}/w`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
workspaceName: workspaceDetails.name,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/workspaces/invitations/[invitationId] - Delete a workspace invitation
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ invitationId: string }> }
|
||||
) {
|
||||
const { invitationId } = await params
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const invitation = await db
|
||||
.select({
|
||||
id: workspaceInvitation.id,
|
||||
workspaceId: workspaceInvitation.workspaceId,
|
||||
email: workspaceInvitation.email,
|
||||
inviterId: workspaceInvitation.inviterId,
|
||||
status: workspaceInvitation.status,
|
||||
})
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.id, invitationId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, invitation.workspaceId)
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (invitation.status !== ('pending' as WorkspaceInvitationStatus)) {
|
||||
return NextResponse.json({ error: 'Can only delete pending invitations' }, { status: 400 })
|
||||
}
|
||||
|
||||
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to delete invitation' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { db } from '@/db'
|
||||
import { permissions, user, workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// Accept an invitation via token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=missing-token',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
// No need to encode API URL as callback, just redirect to invite page
|
||||
// The middleware will handle proper login flow and return to invite page
|
||||
return NextResponse.redirect(
|
||||
new URL(`/invite/${token}?token=${token}`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the invitation by token
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.token, token))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=invalid-token',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invitation has expired
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
return NextResponse.redirect(
|
||||
new URL('/invite/invite-error?reason=expired', env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invitation is already accepted
|
||||
if (invitation.status !== 'pending') {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=already-processed',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Get the user's email from the session
|
||||
const userEmail = session.user.email.toLowerCase()
|
||||
const invitationEmail = invitation.email.toLowerCase()
|
||||
|
||||
// Get user data to check email verification status and for error messages
|
||||
const userData = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!userData) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=user-not-found',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the logged-in user's email matches the invitation
|
||||
const isValidMatch = userEmail === invitationEmail
|
||||
|
||||
if (!isValidMatch) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-mismatch&details=${encodeURIComponent(`Invitation was sent to ${invitation.email}, but you're logged in as ${userData.email}`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Get the workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=workspace-not-found',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user already has permissions for this workspace
|
||||
const existingPermission = await db
|
||||
.select()
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, invitation.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingPermission) {
|
||||
// User already has permissions, just mark the invitation as accepted and redirect
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/workspace/${invitation.workspaceId}/w`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Add user permissions and mark invitation as accepted in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
// Create permissions for the user
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace' as const,
|
||||
entityId: invitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: invitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
// Mark invitation as accepted
|
||||
await tx
|
||||
.update(workspaceInvitation)
|
||||
.set({
|
||||
status: 'accepted',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
})
|
||||
|
||||
// Redirect to the workspace
|
||||
return NextResponse.redirect(
|
||||
new URL(`/workspace/${invitation.workspaceId}/w`, env.NEXT_PUBLIC_APP_URL || 'https://sim.ai')
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error accepting invitation:', error)
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
'/invite/invite-error?reason=server-error',
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { db } from '@/db'
|
||||
import { workspace, workspaceInvitation } from '@/db/schema'
|
||||
|
||||
// Get invitation details by token
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the invitation by token
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(eq(workspaceInvitation.token, token))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: 'Invitation not found or has expired' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if invitation has expired
|
||||
if (new Date() > new Date(invitation.expiresAt)) {
|
||||
return NextResponse.json({ error: 'Invitation has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get workspace details
|
||||
const workspaceDetails = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, invitation.workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!workspaceDetails) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Return the invitation with workspace name
|
||||
return NextResponse.json({
|
||||
...invitation,
|
||||
workspaceName: workspaceDetails.name,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace invitation:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch invitation details' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
permissions,
|
||||
type permissionTypeEnum,
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceInvitation,
|
||||
} from '@/db/schema'
|
||||
@@ -162,7 +163,7 @@ export async function POST(req: NextRequest) {
|
||||
and(
|
||||
eq(workspaceInvitation.workspaceId, workspaceId),
|
||||
eq(workspaceInvitation.email, email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
eq(workspaceInvitation.status, 'pending' as WorkspaceInvitationStatus)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
@@ -189,7 +190,7 @@ export async function POST(req: NextRequest) {
|
||||
email,
|
||||
inviterId: session.user.id,
|
||||
role,
|
||||
status: 'pending',
|
||||
status: 'pending' as WorkspaceInvitationStatus,
|
||||
token,
|
||||
permissions: permission,
|
||||
expiresAt,
|
||||
@@ -205,6 +206,7 @@ export async function POST(req: NextRequest) {
|
||||
to: email,
|
||||
inviterName: session.user.name || session.user.email || 'A user',
|
||||
workspaceName: workspaceDetails.name,
|
||||
invitationId: invitationData.id,
|
||||
token: token,
|
||||
})
|
||||
|
||||
@@ -220,17 +222,19 @@ async function sendInvitationEmail({
|
||||
to,
|
||||
inviterName,
|
||||
workspaceName,
|
||||
invitationId,
|
||||
token,
|
||||
}: {
|
||||
to: string
|
||||
inviterName: string
|
||||
workspaceName: string
|
||||
invitationId: string
|
||||
token: string
|
||||
}) {
|
||||
try {
|
||||
const baseUrl = env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
// Always use the client-side invite route with token parameter
|
||||
const invitationLink = `${baseUrl}/invite/${token}?token=${token}`
|
||||
// Use invitation ID in path, token in query parameter for security
|
||||
const invitationLink = `${baseUrl}/invite/${invitationId}?token=${token}`
|
||||
|
||||
const emailHtml = await render(
|
||||
WorkspaceInvitationEmail({
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
EmailAuth,
|
||||
PasswordAuth,
|
||||
VoiceInterface,
|
||||
} from '@/app/chat/[subdomain]/components'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/[subdomain]/hooks'
|
||||
} from '@/app/chat/components'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/chat/hooks'
|
||||
|
||||
const logger = createLogger('ChatClient')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import './chat-client.css'
|
||||
import './chat.css'
|
||||
|
||||
export default function ChatLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ChatClient from '@/app/chat/[subdomain]/chat-client'
|
||||
import ChatClient from '@/app/chat/[subdomain]/chat'
|
||||
|
||||
export default async function ChatPage({ params }: { params: Promise<{ subdomain: string }> }) {
|
||||
const { subdomain } = await params
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Send, Square } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { VoiceInput } from '@/app/chat/[subdomain]/components/input/voice-input'
|
||||
import { VoiceInput } from '@/app/chat/components/input/voice-input'
|
||||
|
||||
const PLACEHOLDER_MOBILE = 'Enter a message'
|
||||
const PLACEHOLDER_DESKTOP = 'Enter a message or click the mic to speak'
|
||||
@@ -3,10 +3,7 @@
|
||||
import { memo, type RefObject } from 'react'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type ChatMessage,
|
||||
ClientChatMessage,
|
||||
} from '@/app/chat/[subdomain]/components/message/message'
|
||||
import { type ChatMessage, ClientChatMessage } from '@/app/chat/components/message/message'
|
||||
|
||||
interface ChatMessageContainerProps {
|
||||
messages: ChatMessage[]
|
||||
@@ -5,7 +5,7 @@ import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ParticlesVisualization } from '@/app/chat/[subdomain]/components/voice-interface/components/particles'
|
||||
import { ParticlesVisualization } from '@/app/chat/components/voice-interface/components/particles'
|
||||
|
||||
const logger = createLogger('VoiceInterface')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { ChatMessage } from '@/app/chat/[subdomain]/components/message/message'
|
||||
import type { ChatMessage } from '@/app/chat/components/message/message'
|
||||
// No longer need complex output extraction - backend handles this
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Mail, UserPlus, Users2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getErrorMessage } from '@/app/invite/[id]/utils'
|
||||
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||
|
||||
const logger = createLogger('InviteByIDAPI')
|
||||
const logger = createLogger('InviteById')
|
||||
|
||||
export default function Invite() {
|
||||
const router = useRouter()
|
||||
@@ -18,7 +15,6 @@ export default function Invite() {
|
||||
const inviteId = params.id as string
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session, isPending } = useSession()
|
||||
const brandConfig = useBrandConfig()
|
||||
const [invitationDetails, setInvitationDetails] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -28,12 +24,18 @@ export default function Invite() {
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace')
|
||||
|
||||
// Check if this is a new user vs. existing user and get token from query
|
||||
useEffect(() => {
|
||||
const errorReason = searchParams.get('error')
|
||||
|
||||
if (errorReason) {
|
||||
setError(getErrorMessage(errorReason))
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
setIsNewUser(isNew)
|
||||
|
||||
// Get token from URL or use inviteId as token
|
||||
const tokenFromQuery = searchParams.get('token')
|
||||
const effectiveToken = tokenFromQuery || inviteId
|
||||
|
||||
@@ -43,20 +45,16 @@ export default function Invite() {
|
||||
}
|
||||
}, [searchParams, inviteId])
|
||||
|
||||
// Auto-fetch invitation details when logged in
|
||||
useEffect(() => {
|
||||
if (!session?.user || !token) return
|
||||
|
||||
async function fetchInvitationDetails() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// First try to fetch workspace invitation details
|
||||
const workspaceInviteResponse = await fetch(
|
||||
`/api/workspaces/invitations/details?token=${token}`,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
)
|
||||
// Fetch invitation details using the invitation ID from the URL path
|
||||
const workspaceInviteResponse = await fetch(`/api/workspaces/invitations/${inviteId}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (workspaceInviteResponse.ok) {
|
||||
const data = await workspaceInviteResponse.json()
|
||||
@@ -70,7 +68,6 @@ export default function Invite() {
|
||||
return
|
||||
}
|
||||
|
||||
// If workspace invitation not found, try organization invitation
|
||||
try {
|
||||
const { data } = await client.organization.getInvitation({
|
||||
query: { id: inviteId },
|
||||
@@ -84,7 +81,6 @@ export default function Invite() {
|
||||
name: data.organizationName || 'an organization',
|
||||
})
|
||||
|
||||
// Get organization details
|
||||
if (data.organizationId) {
|
||||
const orgResponse = await client.organization.getFullOrganization({
|
||||
query: { organizationId: data.organizationId },
|
||||
@@ -101,7 +97,6 @@ export default function Invite() {
|
||||
throw new Error('Invitation not found or has expired')
|
||||
}
|
||||
} catch (_err) {
|
||||
// If neither workspace nor organization invitation is found
|
||||
throw new Error('Invitation not found or has expired')
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -115,22 +110,19 @@ export default function Invite() {
|
||||
fetchInvitationDetails()
|
||||
}, [session?.user, inviteId, token])
|
||||
|
||||
// Handle invitation acceptance
|
||||
const handleAcceptInvitation = async () => {
|
||||
if (!session?.user) return
|
||||
|
||||
setIsAccepting(true)
|
||||
|
||||
if (invitationType === 'workspace') {
|
||||
window.location.href = `/api/workspaces/invitations/accept?token=${encodeURIComponent(token || '')}`
|
||||
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
|
||||
} else {
|
||||
try {
|
||||
// For organization invites, use the client API
|
||||
const response = await client.organization.acceptInvitation({
|
||||
invitationId: inviteId,
|
||||
})
|
||||
|
||||
// Set the active organization to the one just joined
|
||||
const orgId =
|
||||
response.data?.invitation.organizationId || invitationDetails?.data?.organizationId
|
||||
|
||||
@@ -142,7 +134,6 @@ export default function Invite() {
|
||||
|
||||
setAccepted(true)
|
||||
|
||||
// Redirect to workspace after a brief delay
|
||||
setTimeout(() => {
|
||||
router.push('/workspace')
|
||||
}, 2000)
|
||||
@@ -155,281 +146,135 @@ export default function Invite() {
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the callback URL - this ensures after login, user returns to invite page
|
||||
const getCallbackUrl = () => {
|
||||
return `/invite/${inviteId}${token && token !== inviteId ? `?token=${token}` : ''}`
|
||||
}
|
||||
|
||||
// Show login/signup prompt if not logged in
|
||||
if (!session?.user && !isPending) {
|
||||
const callbackUrl = encodeURIComponent(getCallbackUrl())
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
|
||||
<UserPlus className='h-8 w-8 text-blue-500 dark:text-blue-400' />
|
||||
</div>
|
||||
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
|
||||
You've been invited!
|
||||
</h1>
|
||||
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
{isNewUser
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='login'
|
||||
title="You've been invited!"
|
||||
description={
|
||||
isNewUser
|
||||
? 'Create an account to join this workspace on Sim'
|
||||
: 'Sign in to your account to accept this invitation'}
|
||||
</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isNewUser ? (
|
||||
<>
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
|
||||
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
|
||||
onClick={() =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`)
|
||||
}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
: 'Sign in to your account to accept this invitation'
|
||||
}
|
||||
icon='userPlus'
|
||||
actions={[
|
||||
...(isNewUser
|
||||
? [
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{
|
||||
label: 'I already have an account',
|
||||
onClick: () =>
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Sign in',
|
||||
onClick: () =>
|
||||
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
|
||||
},
|
||||
{
|
||||
label: 'Create an account',
|
||||
onClick: () =>
|
||||
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
|
||||
variant: 'outline' as const,
|
||||
},
|
||||
]),
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading || isPending) {
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<LoadingAgent size='lg' />
|
||||
<p className='mt-4 text-gray-400 text-sm'>Loading invitation...</p>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard type='loading' title='' description='Loading invitation...' />
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
const errorReason = searchParams.get('error')
|
||||
const isExpiredError = errorReason === 'expired'
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
|
||||
<AlertCircle className='h-8 w-8 text-red-500 dark:text-red-400' />
|
||||
</div>
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
|
||||
Invitation Error
|
||||
</h1>
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>{error}</p>
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='error'
|
||||
title='Invitation Error'
|
||||
description={error}
|
||||
icon='error'
|
||||
isExpiredError={isExpiredError}
|
||||
actions={[
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show success state
|
||||
if (accepted) {
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-green-50 p-3 dark:bg-green-950/20'>
|
||||
<CheckCircle2 className='h-8 w-8 text-green-500 dark:text-green-400' />
|
||||
</div>
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Welcome!</h1>
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
You have successfully joined {invitationDetails?.name || 'the workspace'}. Redirecting
|
||||
to your workspace...
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='success'
|
||||
title='Welcome!'
|
||||
description={`You have successfully joined ${invitationDetails?.name || 'the workspace'}. Redirecting to your workspace...`}
|
||||
icon='success'
|
||||
actions={[
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Show invitation details
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src='/logo/b&w/medium.png'
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-blue-50 p-3 dark:bg-blue-950/20'>
|
||||
{invitationType === 'organization' ? (
|
||||
<Users2 className='h-8 w-8 text-blue-500 dark:text-blue-400' />
|
||||
) : (
|
||||
<Mail className='h-8 w-8 text-blue-500 dark:text-blue-400' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>
|
||||
{invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'}
|
||||
</h1>
|
||||
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
You've been invited to join{' '}
|
||||
<span className='font-medium text-black dark:text-white'>
|
||||
{invitationDetails?.name || `a ${invitationType}`}
|
||||
</span>
|
||||
. Click accept below to join.
|
||||
</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
<Button
|
||||
onClick={handleAcceptInvitation}
|
||||
disabled={isAccepting}
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
>
|
||||
{isAccepting ? (
|
||||
<>
|
||||
<LoadingAgent size='sm' />
|
||||
Accepting...
|
||||
</>
|
||||
) : (
|
||||
'Accept Invitation'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='w-full text-gray-600 hover:bg-gray-200 hover:text-black dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<InviteLayout>
|
||||
<InviteStatusCard
|
||||
type='invitation'
|
||||
title={
|
||||
invitationType === 'organization' ? 'Organization Invitation' : 'Workspace Invitation'
|
||||
}
|
||||
description={`You've been invited to join ${invitationDetails?.name || `a ${invitationType}`}. Click accept below to join.`}
|
||||
icon={invitationType === 'organization' ? 'users' : 'mail'}
|
||||
actions={[
|
||||
{
|
||||
label: 'Accept Invitation',
|
||||
onClick: handleAcceptInvitation,
|
||||
disabled: isAccepting,
|
||||
loading: isAccepting,
|
||||
},
|
||||
{
|
||||
label: 'Return to Home',
|
||||
onClick: () => router.push('/'),
|
||||
variant: 'ghost',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</InviteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
28
apps/sim/app/invite/[id]/utils.ts
Normal file
28
apps/sim/app/invite/[id]/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function getErrorMessage(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-token':
|
||||
return 'The invitation link is invalid or missing a required parameter.'
|
||||
case 'invalid-token':
|
||||
return 'The invitation link is invalid or has already been used.'
|
||||
case 'expired':
|
||||
return 'This invitation has expired. Please ask for a new invitation.'
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return 'This invitation was sent to a different email address. Please log in with the correct account or contact the person who invited you.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||
case 'server-error':
|
||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||
default:
|
||||
return 'An unknown error occurred while processing your invitation.'
|
||||
}
|
||||
}
|
||||
2
apps/sim/app/invite/components/index.ts
Normal file
2
apps/sim/app/invite/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { InviteLayout } from './layout'
|
||||
export { InviteStatusCard } from './status-card'
|
||||
56
apps/sim/app/invite/components/layout.tsx
Normal file
56
apps/sim/app/invite/components/layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { GridPattern } from '@/app/(landing)/components/grid-pattern'
|
||||
|
||||
interface InviteLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function InviteLayout({ children }: InviteLayoutProps) {
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
return (
|
||||
<main className='dark relative flex min-h-screen flex-col bg-[var(--brand-background-hex)] font-geist-sans text-white'>
|
||||
{/* Background pattern */}
|
||||
<GridPattern
|
||||
x={-5}
|
||||
y={-5}
|
||||
className='absolute inset-0 z-0 stroke-[#ababab]/5'
|
||||
width={90}
|
||||
height={90}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className='relative z-10 flex flex-1 items-center justify-center px-4 pb-6'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='mb-8 text-center'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/primary/text/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={140}
|
||||
height={42}
|
||||
priority
|
||||
className='mx-auto'
|
||||
/>
|
||||
</div>
|
||||
<div className='rounded-xl border border-neutral-700/40 bg-neutral-800/50 p-6 backdrop-blur-sm'>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className='mt-6 text-center text-neutral-500/80 text-xs leading-relaxed'>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='text-[var(--brand-accent-hex)] underline-offset-4 transition hover:text-[var(--brand-accent-hover-hex)] hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
121
apps/sim/app/invite/components/status-card.tsx
Normal file
121
apps/sim/app/invite/components/status-card.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
|
||||
interface InviteStatusCardProps {
|
||||
type: 'login' | 'loading' | 'error' | 'success' | 'invitation'
|
||||
title: string
|
||||
description: string | React.ReactNode
|
||||
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success'
|
||||
actions?: Array<{
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'outline' | 'ghost'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>
|
||||
isExpiredError?: boolean
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
userPlus: UserPlus,
|
||||
mail: Mail,
|
||||
users: Users2,
|
||||
error: ShieldX,
|
||||
success: CheckCircle2,
|
||||
}
|
||||
|
||||
const iconColorMap = {
|
||||
userPlus: 'text-[#701ffc]',
|
||||
mail: 'text-[#701ffc]',
|
||||
users: 'text-[#701ffc]',
|
||||
error: 'text-red-500 dark:text-red-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
userPlus: 'bg-[#701ffc]/10',
|
||||
mail: 'bg-[#701ffc]/10',
|
||||
users: 'bg-[#701ffc]/10',
|
||||
error: 'bg-red-50 dark:bg-red-950/20',
|
||||
success: 'bg-green-50 dark:bg-green-950/20',
|
||||
}
|
||||
|
||||
export function InviteStatusCard({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
actions = [],
|
||||
isExpiredError = false,
|
||||
}: InviteStatusCardProps) {
|
||||
const router = useRouter()
|
||||
|
||||
if (type === 'loading') {
|
||||
return (
|
||||
<div className='flex w-full max-w-md flex-col items-center'>
|
||||
<LoadingAgent size='lg' />
|
||||
<p className='mt-4 text-muted-foreground text-sm'>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IconComponent = icon ? iconMap[icon] : null
|
||||
const iconColor = icon ? iconColorMap[icon] : ''
|
||||
const iconBg = icon ? iconBgMap[icon] : ''
|
||||
|
||||
return (
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
{IconComponent && (
|
||||
<div className={`mb-6 rounded-full p-3 ${iconBg}`}>
|
||||
<IconComponent className={`h-8 w-8 ${iconColor}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className='mb-2 font-semibold text-[32px] text-white tracking-tight'>{title}</h1>
|
||||
|
||||
<p className='mb-6 text-neutral-400 text-sm leading-relaxed'>{description}</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Request New Invitation
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'default'}
|
||||
className={
|
||||
(action.variant || 'default') === 'default'
|
||||
? 'h-11 w-full bg-brand-primary font-medium text-base text-white shadow-[var(--brand-primary-hex)]/20 shadow-lg transition-colors duration-200 hover:bg-brand-primary-hover'
|
||||
: action.variant === 'outline'
|
||||
? 'h-11 w-full border-[var(--brand-primary-hex)] font-medium text-[var(--brand-primary-hex)] text-base transition-colors duration-200 hover:bg-[var(--brand-primary-hex)] hover:text-white'
|
||||
: 'h-11 w-full text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || action.loading}
|
||||
>
|
||||
{action.loading ? (
|
||||
<>
|
||||
<LoadingAgent size='sm' />
|
||||
{action.label}...
|
||||
</>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RotateCcw, ShieldX } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
function getErrorMessage(reason: string, details?: string): string {
|
||||
switch (reason) {
|
||||
case 'missing-token':
|
||||
return 'The invitation link is invalid or missing a required parameter.'
|
||||
case 'invalid-token':
|
||||
return 'The invitation link is invalid or has already been used.'
|
||||
case 'expired':
|
||||
return 'This invitation has expired. Please ask for a new invitation.'
|
||||
case 'already-processed':
|
||||
return 'This invitation has already been accepted or declined.'
|
||||
case 'email-mismatch':
|
||||
return details
|
||||
? details
|
||||
: 'This invitation was sent to a different email address than the one you are logged in with.'
|
||||
case 'workspace-not-found':
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'invalid-invitation':
|
||||
return 'This invitation is invalid or no longer exists.'
|
||||
case 'missing-invitation-id':
|
||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||
case 'server-error':
|
||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||
default:
|
||||
return 'An unknown error occurred while processing your invitation.'
|
||||
}
|
||||
}
|
||||
|
||||
export default function InviteError() {
|
||||
const searchParams = useSearchParams()
|
||||
const reason = searchParams?.get('reason') || 'unknown'
|
||||
const details = searchParams?.get('details')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
useEffect(() => {
|
||||
// Only set the error message on the client side
|
||||
setErrorMessage(getErrorMessage(reason, details || undefined))
|
||||
}, [reason, details])
|
||||
|
||||
// Provide a fallback message for SSR
|
||||
const displayMessage = errorMessage || 'Loading error details...'
|
||||
|
||||
const isExpiredError = reason === 'expired'
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col items-center justify-center bg-white px-4 dark:bg-black'>
|
||||
{/* Logo */}
|
||||
<div className='mb-8'>
|
||||
<Image
|
||||
src={brandConfig.logoUrl || '/logo/b&w/medium.png'}
|
||||
alt='Sim Logo'
|
||||
width={120}
|
||||
height={67}
|
||||
className='dark:invert'
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex w-full max-w-md flex-col items-center text-center'>
|
||||
<div className='mb-6 rounded-full bg-red-50 p-3 dark:bg-red-950/20'>
|
||||
<ShieldX className='h-8 w-8 text-red-500 dark:text-red-400' />
|
||||
</div>
|
||||
|
||||
<h1 className='mb-2 font-semibold text-black text-xl dark:text-white'>Invitation Error</h1>
|
||||
|
||||
<p className='mb-6 text-gray-600 text-sm leading-relaxed dark:text-gray-300'>
|
||||
{displayMessage}
|
||||
</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white'
|
||||
asChild
|
||||
>
|
||||
<Link href='/'>
|
||||
<RotateCcw className='mr-2 h-4 w-4' />
|
||||
Request New Invitation
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
asChild
|
||||
>
|
||||
<Link href='/'>Return to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className='mt-8 text-center text-gray-500 text-xs'>
|
||||
Need help?{' '}
|
||||
<a href='mailto:help@sim.ai' className='text-blue-400 hover:text-blue-300'>
|
||||
Contact support
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import InviteError from '@/app/invite/invite-error/invite-error'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function InviteErrorPage() {
|
||||
return <InviteError />
|
||||
}
|
||||
@@ -1,401 +1,3 @@
|
||||
'use client'
|
||||
import Unsubscribe from './unsubscribe'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
email: string
|
||||
token: string
|
||||
emailType: string
|
||||
isTransactional: boolean
|
||||
currentPreferences: {
|
||||
unsubscribeAll?: boolean
|
||||
unsubscribeMarketing?: boolean
|
||||
unsubscribeUpdates?: boolean
|
||||
unsubscribeNotifications?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function UnsubscribeContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<UnsubscribeData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||
const brand = useBrandConfig()
|
||||
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
|
||||
useEffect(() => {
|
||||
if (!email || !token) {
|
||||
setError('Missing email or token in URL')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
setData(data)
|
||||
} else {
|
||||
setError(data.error || 'Invalid unsubscribe link')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to validate unsubscribe link')
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [email, token])
|
||||
|
||||
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
|
||||
if (!email || !token) return
|
||||
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me/settings/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
token,
|
||||
type,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setUnsubscribed(true)
|
||||
// Update the data to reflect the change
|
||||
if (data) {
|
||||
// Type-safe property construction with validation
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
unsubscribeAll: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
|
||||
| 'unsubscribeMarketing'
|
||||
| 'unsubscribeUpdates'
|
||||
| 'unsubscribeNotifications'
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
[propertyKey]: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This unsubscribe link is invalid or has expired
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>The link is missing required parameters</li>
|
||||
<li>The link has expired or been used already</li>
|
||||
<li>The link was copied incorrectly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Need immediate help? Email us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle transactional emails
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This email contains important information about your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
||||
<p className='text-blue-800 text-sm'>
|
||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
||||
and security alerts cannot be unsubscribed from as they contain essential
|
||||
information for your account security and functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-foreground text-sm'>
|
||||
If you no longer wish to receive these emails, you can:
|
||||
</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>Close your account entirely</li>
|
||||
<li>Contact our support team for assistance</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsubscribed) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
If you change your mind, you can always update your email preferences in your account
|
||||
settings or contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'd like to
|
||||
stop receiving from Sim.
|
||||
</CardDescription>
|
||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : data?.currentPreferences.unsubscribeAll ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<div className='text-center text-muted-foreground text-sm'>
|
||||
or choose specific types:
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-3'>
|
||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
||||
password resets and security alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
Questions? Contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<UnsubscribeContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
export default Unsubscribe
|
||||
|
||||
401
apps/sim/app/unsubscribe/unsubscribe.tsx
Normal file
401
apps/sim/app/unsubscribe/unsubscribe.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { CheckCircle, Heart, Info, Loader2, XCircle } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
|
||||
interface UnsubscribeData {
|
||||
success: boolean
|
||||
email: string
|
||||
token: string
|
||||
emailType: string
|
||||
isTransactional: boolean
|
||||
currentPreferences: {
|
||||
unsubscribeAll?: boolean
|
||||
unsubscribeMarketing?: boolean
|
||||
unsubscribeUpdates?: boolean
|
||||
unsubscribeNotifications?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function UnsubscribeContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<UnsubscribeData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [unsubscribed, setUnsubscribed] = useState(false)
|
||||
const brand = useBrandConfig()
|
||||
|
||||
const email = searchParams.get('email')
|
||||
const token = searchParams.get('token')
|
||||
|
||||
useEffect(() => {
|
||||
if (!email || !token) {
|
||||
setError('Missing email or token in URL')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the unsubscribe link
|
||||
fetch(
|
||||
`/api/users/me/settings/unsubscribe?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
setData(data)
|
||||
} else {
|
||||
setError(data.error || 'Invalid unsubscribe link')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to validate unsubscribe link')
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [email, token])
|
||||
|
||||
const handleUnsubscribe = async (type: 'all' | 'marketing' | 'updates' | 'notifications') => {
|
||||
if (!email || !token) return
|
||||
|
||||
setProcessing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me/settings/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
token,
|
||||
type,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setUnsubscribed(true)
|
||||
// Update the data to reflect the change
|
||||
if (data) {
|
||||
// Type-safe property construction with validation
|
||||
const validTypes = ['all', 'marketing', 'updates', 'notifications'] as const
|
||||
if (validTypes.includes(type)) {
|
||||
if (type === 'all') {
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
unsubscribeAll: true,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const propertyKey = `unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
|
||||
| 'unsubscribeMarketing'
|
||||
| 'unsubscribeUpdates'
|
||||
| 'unsubscribeNotifications'
|
||||
setData({
|
||||
...data,
|
||||
currentPreferences: {
|
||||
...data.currentPreferences,
|
||||
[propertyKey]: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setError(result.error || 'Failed to unsubscribe')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Failed to process unsubscribe request')
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<XCircle className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>Invalid Unsubscribe Link</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This unsubscribe link is invalid or has expired
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-red-50 p-4'>
|
||||
<p className='text-red-800 text-sm'>
|
||||
<strong>Error:</strong> {error}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground text-sm'>This could happen if:</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>The link is missing required parameters</li>
|
||||
<li>The link has expired or been used already</li>
|
||||
<li>The link was copied incorrectly</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Unsubscribe%20Help&body=Hi%2C%20I%20need%20help%20unsubscribing%20from%20emails.%20My%20unsubscribe%20link%20is%20not%20working.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-[var(--brand-primary-hex)] font-medium text-white shadow-sm transition-colors duration-200 hover:bg-[var(--brand-primary-hover-hex)]'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.history.back()} variant='outline' className='w-full'>
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Need immediate help? Email us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle transactional emails
|
||||
if (data?.isTransactional) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Info className='mx-auto mb-2 h-12 w-12 text-blue-500' />
|
||||
<CardTitle className='text-foreground'>Important Account Emails</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
This email contains important information about your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='rounded-lg border bg-blue-50 p-4'>
|
||||
<p className='text-blue-800 text-sm'>
|
||||
<strong>Transactional emails</strong> like password resets, account confirmations,
|
||||
and security alerts cannot be unsubscribed from as they contain essential
|
||||
information for your account security and functionality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-foreground text-sm'>
|
||||
If you no longer wish to receive these emails, you can:
|
||||
</p>
|
||||
<ul className='ml-4 list-inside list-disc space-y-1 text-muted-foreground text-sm'>
|
||||
<li>Close your account entirely</li>
|
||||
<li>Contact our support team for assistance</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 flex flex-col gap-3'>
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:${brand.supportEmail}?subject=Account%20Help&body=Hi%2C%20I%20need%20help%20with%20my%20account%20emails.`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
className='w-full bg-blue-600 text-white hover:bg-blue-700'
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
<Button onClick={() => window.close()} variant='outline' className='w-full'>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (unsubscribed) {
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<CheckCircle className='mx-auto mb-2 h-12 w-12 text-green-500' />
|
||||
<CardTitle className='text-foreground'>Successfully Unsubscribed</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
You have been unsubscribed from our emails. You will stop receiving emails within 48
|
||||
hours.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='text-center'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
If you change your mind, you can always update your email preferences in your account
|
||||
settings or contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen items-center justify-center bg-background p-4'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardHeader className='text-center'>
|
||||
<Heart className='mx-auto mb-2 h-12 w-12 text-red-500' />
|
||||
<CardTitle className='text-foreground'>We're sorry to see you go!</CardTitle>
|
||||
<CardDescription className='text-muted-foreground'>
|
||||
We understand email preferences are personal. Choose which emails you'd like to
|
||||
stop receiving from Sim.
|
||||
</CardDescription>
|
||||
<div className='mt-2 rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Email: <span className='font-medium text-foreground'>{data?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('all')}
|
||||
disabled={processing || data?.currentPreferences.unsubscribeAll}
|
||||
variant='destructive'
|
||||
className='w-full'
|
||||
>
|
||||
{processing ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : data?.currentPreferences.unsubscribeAll ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeAll
|
||||
? 'Unsubscribed from All Emails'
|
||||
: 'Unsubscribe from All Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<div className='text-center text-muted-foreground text-sm'>
|
||||
or choose specific types:
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('marketing')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeMarketing
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeMarketing ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeMarketing
|
||||
? 'Unsubscribed from Marketing'
|
||||
: 'Unsubscribe from Marketing Emails'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('updates')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeUpdates
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeUpdates ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeUpdates
|
||||
? 'Unsubscribed from Updates'
|
||||
: 'Unsubscribe from Product Updates'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => handleUnsubscribe('notifications')}
|
||||
disabled={
|
||||
processing ||
|
||||
data?.currentPreferences.unsubscribeAll ||
|
||||
data?.currentPreferences.unsubscribeNotifications
|
||||
}
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
>
|
||||
{data?.currentPreferences.unsubscribeNotifications ? (
|
||||
<CheckCircle className='mr-2 h-4 w-4' />
|
||||
) : null}
|
||||
{data?.currentPreferences.unsubscribeNotifications
|
||||
? 'Unsubscribed from Notifications'
|
||||
: 'Unsubscribe from Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 space-y-3'>
|
||||
<div className='rounded-lg border bg-muted/50 p-3'>
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
<strong>Note:</strong> You'll continue receiving important account emails like
|
||||
password resets and security alerts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className='text-center text-muted-foreground text-xs'>
|
||||
Questions? Contact us at{' '}
|
||||
<a href={`mailto:${brand.supportEmail}`} className='text-primary hover:underline'>
|
||||
{brand.supportEmail}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Unsubscribe() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='flex min-h-screen items-center justify-center bg-background'>
|
||||
<Card className='w-full max-w-md border shadow-sm'>
|
||||
<CardContent className='flex items-center justify-center p-8'>
|
||||
<Loader2 className='h-8 w-8 animate-spin text-muted-foreground' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<UnsubscribeContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default function Workflow() {
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/workflows/sync')
|
||||
const response = await fetch('/api/workflows')
|
||||
if (response.ok) {
|
||||
const { data } = await response.json()
|
||||
const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({
|
||||
|
||||
@@ -502,7 +502,7 @@ export function FrozenCanvas({
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/logs/${executionId}/frozen-canvas`)
|
||||
const response = await fetch(`/api/logs/execution/${executionId}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch frozen canvas data: ${response.statusText}`)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function Logs() {
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
@@ -216,7 +216,7 @@ export default function Logs() {
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
@@ -274,7 +274,7 @@ export default function Logs() {
|
||||
Promise.all(
|
||||
idsToFetch.map(async ({ id, merge }) => {
|
||||
try {
|
||||
const res = await fetch(`/api/logs/by-id/${id}`, { signal: controller.signal })
|
||||
const res = await fetch(`/api/logs/${id}`, { signal: controller.signal })
|
||||
if (!res.ok) return
|
||||
const body = await res.json()
|
||||
const detailed = body?.data
|
||||
|
||||
@@ -79,7 +79,7 @@ export function ExampleCommand({
|
||||
case 'rate-limits': {
|
||||
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: ${apiKey}" \\
|
||||
${baseUrlForRateLimit}/api/users/rate-limit`
|
||||
${baseUrlForRateLimit}/api/users/me/rate-limit`
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -119,7 +119,7 @@ export function ExampleCommand({
|
||||
case 'rate-limits': {
|
||||
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: SIM_API_KEY" \\
|
||||
${baseUrlForRateLimit}/api/users/rate-limit`
|
||||
${baseUrlForRateLimit}/api/users/me/rate-limit`
|
||||
}
|
||||
|
||||
default:
|
||||
|
||||
@@ -321,7 +321,7 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
|
||||
try {
|
||||
// Primary: call server-side usage check to mirror backend enforcement
|
||||
const res = await fetch('/api/usage/check', { cache: 'no-store' })
|
||||
const res = await fetch('/api/usage?context=user', { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
const payload = await res.json()
|
||||
const usage = payload?.data
|
||||
|
||||
@@ -385,7 +385,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
if (isLoadingWorkflows || workflows.length > 0) return
|
||||
try {
|
||||
setIsLoadingWorkflows(true)
|
||||
const resp = await fetch('/api/workflows/sync')
|
||||
const resp = await fetch('/api/workflows')
|
||||
if (!resp.ok) throw new Error(`Failed to load workflows: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const items = Array.isArray(data?.data) ? data.data : []
|
||||
|
||||
@@ -23,7 +23,6 @@ import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/s
|
||||
|
||||
const logger = createLogger('EnvironmentVariables')
|
||||
|
||||
// Constants
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),88px] gap-4'
|
||||
const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' }
|
||||
|
||||
@@ -66,7 +65,6 @@ export function EnvironmentVariables({
|
||||
const pendingClose = useRef(false)
|
||||
const initialVarsRef = useRef<UIEnvironmentVariable[]>([])
|
||||
|
||||
// Filter environment variables based on search term
|
||||
const filteredEnvVars = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return envVars.map((envVar, index) => ({ envVar, originalIndex: index }))
|
||||
@@ -77,7 +75,6 @@ export function EnvironmentVariables({
|
||||
.filter(({ envVar }) => envVar.key.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
}, [envVars, searchTerm])
|
||||
|
||||
// Derived state
|
||||
const hasChanges = useMemo(() => {
|
||||
const initialVars = initialVarsRef.current.filter((v) => v.key || v.value)
|
||||
const currentVars = envVars.filter((v) => v.key || v.value)
|
||||
@@ -96,7 +93,6 @@ export function EnvironmentVariables({
|
||||
if (!currentMap.has(key)) return true
|
||||
}
|
||||
|
||||
// Workspace diffs
|
||||
const before = initialWorkspaceVarsRef.current
|
||||
const after = workspaceVars
|
||||
const beforeKeys = Object.keys(before)
|
||||
@@ -109,12 +105,10 @@ export function EnvironmentVariables({
|
||||
return false
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
// Check if there are any active conflicts
|
||||
const hasConflicts = useMemo(() => {
|
||||
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
// Intercept close attempts to check for unsaved changes
|
||||
const handleModalClose = (open: boolean) => {
|
||||
if (!open && hasChanges) {
|
||||
setShowUnsavedChanges(true)
|
||||
@@ -124,7 +118,6 @@ export function EnvironmentVariables({
|
||||
}
|
||||
}
|
||||
|
||||
// Initialization effect
|
||||
useEffect(() => {
|
||||
const existingVars = Object.values(variables)
|
||||
const initialVars = existingVars.length ? existingVars : [INITIAL_ENV_VAR]
|
||||
@@ -158,14 +151,12 @@ export function EnvironmentVariables({
|
||||
}
|
||||
}, [workspaceId, loadWorkspaceEnvironment])
|
||||
|
||||
// Register close handler with parent
|
||||
useEffect(() => {
|
||||
if (registerCloseHandler) {
|
||||
registerCloseHandler(handleModalClose)
|
||||
}
|
||||
}, [registerCloseHandler, hasChanges])
|
||||
|
||||
// Scroll effect - only when explicitly adding a new variable
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
@@ -322,8 +313,7 @@ export function EnvironmentVariables({
|
||||
}),
|
||||
{}
|
||||
)
|
||||
|
||||
useEnvironmentStore.getState().setVariables(validVariables)
|
||||
await useEnvironmentStore.getState().saveEnvironmentVariables(validVariables)
|
||||
|
||||
const before = initialWorkspaceVarsRef.current
|
||||
const after = workspaceVars
|
||||
@@ -354,7 +344,6 @@ export function EnvironmentVariables({
|
||||
}
|
||||
}
|
||||
|
||||
// UI rendering
|
||||
const renderEnvVarRow = (envVar: UIEnvironmentVariable, originalIndex: number) => {
|
||||
const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)
|
||||
return (
|
||||
|
||||
@@ -113,7 +113,7 @@ export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
|
||||
throw new Error('Organization ID is required')
|
||||
}
|
||||
|
||||
const response = await fetch('/api/usage-limits', {
|
||||
const response = await fetch('/api/usage', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -16,11 +16,10 @@ import {
|
||||
|
||||
// Get current Ollama models dynamically
|
||||
const getCurrentOllamaModels = () => {
|
||||
return useOllamaStore.getState().models
|
||||
return useProvidersStore.getState().providers.ollama.models
|
||||
}
|
||||
|
||||
import { useOllamaStore } from '@/stores/ollama/store'
|
||||
import { useOpenRouterStore } from '@/stores/openrouter/store'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('AgentBlock')
|
||||
@@ -158,8 +157,9 @@ Create a system prompt appropriately detailed for the request, using clear langu
|
||||
placeholder: 'Type or select a model...',
|
||||
required: true,
|
||||
options: () => {
|
||||
const ollamaModels = useOllamaStore.getState().models
|
||||
const openrouterModels = useOpenRouterStore.getState().models
|
||||
const providersState = useProvidersStore.getState()
|
||||
const ollamaModels = providersState.providers.ollama.models
|
||||
const openrouterModels = providersState.providers.openrouter.models
|
||||
const baseModels = Object.keys(getBaseModelProviders())
|
||||
const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels]))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockConfig, ParamType } from '@/blocks/types'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
import { getAllModelProviders, getBaseModelProviders, getHostedModels } from '@/providers/utils'
|
||||
import { useOllamaStore } from '@/stores/ollama/store'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('EvaluatorBlock')
|
||||
@@ -177,7 +177,7 @@ export const EvaluatorBlock: BlockConfig<EvaluatorResponse> = {
|
||||
layout: 'half',
|
||||
required: true,
|
||||
options: () => {
|
||||
const ollamaModels = useOllamaStore.getState().models
|
||||
const ollamaModels = useProvidersStore.getState().providers.ollama.models
|
||||
const baseModels = Object.keys(getBaseModelProviders())
|
||||
return [...baseModels, ...ollamaModels].map((model) => ({
|
||||
label: model,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isHosted } from '@/lib/environment'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import type { ProviderId } from '@/providers/types'
|
||||
import { getAllModelProviders, getBaseModelProviders, getHostedModels } from '@/providers/utils'
|
||||
import { useOllamaStore } from '@/stores/ollama/store'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
interface RouterResponse extends ToolResponse {
|
||||
@@ -119,7 +119,7 @@ export const RouterBlock: BlockConfig<RouterResponse> = {
|
||||
type: 'dropdown',
|
||||
layout: 'half',
|
||||
options: () => {
|
||||
const ollamaModels = useOllamaStore.getState().models
|
||||
const ollamaModels = useProvidersStore.getState().providers.ollama.models
|
||||
const baseModels = Object.keys(getBaseModelProviders())
|
||||
return [...baseModels, ...ollamaModels].map((model) => ({
|
||||
label: model,
|
||||
|
||||
@@ -39,8 +39,11 @@ export const WorkspaceInvitationEmail = ({
|
||||
let enhancedLink = invitationLink
|
||||
|
||||
try {
|
||||
// If the link is pointing to the API endpoint directly, update it to use the client route
|
||||
if (invitationLink.includes('/api/workspaces/invitations/accept')) {
|
||||
// If the link is pointing to any API endpoint directly, update it to use the client route
|
||||
if (
|
||||
invitationLink.includes('/api/workspaces/invitations/accept') ||
|
||||
invitationLink.match(/\/api\/workspaces\/invitations\/[^?]+\?token=/)
|
||||
) {
|
||||
const url = new URL(invitationLink)
|
||||
const token = url.searchParams.get('token')
|
||||
if (token) {
|
||||
|
||||
3
apps/sim/db/migrations/0083_ambiguous_dreadnoughts.sql
Normal file
3
apps/sim/db/migrations/0083_ambiguous_dreadnoughts.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE TYPE "public"."workspace_invitation_status" AS ENUM('pending', 'accepted', 'rejected', 'cancelled');--> statement-breakpoint
|
||||
ALTER TABLE "workspace_invitation" ALTER COLUMN "status" SET DEFAULT 'pending'::"public"."workspace_invitation_status";--> statement-breakpoint
|
||||
ALTER TABLE "workspace_invitation" ALTER COLUMN "status" SET DATA TYPE "public"."workspace_invitation_status" USING "status"::"public"."workspace_invitation_status";
|
||||
6049
apps/sim/db/migrations/meta/0083_snapshot.json
Normal file
6049
apps/sim/db/migrations/meta/0083_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -575,6 +575,13 @@
|
||||
"when": 1756767479124,
|
||||
"tag": "0082_light_blockbuster",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 83,
|
||||
"version": "7",
|
||||
"when": 1756768177306,
|
||||
"tag": "0083_ambiguous_dreadnoughts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -641,9 +641,17 @@ export const workspace = pgTable('workspace', {
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
// Define the permission enum
|
||||
export const permissionTypeEnum = pgEnum('permission_type', ['admin', 'write', 'read'])
|
||||
|
||||
export const workspaceInvitationStatusEnum = pgEnum('workspace_invitation_status', [
|
||||
'pending',
|
||||
'accepted',
|
||||
'rejected',
|
||||
'cancelled',
|
||||
])
|
||||
|
||||
export type WorkspaceInvitationStatus = (typeof workspaceInvitationStatusEnum.enumValues)[number]
|
||||
|
||||
export const workspaceInvitation = pgTable('workspace_invitation', {
|
||||
id: text('id').primaryKey(),
|
||||
workspaceId: text('workspace_id')
|
||||
@@ -654,7 +662,7 @@ export const workspaceInvitation = pgTable('workspace_invitation', {
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
role: text('role').notNull().default('member'),
|
||||
status: text('status').notNull().default('pending'),
|
||||
status: workspaceInvitationStatusEnum('status').notNull().default('pending'),
|
||||
token: text('token').notNull().unique(),
|
||||
permissions: permissionTypeEnum('permissions').notNull().default('admin'),
|
||||
orgInvitationId: text('org_invitation_id'),
|
||||
|
||||
@@ -153,7 +153,7 @@ export function useUsageLimit() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('/api/usage-limits?context=user')
|
||||
const response = await fetch('/api/usage?context=user')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
@@ -180,7 +180,7 @@ export function useUsageLimit() {
|
||||
|
||||
const updateLimit = async (newLimit: number) => {
|
||||
try {
|
||||
const response = await fetch('/api/usage-limits?context=user', {
|
||||
const response = await fetch('/api/usage?context=user', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -114,7 +114,6 @@ export async function getOrganizationSeatInfo(
|
||||
organizationId: string
|
||||
): Promise<OrganizationSeatInfo | null> {
|
||||
try {
|
||||
// Get organization details
|
||||
const organizationData = await db
|
||||
.select({
|
||||
id: organization.id,
|
||||
@@ -128,14 +127,12 @@ export async function getOrganizationSeatInfo(
|
||||
return null
|
||||
}
|
||||
|
||||
// Get organization subscription directly (referenceId = organizationId)
|
||||
const subscription = await getOrganizationSubscription(organizationId)
|
||||
|
||||
if (!subscription) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get current member count
|
||||
const memberCount = await db
|
||||
.select({ count: count() })
|
||||
.from(member)
|
||||
@@ -143,11 +140,8 @@ export async function getOrganizationSeatInfo(
|
||||
|
||||
const currentSeats = memberCount[0]?.count || 0
|
||||
|
||||
// Determine seat limits
|
||||
const maxSeats = subscription.seats || 1
|
||||
|
||||
// Enterprise plans have fixed seats (can't self-serve changes)
|
||||
// Team plans can add seats through Stripe
|
||||
const canAddSeats = subscription.plan !== 'enterprise'
|
||||
|
||||
const availableSeats = Math.max(0, maxSeats - currentSeats)
|
||||
@@ -183,14 +177,12 @@ export async function validateBulkInvitations(
|
||||
validationResult: SeatValidationResult
|
||||
}> {
|
||||
try {
|
||||
// Remove duplicates and validate email format
|
||||
const uniqueEmails = [...new Set(emailList)]
|
||||
const validEmails = uniqueEmails.filter(
|
||||
(email) => quickValidateEmail(email.trim().toLowerCase()).isValid
|
||||
)
|
||||
const duplicateEmails = emailList.filter((email, index) => emailList.indexOf(email) !== index)
|
||||
|
||||
// Check for existing members
|
||||
const existingMembers = await db
|
||||
.select({ userEmail: user.email })
|
||||
.from(member)
|
||||
@@ -200,7 +192,6 @@ export async function validateBulkInvitations(
|
||||
const existingEmails = existingMembers.map((m) => m.userEmail)
|
||||
const newEmails = validEmails.filter((email) => !existingEmails.includes(email))
|
||||
|
||||
// Check for pending invitations
|
||||
const pendingInvitations = await db
|
||||
.select({ email: invitation.email })
|
||||
.from(invitation)
|
||||
@@ -209,7 +200,6 @@ export async function validateBulkInvitations(
|
||||
const pendingEmails = pendingInvitations.map((i) => i.email)
|
||||
const finalEmailsToInvite = newEmails.filter((email) => !pendingEmails.includes(email))
|
||||
|
||||
// Validate seat availability
|
||||
const seatsNeeded = finalEmailsToInvite.length
|
||||
const validationResult = await validateSeatAvailability(organizationId, seatsNeeded)
|
||||
|
||||
@@ -258,14 +248,12 @@ export async function updateOrganizationSeats(
|
||||
updatedBy: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Get current organization subscription directly (referenceId = organizationId)
|
||||
const subscriptionRecord = await getOrganizationSubscription(organizationId)
|
||||
|
||||
if (!subscriptionRecord) {
|
||||
return { success: false, error: 'No active subscription found' }
|
||||
}
|
||||
|
||||
// Validate minimum seat requirements
|
||||
const memberCount = await db
|
||||
.select({ count: count() })
|
||||
.from(member)
|
||||
@@ -280,7 +268,6 @@ export async function updateOrganizationSeats(
|
||||
}
|
||||
}
|
||||
|
||||
// Update subscription seat count
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({
|
||||
@@ -320,7 +307,6 @@ export async function validateMemberRemoval(
|
||||
removedBy: string
|
||||
): Promise<{ canRemove: boolean; reason?: string }> {
|
||||
try {
|
||||
// Get member details
|
||||
const memberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
@@ -331,12 +317,10 @@ export async function validateMemberRemoval(
|
||||
return { canRemove: false, reason: 'Member not found in organization' }
|
||||
}
|
||||
|
||||
// Check if trying to remove the organization owner
|
||||
if (memberRecord[0].role === 'owner') {
|
||||
return { canRemove: false, reason: 'Cannot remove organization owner' }
|
||||
}
|
||||
|
||||
// Check if the person removing has sufficient permissions
|
||||
const removerMemberRecord = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
@@ -350,22 +334,18 @@ export async function validateMemberRemoval(
|
||||
const removerRole = removerMemberRecord[0].role
|
||||
const targetRole = memberRecord[0].role
|
||||
|
||||
// Permission hierarchy: owner > admin > member
|
||||
if (removerRole === 'owner') {
|
||||
// Owners can remove anyone except themselves
|
||||
return userIdToRemove === removedBy
|
||||
? { canRemove: false, reason: 'Cannot remove yourself as owner' }
|
||||
: { canRemove: true }
|
||||
}
|
||||
|
||||
if (removerRole === 'admin') {
|
||||
// Admins can remove members but not other admins or owners
|
||||
return targetRole === 'member'
|
||||
? { canRemove: true }
|
||||
: { canRemove: false, reason: 'Insufficient permissions to remove this member' }
|
||||
}
|
||||
|
||||
// Members cannot remove other members
|
||||
return { canRemove: false, reason: 'Insufficient permissions' }
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate member removal', {
|
||||
@@ -390,7 +370,6 @@ export async function getOrganizationSeatAnalytics(organizationId: string) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get member activity data
|
||||
const memberActivity = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
@@ -405,7 +384,6 @@ export async function getOrganizationSeatAnalytics(organizationId: string) {
|
||||
.leftJoin(userStats, eq(member.userId, userStats.userId))
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
// Calculate utilization metrics
|
||||
const utilizationRate =
|
||||
seatInfo.maxSeats > 0 ? (seatInfo.currentSeats / seatInfo.maxSeats) * 100 : 0
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export class ListUserWorkflowsClientTool extends BaseClientTool {
|
||||
try {
|
||||
this.setState(ClientToolCallState.executing)
|
||||
|
||||
const res = await fetch('/api/workflows/sync', { method: 'GET' })
|
||||
const res = await fetch('/api/workflows', { method: 'GET' })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
await this.markToolComplete(res.status, text || 'Failed to fetch workflows')
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
prepareToolsWithUsageControl,
|
||||
trackForcedToolUsage,
|
||||
} from '@/providers/utils'
|
||||
import { useOllamaStore } from '@/stores/ollama/store'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const logger = createLogger('OllamaProvider')
|
||||
@@ -78,13 +78,13 @@ export const ollamaProvider: ProviderConfig = {
|
||||
try {
|
||||
const response = await fetch(`${OLLAMA_HOST}/api/tags`)
|
||||
if (!response.ok) {
|
||||
useOllamaStore.getState().setModels([])
|
||||
useProvidersStore.getState().setModels('ollama', [])
|
||||
logger.warn('Ollama service is not available. The provider will be disabled.')
|
||||
return
|
||||
}
|
||||
const data = (await response.json()) as ModelsObject
|
||||
this.models = data.models.map((model) => model.name)
|
||||
useOllamaStore.getState().setModels(this.models)
|
||||
useProvidersStore.getState().setModels('ollama', this.models)
|
||||
} catch (error) {
|
||||
logger.warn('Ollama model instantiation failed. The provider will be disabled.', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
||||
@@ -30,7 +30,7 @@ import { openRouterProvider } from '@/providers/openrouter'
|
||||
import type { ProviderConfig, ProviderId, ProviderToolConfig } from '@/providers/types'
|
||||
import { xAIProvider } from '@/providers/xai'
|
||||
import { useCustomToolsStore } from '@/stores/custom-tools/store'
|
||||
import { useOllamaStore } from '@/stores/ollama/store'
|
||||
import { useProvidersStore } from '@/stores/providers/store'
|
||||
|
||||
const logger = createLogger('ProviderUtils')
|
||||
|
||||
@@ -576,7 +576,8 @@ export function getApiKey(provider: string, model: string, userProvidedKey?: str
|
||||
const hasUserKey = !!userProvidedKey
|
||||
|
||||
// Ollama models don't require API keys - they run locally
|
||||
const isOllamaModel = provider === 'ollama' || useOllamaStore.getState().models.includes(model)
|
||||
const isOllamaModel =
|
||||
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
|
||||
if (isOllamaModel) {
|
||||
return 'empty' // Ollama uses 'empty' as a placeholder API key
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export const API_ENDPOINTS = {
|
||||
SYNC: '/api/workflows/sync',
|
||||
ENVIRONMENT: '/api/environment',
|
||||
SCHEDULE: '/api/schedules',
|
||||
SETTINGS: '/api/settings',
|
||||
@@ -8,9 +7,6 @@ export const API_ENDPOINTS = {
|
||||
WORKSPACE_ENVIRONMENT: (id: string) => `/api/workspaces/${id}/environment`,
|
||||
}
|
||||
|
||||
// Removed SYNC_INTERVALS - Socket.IO handles real-time sync
|
||||
|
||||
// Copilot tool display names - shared between client and server
|
||||
export const COPILOT_TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
search_documentation: 'Searching documentation',
|
||||
get_user_workflow: 'Analyzing your workflow',
|
||||
@@ -30,7 +26,6 @@ export const COPILOT_TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
reason: 'Reasoning about your workflow',
|
||||
} as const
|
||||
|
||||
// Past tense versions for completed tool calls
|
||||
export const COPILOT_TOOL_PAST_TENSE: Record<string, string> = {
|
||||
search_documentation: 'Searched documentation',
|
||||
get_user_workflow: 'Analyzed your workflow',
|
||||
@@ -50,7 +45,6 @@ export const COPILOT_TOOL_PAST_TENSE: Record<string, string> = {
|
||||
reason: 'Finished reasoning',
|
||||
} as const
|
||||
|
||||
// Error versions for failed tool calls
|
||||
export const COPILOT_TOOL_ERROR_NAMES: Record<string, string> = {
|
||||
search_documentation: 'Errored searching documentation',
|
||||
get_user_workflow: 'Errored analyzing your workflow',
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { updateOllamaProviderModels } from '@/providers/utils'
|
||||
import type { OllamaStore } from '@/stores/ollama/types'
|
||||
|
||||
const logger = createLogger('OllamaStore')
|
||||
|
||||
// Fetch models from the server API when on client side
|
||||
const fetchOllamaModels = async (): Promise<string[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/providers/ollama/models')
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to fetch Ollama models from API', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.models || []
|
||||
} catch (error) {
|
||||
logger.error('Error fetching Ollama models', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const useOllamaStore = create<OllamaStore>((set, get) => ({
|
||||
models: [],
|
||||
isLoading: false,
|
||||
setModels: (models) => {
|
||||
set({ models })
|
||||
// Update the providers when models change
|
||||
updateOllamaProviderModels(models)
|
||||
},
|
||||
|
||||
// Fetch models from API (client-side only)
|
||||
fetchModels: async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
logger.info('Skipping client-side model fetch on server')
|
||||
return
|
||||
}
|
||||
|
||||
if (get().isLoading) {
|
||||
logger.info('Model fetch already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Fetching Ollama models from API')
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
const models = await fetchOllamaModels()
|
||||
logger.info('Successfully fetched Ollama models', {
|
||||
count: models.length,
|
||||
models,
|
||||
})
|
||||
get().setModels(models)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch Ollama models', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// Auto-fetch models when the store is first accessed on the client
|
||||
if (typeof window !== 'undefined') {
|
||||
// Delay to avoid hydration issues
|
||||
setTimeout(() => {
|
||||
useOllamaStore.getState().fetchModels()
|
||||
}, 1000)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface OllamaStore {
|
||||
models: string[]
|
||||
isLoading: boolean
|
||||
setModels: (models: string[]) => void
|
||||
fetchModels: () => Promise<void>
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { updateOpenRouterProviderModels } from '@/providers/utils'
|
||||
import type { OpenRouterStore } from '@/stores/openrouter/types'
|
||||
|
||||
const logger = createLogger('OpenRouterStore')
|
||||
|
||||
const fetchOpenRouterModels = async (): Promise<string[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/providers/openrouter/models')
|
||||
if (!response.ok) {
|
||||
logger.warn('Failed to fetch OpenRouter models from API', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const data = await response.json()
|
||||
return data.models || []
|
||||
} catch (error) {
|
||||
logger.error('Error fetching OpenRouter models', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const useOpenRouterStore = create<OpenRouterStore>((set, get) => ({
|
||||
models: [],
|
||||
isLoading: false,
|
||||
setModels: (models) => {
|
||||
const unique = Array.from(new Set(models))
|
||||
set({ models: unique })
|
||||
updateOpenRouterProviderModels(models)
|
||||
},
|
||||
|
||||
fetchModels: async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
logger.info('Skipping client-side model fetch on server')
|
||||
return
|
||||
}
|
||||
if (get().isLoading) {
|
||||
logger.info('Model fetch already in progress')
|
||||
return
|
||||
}
|
||||
logger.info('Fetching OpenRouter models from API')
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const models = await fetchOpenRouterModels()
|
||||
logger.info('Successfully fetched OpenRouter models', {
|
||||
count: models.length,
|
||||
})
|
||||
get().setModels(models)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch OpenRouter models', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(() => {
|
||||
useOpenRouterStore.getState().fetchModels()
|
||||
}, 1000)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface OpenRouterStore {
|
||||
models: string[]
|
||||
isLoading: boolean
|
||||
setModels: (models: string[]) => void
|
||||
fetchModels: () => Promise<void>
|
||||
}
|
||||
@@ -59,7 +59,6 @@ export interface OrganizationFormData {
|
||||
logo: string
|
||||
}
|
||||
|
||||
// Organization billing and usage types
|
||||
export interface MemberUsageData {
|
||||
userId: string
|
||||
userName: string
|
||||
|
||||
126
apps/sim/stores/providers/store.ts
Normal file
126
apps/sim/stores/providers/store.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { create } from 'zustand'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { updateOllamaProviderModels, updateOpenRouterProviderModels } from '@/providers/utils'
|
||||
import type { ProviderConfig, ProviderName, ProvidersStore } from './types'
|
||||
|
||||
const logger = createLogger('ProvidersStore')
|
||||
|
||||
const PROVIDER_CONFIGS: Record<ProviderName, ProviderConfig> = {
|
||||
ollama: {
|
||||
apiEndpoint: '/api/providers/ollama/models',
|
||||
updateFunction: updateOllamaProviderModels,
|
||||
},
|
||||
openrouter: {
|
||||
apiEndpoint: '/api/providers/openrouter/models',
|
||||
dedupeModels: true,
|
||||
updateFunction: updateOpenRouterProviderModels,
|
||||
},
|
||||
}
|
||||
|
||||
const fetchProviderModels = async (provider: ProviderName): Promise<string[]> => {
|
||||
try {
|
||||
const config = PROVIDER_CONFIGS[provider]
|
||||
const response = await fetch(config.apiEndpoint)
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(`Failed to fetch ${provider} models from API`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.models || []
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching ${provider} models`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const useProvidersStore = create<ProvidersStore>((set, get) => ({
|
||||
providers: {
|
||||
ollama: { models: [], isLoading: false },
|
||||
openrouter: { models: [], isLoading: false },
|
||||
},
|
||||
|
||||
setModels: (provider, models) => {
|
||||
const config = PROVIDER_CONFIGS[provider]
|
||||
|
||||
const processedModels = config.dedupeModels ? Array.from(new Set(models)) : models
|
||||
|
||||
set((state) => ({
|
||||
providers: {
|
||||
...state.providers,
|
||||
[provider]: {
|
||||
...state.providers[provider],
|
||||
models: processedModels,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
config.updateFunction(models)
|
||||
},
|
||||
|
||||
fetchModels: async (provider) => {
|
||||
if (typeof window === 'undefined') {
|
||||
logger.info(`Skipping client-side ${provider} model fetch on server`)
|
||||
return
|
||||
}
|
||||
|
||||
const currentState = get().providers[provider]
|
||||
if (currentState.isLoading) {
|
||||
logger.info(`${provider} model fetch already in progress`)
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Fetching ${provider} models from API`)
|
||||
|
||||
set((state) => ({
|
||||
providers: {
|
||||
...state.providers,
|
||||
[provider]: {
|
||||
...state.providers[provider],
|
||||
isLoading: true,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
try {
|
||||
const models = await fetchProviderModels(provider)
|
||||
logger.info(`Successfully fetched ${provider} models`, {
|
||||
count: models.length,
|
||||
...(provider === 'ollama' ? { models } : {}),
|
||||
})
|
||||
get().setModels(provider, models)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch ${provider} models`, {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
set((state) => ({
|
||||
providers: {
|
||||
...state.providers,
|
||||
[provider]: {
|
||||
...state.providers[provider],
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
getProvider: (provider) => {
|
||||
return get().providers[provider]
|
||||
},
|
||||
}))
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
setTimeout(() => {
|
||||
const store = useProvidersStore.getState()
|
||||
store.fetchModels('ollama')
|
||||
store.fetchModels('openrouter')
|
||||
}, 1000)
|
||||
}
|
||||
19
apps/sim/stores/providers/types.ts
Normal file
19
apps/sim/stores/providers/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type ProviderName = 'ollama' | 'openrouter'
|
||||
|
||||
export interface ProviderState {
|
||||
models: string[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export interface ProvidersStore {
|
||||
providers: Record<ProviderName, ProviderState>
|
||||
setModels: (provider: ProviderName, models: string[]) => void
|
||||
fetchModels: (provider: ProviderName) => Promise<void>
|
||||
getProvider: (provider: ProviderName) => ProviderState
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
apiEndpoint: string
|
||||
dedupeModels?: boolean
|
||||
updateFunction: (models: string[]) => void | Promise<void>
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// Load environment variables from DB
|
||||
loadEnvironmentVariables: async () => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -43,12 +42,10 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Save environment variables to DB
|
||||
saveEnvironmentVariables: async (variables: Record<string, string>) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
// Transform variables to the format expected by the store
|
||||
const transformedVariables = Object.entries(variables).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
@@ -57,10 +54,8 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
{}
|
||||
)
|
||||
|
||||
// Update local state immediately (optimistic update)
|
||||
set({ variables: transformedVariables })
|
||||
|
||||
// Send to DB
|
||||
const response = await fetch(API_ENDPOINTS.ENVIRONMENT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -89,12 +84,10 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
// Reload from DB to ensure consistency
|
||||
get().loadEnvironmentVariables()
|
||||
}
|
||||
},
|
||||
|
||||
// Workspace environment actions
|
||||
loadWorkspaceEnvironment: async (workspaceId: string) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -105,7 +98,6 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
// The UI component for environment modal will handle workspace section state locally.
|
||||
set({ isLoading: false })
|
||||
return data as {
|
||||
workspace: Record<string, string>
|
||||
@@ -155,15 +147,6 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy method updated to use the new saveEnvironmentVariables
|
||||
setVariables: (variables: Record<string, string>) => {
|
||||
get().saveEnvironmentVariables(variables)
|
||||
},
|
||||
|
||||
getVariable: (key: string): string | undefined => {
|
||||
return get().variables[key]?.value
|
||||
},
|
||||
|
||||
getAllVariables: (): Record<string, EnvironmentVariable> => {
|
||||
return get().variables
|
||||
},
|
||||
|
||||
@@ -10,14 +10,9 @@ export interface EnvironmentState {
|
||||
}
|
||||
|
||||
export interface EnvironmentStore extends EnvironmentState {
|
||||
// Legacy method
|
||||
setVariables: (variables: Record<string, string>) => void
|
||||
|
||||
// New methods for direct DB interaction
|
||||
loadEnvironmentVariables: () => Promise<void>
|
||||
saveEnvironmentVariables: (variables: Record<string, string>) => Promise<void>
|
||||
|
||||
// Workspace environment
|
||||
loadWorkspaceEnvironment: (workspaceId: string) => Promise<{
|
||||
workspace: Record<string, string>
|
||||
personal: Record<string, string>
|
||||
@@ -29,7 +24,5 @@ export interface EnvironmentStore extends EnvironmentState {
|
||||
) => Promise<void>
|
||||
removeWorkspaceEnvironmentKeys: (workspaceId: string, keys: string[]) => Promise<void>
|
||||
|
||||
// Utility methods
|
||||
getVariable: (key: string) => string | undefined
|
||||
getAllVariables: () => Record<string, EnvironmentVariable>
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
|
||||
loadUsageLimitData: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/usage-limits?context=user')
|
||||
const response = await fetch('/api/usage?context=user')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
@@ -168,7 +168,7 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
|
||||
updateUsageLimit: async (newLimit: number) => {
|
||||
try {
|
||||
const response = await fetch('/api/usage-limits?context=user', {
|
||||
const response = await fetch('/api/usage?context=user', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -240,7 +240,7 @@ export const useSubscriptionStore = create<SubscriptionStore>()(
|
||||
// Load both subscription and usage limit data in parallel
|
||||
const [subscriptionResponse, usageLimitResponse] = await Promise.all([
|
||||
fetch('/api/billing?context=user'),
|
||||
fetch('/api/usage-limits?context=user'),
|
||||
fetch('/api/usage?context=user'),
|
||||
])
|
||||
|
||||
if (!subscriptionResponse.ok) {
|
||||
|
||||
@@ -34,7 +34,7 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
|
||||
try {
|
||||
useWorkflowRegistry.getState().setLoading(true)
|
||||
|
||||
const url = new URL(API_ENDPOINTS.SYNC, window.location.origin)
|
||||
const url = new URL(API_ENDPOINTS.WORKFLOWS, window.location.origin)
|
||||
|
||||
if (workspaceId) {
|
||||
url.searchParams.append('workspaceId', workspaceId)
|
||||
@@ -940,7 +940,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch('/api/workflows/sync', {
|
||||
const response = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user