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:
Waleed
2025-09-01 21:22:23 -07:00
committed by GitHub
parent 65e861822c
commit 2c2c32c64b
86 changed files with 7958 additions and 2551 deletions

View File

@@ -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: '' }
}
}

View File

@@ -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 }
)
}
}

View File

@@ -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)

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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)
)
)

View File

@@ -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
)

View File

@@ -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 })
}
}

View File

@@ -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 })
}
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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' })
})
})

View File

@@ -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 })
}
}

View File

@@ -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' })
})
})
})

View 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 })
}
}

View File

@@ -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'
)
)
}
}

View File

@@ -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 })
}
}

View File

@@ -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({

View File

@@ -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')

View File

@@ -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 (

View File

@@ -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

View File

@@ -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'

View File

@@ -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[]

View File

@@ -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')

View File

@@ -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'

View File

@@ -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>
)
}

View 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.'
}
}

View File

@@ -0,0 +1,2 @@
export { InviteLayout } from './layout'
export { InviteStatusCard } from './status-card'

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,7 +0,0 @@
import InviteError from '@/app/invite/invite-error/invite-error'
export const dynamic = 'force-dynamic'
export default function InviteErrorPage() {
return <InviteError />
}

View File

@@ -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&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;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&apos;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

View 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&apos;re sorry to see you go!</CardTitle>
<CardDescription className='text-muted-foreground'>
We understand email preferences are personal. Choose which emails you&apos;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&apos;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>
)
}

View File

@@ -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) => ({

View File

@@ -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}`)
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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 : []

View File

@@ -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 (

View File

@@ -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',

View File

@@ -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]))

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View 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";

File diff suppressed because it is too large Load Diff

View File

@@ -575,6 +575,13 @@
"when": 1756767479124,
"tag": "0082_light_blockbuster",
"breakpoints": true
},
{
"idx": 83,
"version": "7",
"when": 1756768177306,
"tag": "0083_ambiguous_dreadnoughts",
"breakpoints": true
}
]
}

View File

@@ -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'),

View File

@@ -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',

View File

@@ -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

View File

@@ -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')

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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',

View File

@@ -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)
}

View File

@@ -1,6 +0,0 @@
export interface OllamaStore {
models: string[]
isLoading: boolean
setModels: (models: string[]) => void
fetchModels: () => Promise<void>
}

View File

@@ -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)
}

View File

@@ -1,6 +0,0 @@
export interface OpenRouterStore {
models: string[]
isLoading: boolean
setModels: (models: string[]) => void
fetchModels: () => Promise<void>
}

View File

@@ -59,7 +59,6 @@ export interface OrganizationFormData {
logo: string
}
// Organization billing and usage types
export interface MemberUsageData {
userId: string
userName: string

View 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)
}

View 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>
}

View File

@@ -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
},

View File

@@ -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>
}

View File

@@ -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) {

View File

@@ -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({