improvement(api-keys): move to workspace level (#1765)

* fix(billing): should allow restoring subscription (#1728)

* fix(already-cancelled-sub): UI should allow restoring subscription

* restore functionality fixed

* fix

* improvement(api-keys): move to workspace level

* remove migration to prep merge

* remove two more unused cols

* prep staging  merge

* add migration back

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
This commit is contained in:
Vikhyath Mondreti
2025-10-30 11:42:58 -07:00
committed by GitHub
parent c99bb0aaa2
commit fe9ebbf81b
61 changed files with 8208 additions and 1530 deletions

View File

@@ -284,7 +284,6 @@ class AsyncExecutionResult:
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```

View File

@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```

View File

@@ -283,7 +283,6 @@ class AsyncExecutionResult:
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```

View File

@@ -294,7 +294,6 @@ interface AsyncExecutionResult {
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```

View File

@@ -284,7 +284,6 @@ class AsyncExecutionResult:
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```

View File

@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```

View File

@@ -284,7 +284,6 @@ class AsyncExecutionResult:
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```

View File

@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```

View File

@@ -284,7 +284,6 @@ class AsyncExecutionResult:
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```

View File

@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```

View File

@@ -284,7 +284,6 @@ class AsyncExecutionResult:
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```

View File

@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```

View File

@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { subscription as subscriptionTable, user } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { requireStripeClient } from '@/lib/billing/stripe-client'
@@ -38,7 +38,10 @@ export async function POST(request: NextRequest) {
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
eq(subscriptionTable.status, 'active')
or(
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.cancelAtPeriodEnd, true)
)
)
)
.limit(1)

View File

@@ -109,17 +109,17 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id',
workspaceId: 'test-workspace-id',
})
vi.doMock('@/lib/api-key/service', async () => {
const actual = await vi.importActual('@/lib/api-key/service')
vi.doMock('@/lib/workspaces/utils', async () => {
const actual = await vi.importActual('@/lib/workspaces/utils')
return {
...(actual as Record<string, unknown>),
getApiKeyOwnerUserId: vi
getWorkspaceBilledAccountUserId: vi
.fn()
.mockImplementation(async (pinnedApiKeyId: string | null | undefined) =>
pinnedApiKeyId ? 'test-user-id' : null
.mockImplementation(async (workspaceId: string | null | undefined) =>
workspaceId ? 'test-user-id' : null
),
}
})
@@ -240,7 +240,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id',
workspaceId: 'test-workspace-id',
})
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
@@ -272,7 +272,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id',
workspaceId: 'test-workspace-id',
})
const headers = {
@@ -307,7 +307,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id',
workspaceId: 'test-workspace-id',
})
const headers = {
@@ -338,7 +338,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id',
workspaceId: 'test-workspace-id',
})
vi.doMock('@trigger.dev/sdk', () => ({
@@ -388,7 +388,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({
id: 'test-workflow-id',
userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id',
workspaceId: 'test-workspace-id',
})
vi.doMock('@trigger.dev/sdk', () => ({

View File

@@ -1,6 +1,6 @@
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { deployWorkflow } from '@/lib/workflows/db-helpers'
@@ -38,35 +38,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
}
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
if (workflowData.pinnedApiKeyId) {
const pinnedKey = await db
.select({ key: apiKey.key, name: apiKey.name, type: apiKey.type })
.from(apiKey)
.where(eq(apiKey.id, workflowData.pinnedApiKeyId))
.limit(1)
if (pinnedKey.length > 0) {
keyInfo = { name: pinnedKey[0].name, type: pinnedKey[0].type as 'personal' | 'workspace' }
}
} else {
const userApiKey = await db
.select({
key: apiKey.key,
name: apiKey.name,
type: apiKey.type,
})
.from(apiKey)
.where(and(eq(apiKey.userId, workflowData.userId), eq(apiKey.type, 'personal')))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
if (userApiKey.length > 0) {
keyInfo = { name: userApiKey[0].name, type: userApiKey[0].type as 'personal' | 'workspace' }
}
}
let needsRedeployment = false
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
@@ -97,7 +68,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'No API key found'
const responseApiKeyInfo = workflowData.workspaceId ? 'Workspace API keys' : 'Personal API keys'
return createSuccessResponse({
apiKey: responseApiKeyInfo,
@@ -127,101 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(error.message, error.status)
}
const userId = workflowData!.userId
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {}
logger.debug(`[${requestId}] Validating API key for deployment`)
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
let matchedKey: {
id: string
key: string
name: string
type: 'personal' | 'workspace'
} | null = null
// Use provided API key, or fall back to existing pinned API key for redeployment
const apiKeyToUse = providedApiKey || workflowData!.pinnedApiKeyId
if (!apiKeyToUse) {
return NextResponse.json(
{ error: 'API key is required. Please create or select an API key before deploying.' },
{ status: 400 }
)
}
let isValidKey = false
const currentUserId = session?.user?.id
if (currentUserId) {
const [personalKey] = await db
.select({
id: apiKey.id,
key: apiKey.key,
name: apiKey.name,
expiresAt: apiKey.expiresAt,
})
.from(apiKey)
.where(
and(
eq(apiKey.id, apiKeyToUse),
eq(apiKey.userId, currentUserId),
eq(apiKey.type, 'personal')
)
)
.limit(1)
if (personalKey) {
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
matchedKey = { ...personalKey, type: 'personal' }
isValidKey = true
keyInfo = { name: personalKey.name, type: 'personal' }
}
}
}
if (!isValidKey) {
if (workflowData!.workspaceId) {
const [workspaceKey] = await db
.select({
id: apiKey.id,
key: apiKey.key,
name: apiKey.name,
expiresAt: apiKey.expiresAt,
})
.from(apiKey)
.where(
and(
eq(apiKey.id, apiKeyToUse),
eq(apiKey.workspaceId, workflowData!.workspaceId),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (workspaceKey) {
if (!workspaceKey.expiresAt || workspaceKey.expiresAt >= new Date()) {
matchedKey = { ...workspaceKey, type: 'workspace' }
isValidKey = true
keyInfo = { name: workspaceKey.name, type: 'workspace' }
}
}
}
}
if (!isValidKey) {
logger.warn(`[${requestId}] Invalid API key ID provided for workflow deployment: ${id}`)
return createErrorResponse('Invalid API key provided', 400)
}
// Attribution: this route is UI-only; require session user as actor
const actorUserId: string | null = session?.user?.id ?? null
if (!actorUserId) {
@@ -232,8 +108,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
pinnedApiKeyId: matchedKey?.id,
includeDeployedState: true,
workflowName: workflowData!.name,
})
@@ -243,20 +117,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployedAt = deployResult.deployedAt!
if (matchedKey) {
try {
await db
.update(apiKey)
.set({ lastUsed: new Date(), updatedAt: new Date() })
.where(eq(apiKey.id, matchedKey.id))
} catch (e) {
logger.warn(`[${requestId}] Failed to update lastUsed for api key`)
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key'
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
return createSuccessResponse({
apiKey: responseApiKeyInfo,
@@ -298,7 +163,7 @@ export async function DELETE(
await tx
.update(workflow)
.set({ isDeployed: false, deployedAt: null, deployedState: null, pinnedApiKeyId: null })
.set({ isDeployed: false, deployedAt: null })
.where(eq(workflow.id, id))
})

View File

@@ -1,4 +1,4 @@
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
@@ -19,11 +19,7 @@ export async function POST(
const { id, version } = await params
try {
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
@@ -33,52 +29,6 @@ export async function POST(
return createErrorResponse('Invalid version', 400)
}
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {}
let pinnedApiKeyId: string | null = null
if (providedApiKey) {
const currentUserId = session?.user?.id
if (currentUserId) {
const [personalKey] = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.userId, currentUserId),
eq(apiKey.type, 'personal')
)
)
.limit(1)
if (personalKey) {
pinnedApiKeyId = personalKey.id
} else if (workflowData!.workspaceId) {
const [workspaceKey] = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.workspaceId, workflowData!.workspaceId),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (workspaceKey) {
pinnedApiKeyId = workspaceKey.id
}
}
}
}
const now = new Date()
await db.transaction(async (tx) => {
@@ -112,10 +62,6 @@ export async function POST(
deployedAt: now,
}
if (pinnedApiKeyId) {
updateData.pinnedApiKeyId = pinnedApiKeyId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})

View File

@@ -96,7 +96,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
createdAt: now,
updatedAt: now,
isDeployed: false,
collaborators: [],
runCount: 0,
// Duplicate variables with new IDs and new workflowId
variables: (() => {
@@ -112,8 +111,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
return remapped
})(),
isPublished: false,
marketplaceData: null,
})
// Copy all blocks from source workflow with new IDs

View File

@@ -62,7 +62,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return createSuccessResponse({
isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt,
isPublished: validation.workflow.isPublished,
needsRedeployment,
})
} catch (error) {

View File

@@ -1,6 +1,9 @@
import type { NextRequest } from 'next/server'
import { authenticateApiKey } from '@/lib/api-key/auth'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import {
type ApiKeyAuthResult,
authenticateApiKeyFromHeader,
updateApiKeyLastUsed,
} from '@/lib/api-key/service'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getWorkflowById } from '@/lib/workflows/utils'
@@ -60,50 +63,39 @@ export async function validateWorkflowAccess(
}
}
// If a pinned key exists, only accept that specific key
if (workflow.pinnedApiKey?.key) {
const isValidPinnedKey = await authenticateApiKey(apiKeyHeader, workflow.pinnedApiKey.key)
if (!isValidPinnedKey) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
let validResult: ApiKeyAuthResult | null = null
if (workflow.workspaceId) {
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
workspaceId: workflow.workspaceId as string,
keyTypes: ['workspace', 'personal'],
})
if (workspaceResult.success) {
validResult = workspaceResult
}
} else {
// Try personal keys first
const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
userId: workflow.userId as string,
keyTypes: ['personal'],
})
let validResult = null
if (personalResult.success) {
validResult = personalResult
} else if (workflow.workspaceId) {
// Try workspace keys
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
workspaceId: workflow.workspaceId as string,
keyTypes: ['workspace'],
})
if (workspaceResult.success) {
validResult = workspaceResult
}
}
}
// If no valid key found, reject
if (!validResult) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
if (!validResult) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
}
await updateApiKeyLastUsed(validResult.keyId!)
if (validResult.keyId) {
await updateApiKeyLastUsed(validResult.keyId)
}
}
return { workflow }

View File

@@ -143,11 +143,8 @@ export async function POST(req: NextRequest) {
createdAt: now,
updatedAt: now,
isDeployed: false,
collaborators: [],
runCount: 0,
variables: {},
isPublished: false,
marketplaceData: null,
})
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)

View File

@@ -94,10 +94,27 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) {
if (permission !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const workspaceRows = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRows.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (workspaceRows[0].billedAccountUserId !== userId) {
return NextResponse.json(
{ error: 'Only the workspace billing account can create workspace API keys' },
{ status: 403 }
)
}
const body = await request.json()
const { name } = CreateKeySchema.parse(body)
@@ -181,10 +198,27 @@ export async function DELETE(
const userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) {
if (permission !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const workspaceRows = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRows.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (workspaceRows[0].billedAccountUserId !== userId) {
return NextResponse.json(
{ error: 'Only the workspace billing account can delete workspace API keys' },
{ status: 403 }
)
}
const body = await request.json()
const { keys } = DeleteKeysSchema.parse(body)

View File

@@ -1,6 +1,6 @@
import crypto from 'crypto'
import { db } from '@sim/db'
import { permissions, type permissionTypeEnum } from '@sim/db/schema'
import { permissions, type permissionTypeEnum, workspace } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -94,6 +94,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const body: UpdatePermissionsRequest = await request.json()
const workspaceRow = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
const billedAccountUserId = workspaceRow[0].billedAccountUserId
const selfUpdate = body.updates.find((update) => update.userId === session.user.id)
if (selfUpdate && selfUpdate.permissions !== 'admin') {
return NextResponse.json(
@@ -102,6 +114,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
)
}
if (
billedAccountUserId &&
body.updates.some(
(update) => update.userId === billedAccountUserId && update.permissions !== 'admin'
)
) {
return NextResponse.json(
{ error: 'Workspace billing account must retain admin permissions' },
{ status: 400 }
)
}
await db.transaction(async (tx) => {
for (const update of body.updates) {
await tx

View File

@@ -100,22 +100,95 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
try {
const { name } = await request.json()
const body = await request.json()
const {
name,
billedAccountUserId,
allowPersonalApiKeys,
}: {
name?: string
billedAccountUserId?: string
allowPersonalApiKeys?: boolean
} = body ?? {}
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
if (
name === undefined &&
billedAccountUserId === undefined &&
allowPersonalApiKeys === undefined
) {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 })
}
// Update workspace
await db
.update(workspace)
.set({
name,
updatedAt: new Date(),
})
const existingWorkspace = await db
.select()
.from(workspace)
.where(eq(workspace.id, workspaceId))
.then((rows) => rows[0])
if (!existingWorkspace) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
const updateData: Record<string, unknown> = {}
if (name !== undefined) {
const trimmedName = name.trim()
if (!trimmedName) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
}
updateData.name = trimmedName
}
if (allowPersonalApiKeys !== undefined) {
updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys)
}
if (billedAccountUserId !== undefined) {
const candidateId = billedAccountUserId?.trim()
if (!candidateId) {
return NextResponse.json({ error: 'billedAccountUserId is required' }, { status: 400 })
}
const isOwner = candidateId === existingWorkspace.ownerId
let hasAdminAccess = isOwner
if (!hasAdminAccess) {
const adminPermission = await db
.select({ id: permissions.id })
.from(permissions)
.where(
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId),
eq(permissions.userId, candidateId),
eq(permissions.permissionType, 'admin')
)
)
.limit(1)
hasAdminAccess = adminPermission.length > 0
}
if (!hasAdminAccess) {
return NextResponse.json(
{ error: 'Billed account must be a workspace admin' },
{ status: 400 }
)
}
updateData.billedAccountUserId = candidateId
}
if (Object.keys(updateData).length === 0) {
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
}
updateData.updatedAt = new Date()
await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId))
// Get updated workspace
const updatedWorkspace = await db
.select()
.from(workspace)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { permissions } from '@sim/db/schema'
import { permissions, workspace } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -23,6 +23,23 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
}
const workspaceRow = await db
.select({ billedAccountUserId: workspace.billedAccountUserId })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceRow.length) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}
if (workspaceRow[0].billedAccountUserId === userId) {
return NextResponse.json(
{ error: 'Cannot remove the workspace billing account. Please reassign billing first.' },
{ status: 400 }
)
}
// Check if the user to be removed actually has permissions for this workspace
const userPermission = await db
.select()

View File

@@ -95,6 +95,8 @@ async function createWorkspace(userId: string, name: string) {
id: workspaceId,
name,
ownerId: userId,
billedAccountUserId: userId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
})
@@ -124,11 +126,8 @@ async function createWorkspace(userId: string, name: string) {
createdAt: now,
updatedAt: now,
isDeployed: false,
collaborators: [],
runCount: 0,
variables: {},
isPublished: false,
marketplaceData: null,
})
// No blocks are inserted - empty canvas
@@ -147,6 +146,8 @@ async function createWorkspace(userId: string, name: string) {
id: workspaceId,
name,
ownerId: userId,
billedAccountUserId: userId,
allowPersonalApiKeys: true,
createdAt: now,
updatedAt: now,
role: 'owner',

View File

@@ -1,463 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, Copy, Info, Loader2, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Input,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
const logger = createLogger('ApiKeySelector')
export interface ApiKey {
id: string
name: string
key: string
displayKey?: string
lastUsed?: string
createdAt: string
expiresAt?: string
createdBy?: string
}
interface ApiKeysData {
workspace: ApiKey[]
personal: ApiKey[]
}
interface ApiKeySelectorProps {
value: string
onChange: (keyId: string) => void
disabled?: boolean
apiKeys?: ApiKey[]
onApiKeyCreated?: () => void
showLabel?: boolean
label?: string
isDeployed?: boolean
deployedApiKeyDisplay?: string
}
export function ApiKeySelector({
value,
onChange,
disabled = false,
apiKeys = [],
onApiKeyCreated,
showLabel = true,
label = 'API Key',
isDeployed = false,
deployedApiKeyDisplay,
}: ApiKeySelectorProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
const [apiKeysData, setApiKeysData] = useState<ApiKeysData | null>(null)
const [isCreatingKey, setIsCreatingKey] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
const [keysLoaded, setKeysLoaded] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [justCreatedKeyId, setJustCreatedKeyId] = useState<string | null>(null)
useEffect(() => {
fetchApiKeys()
}, [workspaceId])
const fetchApiKeys = async () => {
try {
setKeysLoaded(false)
const [workspaceRes, personalRes] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}/api-keys`),
fetch('/api/users/me/api-keys'),
])
const workspaceData = workspaceRes.ok ? await workspaceRes.json() : { keys: [] }
const personalData = personalRes.ok ? await personalRes.json() : { keys: [] }
setApiKeysData({
workspace: workspaceData.keys || [],
personal: personalData.keys || [],
})
setKeysLoaded(true)
} catch (error) {
logger.error('Error fetching API keys:', { error })
setKeysLoaded(true)
}
}
const handleCreateKey = async () => {
if (!newKeyName.trim()) {
setCreateError('Please enter a name for the API key')
return
}
try {
setIsSubmittingCreate(true)
setCreateError(null)
const endpoint =
keyType === 'workspace'
? `/api/workspaces/${workspaceId}/api-keys`
: '/api/users/me/api-keys'
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to create API key')
}
const data = await response.json()
setNewKey(data.key)
setJustCreatedKeyId(data.key.id)
setShowNewKeyDialog(true)
setIsCreatingKey(false)
setNewKeyName('')
// Refresh API keys
await fetchApiKeys()
onApiKeyCreated?.()
} catch (error: any) {
setCreateError(error.message || 'Failed to create API key')
} finally {
setIsSubmittingCreate(false)
}
}
const handleCopyKey = async () => {
if (newKey?.key) {
await navigator.clipboard.writeText(newKey.key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
}
if (isDeployed && deployedApiKeyDisplay) {
return (
<div className='space-y-1.5'>
{showLabel && (
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>{label}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className='h-3.5 w-3.5 text-muted-foreground' />
</TooltipTrigger>
<TooltipContent>
<p>Owner is billed for usage</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<div className='rounded-md border bg-background'>
<div className='flex items-center justify-between p-3'>
<pre className='flex-1 overflow-x-auto whitespace-pre-wrap font-mono text-xs'>
{(() => {
const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
if (match) {
return match[1].trim()
}
return deployedApiKeyDisplay
})()}
</pre>
{(() => {
const match = deployedApiKeyDisplay.match(/^(.*?)\s+\(([^)]+)\)$/)
if (match) {
const type = match[2]
return (
<div className='ml-2 flex-shrink-0'>
<span className='inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs capitalize'>
{type}
</span>
</div>
)
}
return null
})()}
</div>
</div>
</div>
)
}
return (
<>
<div className='space-y-2'>
{showLabel && (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-1.5'>
<Label className='font-medium text-sm'>{label}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className='h-3.5 w-3.5 text-muted-foreground' />
</TooltipTrigger>
<TooltipContent>
<p>Key Owner is Billed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{!disabled && (
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 gap-1 px-2 text-muted-foreground text-xs'
onClick={() => {
setIsCreatingKey(true)
setCreateError(null)
}}
>
<Plus className='h-3.5 w-3.5' />
<span>Create new</span>
</Button>
)}
</div>
)}
<Select value={value} onValueChange={onChange} disabled={disabled || !keysLoaded}>
<SelectTrigger className={!keysLoaded ? 'opacity-70' : ''}>
{!keysLoaded ? (
<div className='flex items-center space-x-2'>
<Loader2 className='h-3.5 w-3.5 animate-spin' />
<span>Loading API keys...</span>
</div>
) : (
<SelectValue placeholder='Select an API key' className='text-sm' />
)}
</SelectTrigger>
<SelectContent align='start' className='w-[var(--radix-select-trigger-width)] py-1'>
{apiKeysData && apiKeysData.workspace.length > 0 && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Workspace
</SelectLabel>
{apiKeysData.workspace.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{((apiKeysData && apiKeysData.personal.length > 0) ||
(!apiKeysData && apiKeys.length > 0)) && (
<SelectGroup>
<SelectLabel className='px-3 py-1.5 font-medium text-muted-foreground text-xs uppercase tracking-wide'>
Personal
</SelectLabel>
{(apiKeysData ? apiKeysData.personal : apiKeys).map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
className='my-0.5 flex cursor-pointer items-center rounded-sm px-3 py-2.5 data-[state=checked]:bg-muted [&>span.absolute]:hidden'
>
<div className='flex w-full items-center'>
<div className='flex w-full items-center justify-between'>
<span className='mr-2 truncate text-sm'>{apiKey.name}</span>
<span className='mt-[1px] flex-shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground text-xs'>
{apiKey.displayKey || apiKey.key}
</span>
</div>
</div>
</SelectItem>
))}
</SelectGroup>
)}
{!apiKeysData && apiKeys.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div>
)}
{apiKeysData &&
apiKeysData.workspace.length === 0 &&
apiKeysData.personal.length === 0 && (
<div className='px-3 py-2 text-muted-foreground text-sm'>No API keys available</div>
)}
</SelectContent>
</Select>
</div>
{/* Create Key Dialog */}
<AlertDialog open={isCreatingKey} onOpenChange={setIsCreatingKey}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-4 py-2'>
{canCreateWorkspaceKeys && (
<div className='space-y-2'>
<p className='font-[360] text-sm'>API Key Type</p>
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'default' : 'outline'}
size='sm'
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
>
Workspace
</Button>
</div>
</div>
)}
<div className='space-y-2'>
<Label htmlFor='new-key-name'>API Key Name</Label>
<Input
id='new-key-name'
placeholder='My API Key'
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null)
}}
disabled={isSubmittingCreate}
/>
{createError && <p className='text-destructive text-sm'>{createError}</p>}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel
disabled={isSubmittingCreate}
onClick={() => {
setNewKeyName('')
setCreateError(null)
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isSubmittingCreate || !newKeyName.trim()}
onClick={(e) => {
e.preventDefault()
handleCreateKey()
}}
>
{isSubmittingCreate ? (
<>
<Loader2 className='mr-1.5 h-3 w-3 animate-spin' />
Creating...
</>
) : (
'Create'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* New Key Dialog */}
<AlertDialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
setCopySuccess(false)
if (justCreatedKeyId) {
onChange(justCreatedKeyId)
setJustCreatedKeyId(null)
}
}
}}
>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
<AlertDialogDescription>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</AlertDialogDescription>
</AlertDialogHeader>
{newKey && (
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={handleCopyKey}
>
{copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -1,94 +0,0 @@
'use client'
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'
import { createLogger } from '@/lib/logs/console/logger'
import {
type ApiKey,
ApiKeySelector,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/api-key-selector/api-key-selector'
const logger = createLogger('DeployForm')
// Form schema for API key selection or creation
const deployFormSchema = z.object({
apiKey: z.string().min(1, 'Please select an API key'),
newKeyName: z.string().optional(),
})
type DeployFormValues = z.infer<typeof deployFormSchema>
interface DeployFormProps {
apiKeys: ApiKey[]
selectedApiKeyId: string
onApiKeyChange: (keyId: string) => void
onSubmit: (data: DeployFormValues) => void
onApiKeyCreated?: () => void
formId?: string
isDeployed?: boolean
deployedApiKeyDisplay?: string
}
export function DeployForm({
apiKeys,
selectedApiKeyId,
onApiKeyChange,
onSubmit,
onApiKeyCreated,
formId,
isDeployed = false,
deployedApiKeyDisplay,
}: DeployFormProps) {
const form = useForm<DeployFormValues>({
resolver: zodResolver(deployFormSchema),
defaultValues: {
apiKey: selectedApiKeyId || (apiKeys.length > 0 ? apiKeys[0].id : ''),
newKeyName: '',
},
})
useEffect(() => {
if (selectedApiKeyId) {
form.setValue('apiKey', selectedApiKeyId)
}
}, [selectedApiKeyId, form])
return (
<Form {...form}>
<form
id={formId}
onSubmit={(e) => {
e.preventDefault()
onSubmit(form.getValues())
}}
className='space-y-6'
>
<FormField
control={form.control}
name='apiKey'
render={({ field }) => (
<FormItem className='space-y-1.5'>
<ApiKeySelector
value={field.value}
onChange={(keyId) => {
field.onChange(keyId)
onApiKeyChange(keyId)
}}
apiKeys={apiKeys}
onApiKeyCreated={onApiKeyCreated}
showLabel={true}
label='Select API Key'
isDeployed={isDeployed}
deployedApiKeyDisplay={deployedApiKeyDisplay}
/>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}

View File

@@ -17,7 +17,6 @@ import {
} from '@/components/ui'
import {
ApiEndpoint,
ApiKey,
DeployStatus,
ExampleCommand,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components'
@@ -114,7 +113,6 @@ export function DeploymentInfo({
<div className='space-y-4 overflow-y-auto px-1'>
<div className='space-y-4'>
<ApiEndpoint endpoint={deploymentInfo.endpoint} />
<ApiKey apiKey={deploymentInfo.apiKey} />
<ExampleCommand
command={deploymentInfo.exampleCommand}
apiKey={deploymentInfo.apiKey}

View File

@@ -1,4 +1,3 @@
export { ChatDeploy } from './chat-deploy/chat-deploy'
export { DeployForm } from './deploy-form/deploy-form'
export { DeploymentInfo } from './deployment-info/deployment-info'
export { ImageSelector } from './image-selector/image-selector'

View File

@@ -17,10 +17,7 @@ import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
import {
DeployForm,
DeploymentInfo,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -29,7 +26,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployModal')
interface DeployModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -41,15 +37,6 @@ interface DeployModalProps {
refetchDeployedState: () => Promise<void>
}
interface ApiKey {
id: string
name: string
key: string
lastUsed?: string
createdAt: string
expiresAt?: string
}
interface WorkflowDeploymentInfo {
isDeployed: boolean
deployedAt?: string
@@ -59,12 +46,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean
}
interface DeployFormValues {
apiKey: string
newKeyName?: string
}
type TabView = 'general' | 'api' | 'versions' | 'chat'
type TabView = 'api' | 'versions' | 'chat'
export function DeployModal({
open,
@@ -85,9 +67,11 @@ export function DeployModal({
const [isUndeploying, setIsUndeploying] = useState(false)
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
const [activeTab, setActiveTab] = useState<TabView>('general')
const [selectedApiKeyId, setSelectedApiKeyId] = useState<string>('')
const workflowMetadata = useWorkflowRegistry((state) =>
workflowId ? state.workflows[workflowId] : undefined
)
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
const [activeTab, setActiveTab] = useState<TabView>('api')
const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [chatExists, setChatExists] = useState(false)
@@ -116,6 +100,16 @@ export function DeployModal({
}
}, [editingVersion])
const getApiKeyLabel = (value?: string | null) => {
if (value && value.trim().length > 0) {
return value
}
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
}
const getApiHeaderPlaceholder = () =>
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
const getInputFormatExample = (includeStreaming = false) => {
let inputFormatExample = ''
try {
@@ -209,21 +203,6 @@ export function DeployModal({
return inputFormatExample
}
const fetchApiKeys = async () => {
if (!open) return
try {
const response = await fetch('/api/users/me/api-keys')
if (response.ok) {
const data = await response.json()
setApiKeys(data.keys || [])
}
} catch (error) {
logger.error('Error fetching API keys:', { error })
}
}
const fetchChatDeploymentInfo = async () => {
if (!open || !workflowId) return
@@ -252,39 +231,19 @@ export function DeployModal({
useEffect(() => {
if (open) {
setIsLoading(true)
fetchApiKeys()
fetchChatDeploymentInfo()
setActiveTab('api')
setVersionToActivate(null)
} else {
setSelectedApiKeyId('')
setVersionToActivate(null)
}
}, [open, workflowId])
useEffect(() => {
if (apiKeys.length === 0) return
if (deploymentInfo?.apiKey) {
const matchingKey = apiKeys.find((k) => k.key === deploymentInfo.apiKey)
if (matchingKey) {
setSelectedApiKeyId(matchingKey.id)
return
}
}
if (!selectedApiKeyId) {
setSelectedApiKeyId(apiKeys[0].id)
}
}, [deploymentInfo, apiKeys])
useEffect(() => {
async function fetchDeploymentInfo() {
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
if (!open) {
setIsLoading(false)
}
setIsLoading(false)
return
}
@@ -305,13 +264,14 @@ export function DeployModal({
const data = await response.json()
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY'
setDeploymentInfo({
isDeployed: data.isDeployed,
deployedAt: data.deployedAt,
apiKey: data.apiKey,
apiKey: data.apiKey || placeholderKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment,
})
} catch (error) {
@@ -324,14 +284,12 @@ export function DeployModal({
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
const onDeploy = async (data: DeployFormValues) => {
const onDeploy = async () => {
setApiDeployError(null)
try {
setIsSubmitting(true)
const apiKeyToUse = data.apiKey || selectedApiKeyId
let deployEndpoint = `/api/workflows/${workflowId}/deploy`
if (versionToActivate !== null) {
deployEndpoint = `/api/workflows/${workflowId}/deployments/${versionToActivate}/activate`
@@ -343,7 +301,6 @@ export function DeployModal({
'Content-Type': 'application/json',
},
body: JSON.stringify({
apiKey: apiKeyToUse,
deployChatEnabled: false,
}),
})
@@ -358,14 +315,9 @@ export function DeployModal({
const isActivating = versionToActivate !== null
const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false)
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
const apiKeyFromResponse = responseData.apiKey || apiKeyToUse
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyFromResponse)
const matchingKey = apiKeys.find((k) => k.key === apiKeyFromResponse || k.id === apiKeyToUse)
if (matchingKey) {
setSelectedApiKeyId(matchingKey.id)
}
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
const isActivatingVersion = versionToActivate !== null
setNeedsRedeployment(isActivatingVersion)
@@ -381,13 +333,14 @@ export function DeployModal({
const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: deploymentData.apiKey,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: isActivatingVersion,
})
}
@@ -543,7 +496,7 @@ export function DeployModal({
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
apiKey
getApiKeyLabel(apiKey)
)
setNeedsRedeployment(false)
@@ -573,7 +526,7 @@ export function DeployModal({
const isActivating = versionToActivate !== null
setDeploymentStatus(workflowId, true, new Date())
setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) {
@@ -581,12 +534,14 @@ export function DeployModal({
const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({
isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt,
apiKey: deploymentData.apiKey,
apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
needsRedeployment: isActivating,
})
}
@@ -679,72 +634,67 @@ export function DeployModal({
<div className='flex-1 overflow-y-auto'>
<div className='p-6' key={`${activeTab}-${versionToActivate}`}>
{activeTab === 'api' && (
<>
{versionToActivate !== null ? (
<>
{apiDeployError && (
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<div className='-mx-1 px-1'>
<DeployForm
apiKeys={apiKeys}
selectedApiKeyId={selectedApiKeyId}
onApiKeyChange={setSelectedApiKeyId}
onSubmit={onDeploy}
onApiKeyCreated={fetchApiKeys}
formId='deploy-api-form'
isDeployed={false}
deployedApiKeyDisplay={undefined}
/>
</div>
</>
) : isDeployed ? (
<>
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={
deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
isUndeploying={isUndeploying}
workflowId={workflowId}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
getInputFormatExample={getInputFormatExample}
selectedStreamingOutputs={selectedStreamingOutputs}
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
/>
</>
) : (
<>
{apiDeployError && (
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
<div className='-mx-1 px-1'>
<DeployForm
apiKeys={apiKeys}
selectedApiKeyId={selectedApiKeyId}
onApiKeyChange={setSelectedApiKeyId}
onSubmit={onDeploy}
onApiKeyCreated={fetchApiKeys}
formId='deploy-api-form'
isDeployed={false}
deployedApiKeyDisplay={undefined}
/>
</div>
</>
<div className='space-y-4'>
{apiDeployError && (
<div className='rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
<div className='font-semibold'>API Deployment Error</div>
<div>{apiDeployError}</div>
</div>
)}
</>
{versionToActivate !== null ? (
<div className='space-y-4'>
<div className='rounded-md border bg-muted/40 p-4 text-muted-foreground text-sm'>
{`Deploy version ${
versions.find((v) => v.version === versionToActivate)?.name ||
`v${versionToActivate}`
} to production.`}
</div>
<div className='flex gap-2'>
<Button
onClick={onDeploy}
disabled={isSubmitting}
className={cn(
'gap-2 font-medium',
'bg-[var(--brand-primary-hover-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hover-hex)] disabled:hover:shadow-none'
)}
>
{isSubmitting ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Deploying...
</>
) : (
'Deploy version'
)}
</Button>
<Button variant='outline' onClick={() => setVersionToActivate(null)}>
Cancel
</Button>
</div>
</div>
) : (
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={
deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
isUndeploying={isUndeploying}
workflowId={workflowId}
deployedState={deployedState}
isLoadingDeployedState={isLoadingDeployedState}
getInputFormatExample={getInputFormatExample}
selectedStreamingOutputs={selectedStreamingOutputs}
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
/>
)}
</div>
)}
{activeTab === 'versions' && (
@@ -915,57 +865,23 @@ export function DeployModal({
)}
{activeTab === 'chat' && (
<>
<ChatDeploy
workflowId={workflowId || ''}
deploymentInfo={deploymentInfo}
onChatExistsChange={setChatExists}
chatSubmitting={chatSubmitting}
setChatSubmitting={setChatSubmitting}
onValidationChange={setIsChatFormValid}
onDeploymentComplete={handleCloseModal}
onDeployed={handlePostDeploymentUpdate}
onUndeploy={handleUndeploy}
onVersionActivated={() => setVersionToActivate(null)}
/>
</>
<ChatDeploy
workflowId={workflowId || ''}
deploymentInfo={deploymentInfo}
onChatExistsChange={setChatExists}
chatSubmitting={chatSubmitting}
setChatSubmitting={setChatSubmitting}
onValidationChange={setIsChatFormValid}
onDeploymentComplete={handleCloseModal}
onDeployed={handlePostDeploymentUpdate}
onUndeploy={handleUndeploy}
onVersionActivated={() => setVersionToActivate(null)}
/>
)}
</div>
</div>
</div>
{activeTab === 'api' && (versionToActivate !== null || !isDeployed) && (
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
<Button variant='outline' onClick={handleCloseModal}>
Cancel
</Button>
<Button
type='submit'
form='deploy-api-form'
disabled={isSubmitting || !apiKeys.length}
className={cn(
'gap-2 font-medium',
'bg-[var(--brand-primary-hover-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'text-white transition-all duration-200',
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hover-hex)] disabled:hover:shadow-none'
)}
>
{isSubmitting ? (
<>
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
Deploying...
</>
) : versionToActivate !== null ? (
`Deploy ${versions.find((v) => v.version === versionToActivate)?.name || `v${versionToActivate}`}`
) : (
'Deploy API'
)}
</Button>
</div>
)}
{activeTab === 'chat' && (
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
<Button variant='outline' onClick={handleCloseModal}>

View File

@@ -37,7 +37,7 @@ export function DeploymentControls({
const workflowNeedsRedeployment = needsRedeployment
const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment
const [isDeploying, _setIsDeploying] = useState(false)
const [isDeploying, setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const lastWorkflowIdRef = useRef<string | null>(null)
@@ -59,11 +59,52 @@ export function DeploymentControls({
const canDeploy = userPermissions.canAdmin
const isDisabled = isDeploying || !canDeploy
const handleDeployClick = useCallback(() => {
if (canDeploy) {
setIsModalOpen(true)
const handleDeployClick = useCallback(async () => {
if (!canDeploy || !activeWorkflowId) return
// If undeployed, deploy first then open modal
if (!isDeployed) {
setIsDeploying(true)
try {
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
deployChatEnabled: false,
}),
})
if (response.ok) {
const responseData = await response.json()
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
const isDeployedStatus = responseData.isDeployed ?? false
const deployedAtTime = responseData.deployedAt
? new Date(responseData.deployedAt)
: undefined
setDeploymentStatus(
activeWorkflowId,
isDeployedStatus,
deployedAtTime,
responseData.apiKey || ''
)
await refetchWithErrorHandling()
// Open modal after successful deployment
setIsModalOpen(true)
}
} catch (error) {
// On error, still open modal to show error
setIsModalOpen(true)
} finally {
setIsDeploying(false)
}
return
}
}, [canDeploy, setIsModalOpen])
// If already deployed, just open modal
setIsModalOpen(true)
}, [canDeploy, isDeployed, activeWorkflowId, refetchWithErrorHandling])
const getTooltipText = () => {
if (!canDeploy) {

View File

@@ -1151,7 +1151,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
// Get and sort regular workflows by creation date (newest first) for stable ordering
const regularWorkflows = Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId)
.filter((workflow) => workflow.marketplaceData?.status !== 'temp')
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
// Group workflows by folder

View File

@@ -368,14 +368,12 @@ function useDragHandlers(
interface FolderTreeProps {
regularWorkflows: WorkflowMetadata[]
marketplaceWorkflows: WorkflowMetadata[]
isLoading?: boolean
onCreateWorkflow: (folderId?: string) => void
}
export function FolderTree({
regularWorkflows,
marketplaceWorkflows,
isLoading = false,
onCreateWorkflow,
}: FolderTreeProps) {
@@ -565,15 +563,12 @@ export function FolderTree({
))}
{/* Empty state */}
{!showLoading &&
regularWorkflows.length === 0 &&
marketplaceWorkflows.length === 0 &&
folderTree.length === 0 && (
<div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'>
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create
one to get started.
</div>
)}
{!showLoading && regularWorkflows.length === 0 && folderTree.length === 0 && (
<div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'>
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create
one to get started.
</div>
)}
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Copy, Plus, Search } from 'lucide-react'
import { Check, Copy, Info, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
AlertDialog,
@@ -17,6 +17,8 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -58,7 +60,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
const canManageWorkspaceKeys = userPermissions.canAdmin
// State for both workspace and personal keys
const [workspaceKeys, setWorkspaceKeys] = useState<ApiKey[]>([])
@@ -79,6 +81,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [billedAccountUserId, setBilledAccountUserId] = useState<string | null>(null)
const [allowPersonalApiKeys, setAllowPersonalApiKeys] = useState<boolean>(true)
const [workspaceAdmins, setWorkspaceAdmins] = useState<
Array<{ userId: string; name: string; email: string; permissionType: string }>
>([])
const [workspaceSettingsLoading, setWorkspaceSettingsLoading] = useState<boolean>(true)
const [workspaceSettingsUpdating, setWorkspaceSettingsUpdating] = useState<boolean>(false)
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const createButtonDisabled =
workspaceSettingsLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const filteredWorkspaceKeys = useMemo(() => {
@@ -147,6 +161,75 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}
const fetchWorkspaceSettings = async () => {
if (!workspaceId) return
setWorkspaceSettingsLoading(true)
try {
const [workspaceResponse, permissionsResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}`),
fetch(`/api/workspaces/${workspaceId}/permissions`),
])
if (workspaceResponse.ok) {
const data = await workspaceResponse.json()
const workspaceData = data.workspace ?? {}
setBilledAccountUserId(workspaceData.billedAccountUserId ?? null)
setAllowPersonalApiKeys(
workspaceData.allowPersonalApiKeys === undefined
? true
: Boolean(workspaceData.allowPersonalApiKeys)
)
} else {
logger.error('Failed to fetch workspace details', { status: workspaceResponse.status })
}
if (permissionsResponse.ok) {
const data = await permissionsResponse.json()
const users = Array.isArray(data.users) ? data.users : []
const admins = users.filter((user: any) => user.permissionType === 'admin')
setWorkspaceAdmins(admins)
} else {
logger.error('Failed to fetch workspace permissions', {
status: permissionsResponse.status,
})
}
} catch (error) {
logger.error('Error fetching workspace settings:', { error })
} finally {
setWorkspaceSettingsLoading(false)
}
}
const updateWorkspaceSettings = async (updates: {
billedAccountUserId?: string
allowPersonalApiKeys?: boolean
}) => {
if (!workspaceId) return
setWorkspaceSettingsUpdating(true)
try {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to update workspace settings')
}
await fetchWorkspaceSettings()
} catch (error) {
logger.error('Error updating workspace settings:', { error })
throw error
} finally {
setWorkspaceSettingsUpdating(false)
}
}
const handleCreateKey = async () => {
if (!userId || !newKeyName.trim()) return
@@ -281,6 +364,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}, [registerCloseHandler])
useEffect(() => {
if (workspaceId) {
fetchWorkspaceSettings()
}
}, [workspaceId])
useEffect(() => {
if (!allowPersonalApiKeys && keyType === 'personal') {
setKeyType('workspace')
}
}, [allowPersonalApiKeys, keyType])
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
@@ -303,7 +398,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
<div className='px-6 pt-2 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
@@ -338,6 +433,50 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
) : (
<>
{/* Allow Personal API Keys Toggle */}
{!searchTerm.trim() && (
<TooltipProvider delayDuration={150}>
<div className='mb-6 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[12px] text-foreground'>
Allow personal API keys
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type='button'
className='rounded-full p-1 text-muted-foreground transition hover:text-foreground'
>
<Info className='h-3 w-3' strokeWidth={2} />
</button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs text-xs'>
Allow collaborators to create and use their own keys with billing charged
to them.
</TooltipContent>
</Tooltip>
</div>
{workspaceSettingsLoading ? (
<Skeleton className='h-5 w-16 rounded-full' />
) : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
onCheckedChange={async (checked) => {
const previous = allowPersonalApiKeys
setAllowPersonalApiKeys(checked)
try {
await updateWorkspaceSettings({ allowPersonalApiKeys: checked })
} catch (error) {
setAllowPersonalApiKeys(previous)
}
}}
/>
)}
</div>
</TooltipProvider>
)}
{/* Workspace section */}
{!searchTerm.trim() ? (
<div className='mb-6 space-y-2'>
@@ -468,27 +607,26 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
<div className='flex w-full items-center px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[108px]' />
</>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
) : (
<>
<Button
onClick={() => {
setIsCreateDialogOpen(true)
setKeyType('personal')
setCreateError(null)
}}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
</Button>
</>
<Button
onClick={() => {
if (createButtonDisabled) {
return
}
setIsCreateDialogOpen(true)
setKeyType(defaultKeyType)
setCreateError(null)
}}
variant='ghost'
disabled={createButtonDisabled}
className='h-8 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
</Button>
)}
</div>
</div>
@@ -518,7 +656,8 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
setKeyType('personal')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
disabled={!allowPersonalApiKeys}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80 disabled:opacity-60 disabled:cursor-not-allowed'
>
Personal
</Button>
@@ -560,7 +699,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
className='h-9 w-full rounded-[8px] border-border bg-background text-foreground hover:bg-muted dark:border-border dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
onClick={() => {
setNewKeyName('')
setKeyType('personal')
setKeyType(defaultKeyType)
}}
>
Cancel

View File

@@ -12,7 +12,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
@@ -30,6 +29,7 @@ interface CancelSubscriptionProps {
}
subscriptionData?: {
periodEnd?: Date | null
cancelAtPeriodEnd?: boolean
}
}
@@ -127,35 +127,48 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const subscriptionStatus = getSubscriptionStatus()
const activeOrgId = activeOrganization?.id
// For team/enterprise plans, get the subscription ID from organization store
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
const orgSubscription = useOrganizationStore.getState().subscriptionData
if (orgSubscription?.id && orgSubscription?.cancelAtPeriodEnd) {
// Restore the organization subscription
if (!betterAuthSubscription.restore) {
throw new Error('Subscription restore not available')
}
const result = await betterAuthSubscription.restore({
referenceId: activeOrgId,
subscriptionId: orgSubscription.id,
})
logger.info('Organization subscription restored successfully', result)
if (isCancelAtPeriodEnd) {
if (!betterAuthSubscription.restore) {
throw new Error('Subscription restore not available')
}
let referenceId: string
let subscriptionId: string | undefined
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
const orgSubscription = useOrganizationStore.getState().subscriptionData
referenceId = activeOrgId
subscriptionId = orgSubscription?.id
} else {
// For personal subscriptions, use user ID and let better-auth find the subscription
referenceId = session.user.id
subscriptionId = undefined
}
logger.info('Restoring subscription', { referenceId, subscriptionId })
// Build restore params - only include subscriptionId if we have one (team/enterprise)
const restoreParams: any = { referenceId }
if (subscriptionId) {
restoreParams.subscriptionId = subscriptionId
}
const result = await betterAuthSubscription.restore(restoreParams)
logger.info('Subscription restored successfully', result)
}
// Refresh state and close
await refresh()
if (activeOrgId) {
await loadOrganizationSubscription(activeOrgId)
await refreshOrganization().catch(() => {})
}
setIsDialogOpen(false)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to keep subscription'
const errorMessage = error instanceof Error ? error.message : 'Failed to restore subscription'
setError(errorMessage)
logger.error('Failed to keep subscription', { error })
logger.error('Failed to restore subscription', { error })
} finally {
setIsLoading(false)
}
@@ -190,19 +203,15 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const periodEndDate = getPeriodEndDate()
// Check if subscription is set to cancel at period end
const isCancelAtPeriodEnd = (() => {
const subscriptionStatus = getSubscriptionStatus()
if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) {
return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true
}
return false
})()
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
return (
<>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-sm'>Manage Subscription</span>
<span className='font-medium text-sm'>
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
</span>
{isCancelAtPeriodEnd && (
<p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)}
@@ -217,10 +226,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
error
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
: isCancelAtPeriodEnd
? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500'
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
)}
>
{error ? 'Error' : 'Manage'}
{error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}
</Button>
</div>
@@ -228,11 +239,11 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{isCancelAtPeriodEnd ? 'Manage' : 'Cancel'} {subscription.plan} subscription?
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} subscription?
</AlertDialogTitle>
<AlertDialogDescription>
{isCancelAtPeriodEnd
? 'Your subscription is set to cancel at the end of the billing period. You can reactivate it or manage other settings.'
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
periodEndDate
)}, then downgrade to free plan.`}{' '}
@@ -260,38 +271,23 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={handleKeep}
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
disabled={isLoading}
>
Keep Subscription
{isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'}
</AlertDialogCancel>
{(() => {
const subscriptionStatus = getSubscriptionStatus()
if (
subscriptionStatus.isPaid &&
(activeOrganization?.id
? useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd
: false)
) {
if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<AlertDialogAction
disabled
className='h-9 w-full cursor-not-allowed rounded-[8px] bg-muted text-muted-foreground opacity-50'
>
Continue
</AlertDialogAction>
</div>
</TooltipTrigger>
<TooltipContent side='top'>
<p>Subscription will be cancelled at end of billing period</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialogAction
onClick={handleKeep}
className='h-9 w-full rounded-[8px] bg-green-500 text-white transition-all duration-200 hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600'
disabled={isLoading}
>
{isLoading ? 'Restoring...' : 'Restore Subscription'}
</AlertDialogAction>
)
}
return (

View File

@@ -1,10 +1,22 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Skeleton, Switch } from '@/components/ui'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
import { getBaseUrl } from '@/lib/urls/utils'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header'
import {
CancelSubscription,
@@ -175,6 +187,11 @@ const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() +
export function Subscription({ onOpenChange }: SubscriptionProps) {
const { data: session } = useSession()
const { handleUpgrade } = useSubscriptionUpgrade()
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const logger = createLogger('Subscription')
const {
isLoading,
@@ -191,6 +208,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
const usageLimitRef = useRef<UsageLimitRef | null>(null)
// Workspace billing state
const [billedAccountUserId, setBilledAccountUserId] = useState<string | null>(null)
const [workspaceAdmins, setWorkspaceAdmins] = useState<
Array<{ userId: string; name: string; email: string; permissionType: string }>
>([])
const [workspaceSettingsLoading, setWorkspaceSettingsLoading] = useState<boolean>(true)
const [workspaceSettingsUpdating, setWorkspaceSettingsUpdating] = useState<boolean>(false)
// Get real subscription data from store
const subscription = getSubscriptionStatus()
const usage = getUsage()
@@ -203,6 +228,77 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}
}, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData])
// Fetch workspace billing settings
const fetchWorkspaceSettings = useCallback(async () => {
if (!workspaceId) return
setWorkspaceSettingsLoading(true)
try {
const [workspaceResponse, permissionsResponse] = await Promise.all([
fetch(`/api/workspaces/${workspaceId}`),
fetch(`/api/workspaces/${workspaceId}/permissions`),
])
if (workspaceResponse.ok) {
const data = await workspaceResponse.json()
const workspaceData = data.workspace ?? {}
setBilledAccountUserId(workspaceData.billedAccountUserId ?? null)
} else {
logger.error('Failed to fetch workspace details', { status: workspaceResponse.status })
}
if (permissionsResponse.ok) {
const data = await permissionsResponse.json()
const users = Array.isArray(data.users) ? data.users : []
const admins = users.filter((user: any) => user.permissionType === 'admin')
setWorkspaceAdmins(admins)
} else {
logger.error('Failed to fetch workspace permissions', {
status: permissionsResponse.status,
})
}
} catch (error) {
logger.error('Error fetching workspace settings:', { error })
} finally {
setWorkspaceSettingsLoading(false)
}
}, [workspaceId, logger])
const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => {
if (!workspaceId) return
setWorkspaceSettingsUpdating(true)
try {
const response = await fetch(`/api/workspaces/${workspaceId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to update workspace settings')
}
await fetchWorkspaceSettings()
} catch (error) {
logger.error('Error updating workspace settings:', { error })
throw error
} finally {
setWorkspaceSettingsUpdating(false)
}
}
useEffect(() => {
if (workspaceId) {
fetchWorkspaceSettings()
} else {
setWorkspaceSettingsLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId])
// Auto-clear upgrade error
useEffect(() => {
if (upgradeError) {
@@ -540,10 +636,56 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}}
subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null,
cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd,
}}
/>
</div>
)}
{/* Workspace API Billing Settings */}
{canManageWorkspaceKeys && (
<div className='mt-6 flex items-center justify-between'>
<span className='font-medium text-sm'>Billed Account for Workspace</span>
{workspaceSettingsLoading ? (
<Skeleton className='h-8 w-[200px] rounded-md' />
) : workspaceAdmins.length === 0 ? (
<div className='rounded-md border border-dashed px-3 py-1.5 text-muted-foreground text-xs'>
No admin members available
</div>
) : (
<Select
value={billedAccountUserId ?? ''}
onValueChange={async (value) => {
if (value === billedAccountUserId) return
const previous = billedAccountUserId
setBilledAccountUserId(value)
try {
await updateWorkspaceSettings({ billedAccountUserId: value })
} catch (error) {
setBilledAccountUserId(previous ?? null)
}
}}
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
>
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
<SelectValue placeholder='Select admin' />
</SelectTrigger>
<SelectContent align='start'>
<SelectGroup>
<SelectLabel className='px-3 py-1 text-muted-foreground text-[11px] uppercase'>
Workspace admins
</SelectLabel>
{workspaceAdmins.map((admin) => (
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
{admin.email}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
)}
</div>
</div>
)

View File

@@ -40,15 +40,10 @@ function WorkflowItem({ workflow, active, isMarketplace }: WorkflowItemProps) {
interface WorkflowListProps {
regularWorkflows: WorkflowMetadata[]
marketplaceWorkflows: WorkflowMetadata[]
isLoading?: boolean
}
export function WorkflowList({
regularWorkflows,
marketplaceWorkflows,
isLoading = false,
}: WorkflowListProps) {
export function WorkflowList({ regularWorkflows, isLoading = false }: WorkflowListProps) {
const pathname = usePathname()
const params = useParams()
const workspaceId = params.workspaceId as string
@@ -70,11 +65,7 @@ export function WorkflowList({
}, [])
// Only show empty state when not loading and user is logged in
const showEmptyState =
!isLoading &&
session?.user &&
regularWorkflows.length === 0 &&
marketplaceWorkflows.length === 0
const showEmptyState = !isLoading && session?.user && regularWorkflows.length === 0
return (
<div className={`space-y-1 ${isLoading ? 'opacity-60' : ''}`}>
@@ -91,29 +82,6 @@ export function WorkflowList({
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
/>
))}
{/* Marketplace Temp Workflows (if any) */}
{marketplaceWorkflows.length > 0 && (
<div className='mt-2 border-border/30 border-t pt-2'>
<h3 className='mb-1 px-2 font-medium text-muted-foreground text-xs'>Marketplace</h3>
{marketplaceWorkflows.map((workflow) => (
<WorkflowItem
key={workflow.id}
workflow={workflow}
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
isMarketplace
/>
))}
</div>
)}
{/* Empty state */}
{showEmptyState && (
<div className='px-2 py-1.5 text-muted-foreground text-xs'>
No workflows in {workspaceId ? 'this workspace' : 'your account'}. Create one to get
started.
</div>
)}
</>
)}
</div>

View File

@@ -679,46 +679,34 @@ export function Sidebar() {
const [showSearchModal, setShowSearchModal] = useState(false)
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false)
// Separate regular workflows from temporary marketplace workflows
const { regularWorkflows, tempWorkflows } = useMemo(() => {
// Get workflows for the current workspace
const regularWorkflows = useMemo(() => {
if (isLoading) return []
const regular: WorkflowMetadata[] = []
const temp: WorkflowMetadata[] = []
if (!isLoading) {
Object.values(workflows).forEach((workflow) => {
if (workflow.workspaceId === workspaceId || !workflow.workspaceId) {
if (workflow.marketplaceData?.status === 'temp') {
temp.push(workflow)
} else {
regular.push(workflow)
}
}
})
// Sort by creation date (newest first) for stable ordering
const sortByCreatedAt = (a: WorkflowMetadata, b: WorkflowMetadata) => {
return b.createdAt.getTime() - a.createdAt.getTime()
Object.values(workflows).forEach((workflow) => {
if (workflow.workspaceId === workspaceId || !workflow.workspaceId) {
regular.push(workflow)
}
})
regular.sort(sortByCreatedAt)
temp.sort(sortByCreatedAt)
}
// Sort by creation date (newest first) for stable ordering
regular.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
return { regularWorkflows: regular, tempWorkflows: temp }
return regular
}, [workflows, isLoading, workspaceId])
// Prepare workflows for search modal
const searchWorkflows = useMemo(() => {
if (isLoading) return []
const allWorkflows = [...regularWorkflows, ...tempWorkflows]
return allWorkflows.map((workflow) => ({
return regularWorkflows.map((workflow) => ({
id: workflow.id,
name: workflow.name,
href: `/workspace/${workspaceId}/w/${workflow.id}`,
isCurrent: workflow.id === workflowId,
}))
}, [regularWorkflows, tempWorkflows, workspaceId, workflowId, isLoading])
}, [regularWorkflows, workspaceId, workflowId, isLoading])
// Prepare workspaces for search modal (include all workspaces)
const searchWorkspaces = useMemo(() => {
@@ -942,7 +930,6 @@ export function Sidebar() {
<div ref={workflowScrollAreaRef}>
<FolderTree
regularWorkflows={regularWorkflows}
marketplaceWorkflows={tempWorkflows}
isLoading={isLoading}
onCreateWorkflow={handleCreateWorkflow}
/>

View File

@@ -3,7 +3,6 @@ import { task } from '@trigger.dev/sdk'
import { Cron } from 'croner'
import { eq } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { getApiKeyOwnerUserId } from '@/lib/api-key/service'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
@@ -19,6 +18,7 @@ import {
import { decryptSecret } from '@/lib/utils'
import { blockExistsInDeployment, loadDeployedWorkflowState } from '@/lib/workflows/db-helpers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { Executor } from '@/executor'
import { Serializer } from '@/serializer'
import { RateLimiter } from '@/services/queue'
@@ -91,11 +91,19 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
return
}
const actorUserId = await getApiKeyOwnerUserId(workflowRecord.pinnedApiKeyId)
let actorUserId: string | null = null
if (workflowRecord.workspaceId) {
actorUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
}
if (!actorUserId) {
actorUserId = workflowRecord.userId ?? null
}
if (!actorUserId) {
logger.warn(
`[${requestId}] Skipping schedule ${payload.scheduleId}: pinned API key required to attribute usage.`
`[${requestId}] Skipping schedule ${payload.scheduleId}: unable to resolve billed account.`
)
return
}

View File

@@ -1,11 +1,13 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { db } from '@sim/db'
import { apiKey as apiKeyTable, workspace } from '@sim/db/schema'
import { apiKey as apiKeyTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { authenticateApiKey } from '@/lib/api-key/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
const logger = createLogger('ApiKeyService')
@@ -36,6 +38,18 @@ export async function authenticateApiKeyFromHeader(
}
try {
let workspaceSettings: {
billedAccountUserId: string | null
allowPersonalApiKeys: boolean
} | null = null
if (options.workspaceId) {
workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId)
if (!workspaceSettings) {
return { success: false, error: 'Workspace not found' }
}
}
// Build query based on options
let query = db
.select({
@@ -48,11 +62,6 @@ export async function authenticateApiKeyFromHeader(
})
.from(apiKeyTable)
// Add workspace join if needed for workspace keys
if (options.workspaceId || options.keyTypes?.includes('workspace')) {
query = query.leftJoin(workspace, eq(apiKeyTable.workspaceId, workspace.id)) as any
}
// Apply filters
const conditions = []
@@ -60,10 +69,6 @@ export async function authenticateApiKeyFromHeader(
conditions.push(eq(apiKeyTable.userId, options.userId))
}
if (options.workspaceId) {
conditions.push(eq(apiKeyTable.workspaceId, options.workspaceId))
}
if (options.keyTypes?.length) {
if (options.keyTypes.length === 1) {
conditions.push(eq(apiKeyTable.type, options.keyTypes[0]))
@@ -78,11 +83,27 @@ export async function authenticateApiKeyFromHeader(
const keyRecords = await query
// Filter by keyTypes in memory if multiple types specified
const filteredRecords =
options.keyTypes?.length && options.keyTypes.length > 1
? keyRecords.filter((record) => options.keyTypes!.includes(record.type as any))
: keyRecords
const filteredRecords = keyRecords.filter((record) => {
const keyType = record.type as 'personal' | 'workspace'
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) {
return false
}
if (options.workspaceId) {
if (keyType === 'workspace') {
return record.workspaceId === options.workspaceId
}
if (keyType === 'personal') {
return workspaceSettings?.allowPersonalApiKeys ?? false
}
}
return true
})
const permissionCache = new Map<string, boolean>()
// Authenticate each key
for (const storedKey of filteredRecords) {
@@ -91,6 +112,29 @@ export async function authenticateApiKeyFromHeader(
continue
}
if (options.workspaceId && (storedKey.type as 'personal' | 'workspace') === 'personal') {
if (!workspaceSettings?.allowPersonalApiKeys) {
continue
}
if (!storedKey.userId) {
continue
}
if (!permissionCache.has(storedKey.userId)) {
const permission = await getUserEntityPermissions(
storedKey.userId,
'workspace',
options.workspaceId
)
permissionCache.set(storedKey.userId, permission !== null)
}
if (!permissionCache.get(storedKey.userId)) {
continue
}
}
try {
const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key)
if (isValid) {
@@ -99,7 +143,7 @@ export async function authenticateApiKeyFromHeader(
userId: storedKey.userId,
keyId: storedKey.id,
keyType: storedKey.type as 'personal' | 'workspace',
workspaceId: storedKey.workspaceId || undefined,
workspaceId: storedKey.workspaceId || options.workspaceId || undefined,
}
}
} catch (error) {
@@ -125,27 +169,6 @@ export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
}
}
/**
* Given a pinned API key ID, resolve the owning userId (actor).
* Returns null if not found.
*/
export async function getApiKeyOwnerUserId(
pinnedApiKeyId: string | null | undefined
): Promise<string | null> {
if (!pinnedApiKeyId) return null
try {
const rows = await db
.select({ userId: apiKeyTable.userId })
.from(apiKeyTable)
.where(eq(apiKeyTable.id, pinnedApiKeyId))
.limit(1)
return rows[0]?.userId ?? null
} catch (error) {
logger.error('Error resolving API key owner', { error, pinnedApiKeyId })
return null
}
}
/**
* Get the API encryption key from the environment
* @returns The API encryption key

View File

@@ -220,6 +220,7 @@ export async function getSimplifiedBillingSummary(
metadata: any
stripeSubscriptionId: string | null
periodEnd: Date | string | null
cancelAtPeriodEnd?: boolean
// Usage details
usage: {
current: number
@@ -341,6 +342,7 @@ export async function getSimplifiedBillingSummary(
metadata: subscription.metadata || null,
stripeSubscriptionId: subscription.stripeSubscriptionId || null,
periodEnd: subscription.periodEnd || null,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined,
// Usage details
usage: {
current: usageData.currentUsage,
@@ -463,6 +465,7 @@ export async function getSimplifiedBillingSummary(
metadata: subscription?.metadata || null,
stripeSubscriptionId: subscription?.stripeSubscriptionId || null,
periodEnd: subscription?.periodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || undefined,
// Usage details
usage: {
current: currentUsage,
@@ -524,5 +527,14 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
daysRemaining: 0,
copilotCost: 0,
},
...(type === 'organization' && {
organizationData: {
seatCount: 0,
memberCount: 0,
totalBasePrice: 0,
totalCurrentUsage: 0,
totalOverage: 0,
},
}),
}
}

View File

@@ -2,7 +2,6 @@ import { db, webhook, workflow } from '@sim/db'
import { tasks } from '@trigger.dev/sdk'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getApiKeyOwnerUserId } from '@/lib/api-key/service'
import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { env, isTruthy } from '@/lib/env'
@@ -13,6 +12,7 @@ import {
validateMicrosoftTeamsSignature,
verifyProviderWebhook,
} from '@/lib/webhooks/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { executeWebhookJob } from '@/background/webhook-execution'
import { RateLimiter } from '@/services/queue'
@@ -26,6 +26,20 @@ export interface WebhookProcessorOptions {
executionTarget?: 'deployed' | 'live'
}
async function resolveWorkflowActorUserId(foundWorkflow: {
workspaceId?: string | null
userId?: string | null
}): Promise<string | null> {
if (foundWorkflow?.workspaceId) {
const billedAccount = await getWorkspaceBilledAccountUserId(foundWorkflow.workspaceId)
if (billedAccount) {
return billedAccount
}
}
return foundWorkflow?.userId ?? null
}
export async function parseWebhookBody(
request: NextRequest,
requestId: string
@@ -269,11 +283,11 @@ export async function checkRateLimits(
requestId: string
): Promise<NextResponse | null> {
try {
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId)
const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
if (!actorUserId) {
logger.warn(`[${requestId}] Webhook requires pinned API key to attribute usage`)
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 })
logger.warn(`[${requestId}] Webhook requires a workspace billing account to attribute usage`)
return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
}
const userSubscription = await getHighestPrioritySubscription(actorUserId)
@@ -327,11 +341,11 @@ export async function checkUsageLimits(
}
try {
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId)
const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
if (!actorUserId) {
logger.warn(`[${requestId}] Webhook requires pinned API key to attribute usage`)
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 })
logger.warn(`[${requestId}] Webhook requires a workspace billing account to attribute usage`)
return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
}
const usageCheck = await checkServerSideUsageLimits(actorUserId)
@@ -376,10 +390,12 @@ export async function queueWebhookExecution(
options: WebhookProcessorOptions
): Promise<NextResponse> {
try {
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId)
const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
if (!actorUserId) {
logger.warn(`[${options.requestId}] Webhook requires pinned API key to attribute usage`)
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 })
logger.warn(
`[${options.requestId}] Webhook requires a workspace billing account to attribute usage`
)
return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
}
const headers = Object.fromEntries(request.headers.entries())

View File

@@ -369,8 +369,6 @@ export async function migrateWorkflowToNormalizedTables(
export async function deployWorkflow(params: {
workflowId: string
deployedBy: string // User ID of the person deploying
pinnedApiKeyId?: string
includeDeployedState?: boolean
workflowName?: string
}): Promise<{
success: boolean
@@ -379,13 +377,7 @@ export async function deployWorkflow(params: {
currentState?: any
error?: string
}> {
const {
workflowId,
deployedBy,
pinnedApiKeyId,
includeDeployedState = false,
workflowName,
} = params
const { workflowId, deployedBy, workflowName } = params
try {
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
@@ -435,14 +427,6 @@ export async function deployWorkflow(params: {
deployedAt: now,
}
if (includeDeployedState) {
updateData.deployedState = currentState
}
if (pinnedApiKeyId) {
updateData.pinnedApiKeyId = pinnedApiKeyId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
return nextVersion

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { apiKey, permissions, workflow as workflowTable, workspace } from '@sim/db/schema'
import { permissions, workflow as workflowTable, workspace } from '@sim/db/schema'
import type { InferSelectModel } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
@@ -12,86 +12,12 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowUtils')
const WORKFLOW_BASE_SELECTION = {
id: workflowTable.id,
userId: workflowTable.userId,
workspaceId: workflowTable.workspaceId,
folderId: workflowTable.folderId,
name: workflowTable.name,
description: workflowTable.description,
color: workflowTable.color,
lastSynced: workflowTable.lastSynced,
createdAt: workflowTable.createdAt,
updatedAt: workflowTable.updatedAt,
isDeployed: workflowTable.isDeployed,
deployedState: workflowTable.deployedState,
deployedAt: workflowTable.deployedAt,
pinnedApiKeyId: workflowTable.pinnedApiKeyId,
collaborators: workflowTable.collaborators,
runCount: workflowTable.runCount,
lastRunAt: workflowTable.lastRunAt,
variables: workflowTable.variables,
isPublished: workflowTable.isPublished,
marketplaceData: workflowTable.marketplaceData,
pinnedApiKeyKey: apiKey.key,
pinnedApiKeyName: apiKey.name,
pinnedApiKeyType: apiKey.type,
pinnedApiKeyWorkspaceId: apiKey.workspaceId,
}
type WorkflowSelection = InferSelectModel<typeof workflowTable>
type ApiKeySelection = InferSelectModel<typeof apiKey>
type WorkflowRow = WorkflowSelection & {
pinnedApiKeyKey: ApiKeySelection['key'] | null
pinnedApiKeyName: ApiKeySelection['name'] | null
pinnedApiKeyType: ApiKeySelection['type'] | null
pinnedApiKeyWorkspaceId: ApiKeySelection['workspaceId'] | null
}
type WorkflowWithPinnedKey = WorkflowSelection & {
pinnedApiKey: Pick<ApiKeySelection, 'id' | 'name' | 'key' | 'type' | 'workspaceId'> | null
}
function mapWorkflowRow(row: WorkflowRow | undefined): WorkflowWithPinnedKey | undefined {
if (!row) {
return undefined
}
const {
pinnedApiKeyKey,
pinnedApiKeyName,
pinnedApiKeyType,
pinnedApiKeyWorkspaceId,
...workflowWithoutDerived
} = row
const pinnedApiKey =
workflowWithoutDerived.pinnedApiKeyId && pinnedApiKeyKey && pinnedApiKeyName && pinnedApiKeyType
? {
id: workflowWithoutDerived.pinnedApiKeyId,
name: pinnedApiKeyName,
key: pinnedApiKeyKey,
type: pinnedApiKeyType,
workspaceId: pinnedApiKeyWorkspaceId,
}
: null
return {
...workflowWithoutDerived,
pinnedApiKey,
}
}
export async function getWorkflowById(id: string) {
const rows = await db
.select(WORKFLOW_BASE_SELECTION)
.from(workflowTable)
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
.where(eq(workflowTable.id, id))
.limit(1)
const rows = await db.select().from(workflowTable).where(eq(workflowTable.id, id)).limit(1)
return mapWorkflowRow(rows[0] as WorkflowRow | undefined)
return rows[0]
}
type WorkflowRecord = ReturnType<typeof getWorkflowById> extends Promise<infer R>
@@ -110,55 +36,50 @@ export async function getWorkflowAccessContext(
workflowId: string,
userId?: string
): Promise<WorkflowAccessContext | null> {
const rows = await db
.select({
...WORKFLOW_BASE_SELECTION,
workspaceOwnerId: workspace.ownerId,
workspacePermission: permissions.permissionType,
})
.from(workflowTable)
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
.leftJoin(workspace, eq(workspace.id, workflowTable.workspaceId))
.leftJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflowTable.workspaceId),
userId ? eq(permissions.userId, userId) : eq(permissions.userId, '' as unknown as string)
)
)
.where(eq(workflowTable.id, workflowId))
.limit(1)
const row = rows[0] as
| (WorkflowRow & {
workspaceOwnerId: string | null
workspacePermission: PermissionType | null
})
| undefined
if (!row) {
return null
}
const workflow = mapWorkflowRow(row as WorkflowRow)
const workflow = await getWorkflowById(workflowId)
if (!workflow) {
return null
}
const resolvedWorkspaceOwner = row.workspaceOwnerId ?? null
const resolvedWorkspacePermission = row.workspacePermission ?? null
let workspaceOwnerId: string | null = null
let workspacePermission: PermissionType | null = null
if (workflow.workspaceId) {
const [workspaceRow] = await db
.select({ ownerId: workspace.ownerId })
.from(workspace)
.where(eq(workspace.id, workflow.workspaceId))
.limit(1)
workspaceOwnerId = workspaceRow?.ownerId ?? null
if (userId) {
const [permissionRow] = await db
.select({ permissionType: permissions.permissionType })
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId)
)
)
.limit(1)
workspacePermission = permissionRow?.permissionType ?? null
}
}
const resolvedUserId = userId ?? null
const isOwner = resolvedUserId ? workflow.userId === resolvedUserId : false
const isWorkspaceOwner = resolvedUserId ? resolvedWorkspaceOwner === resolvedUserId : false
const isWorkspaceOwner = resolvedUserId ? workspaceOwnerId === resolvedUserId : false
return {
workflow,
workspaceOwnerId: resolvedWorkspaceOwner,
workspacePermission: resolvedWorkspacePermission,
workspaceOwnerId,
workspacePermission,
isOwner,
isWorkspaceOwner,
}

View File

@@ -0,0 +1,39 @@
import { db } from '@sim/db'
import { workspace as workspaceTable } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
interface WorkspaceBillingSettings {
billedAccountUserId: string | null
allowPersonalApiKeys: boolean
}
export async function getWorkspaceBillingSettings(
workspaceId: string
): Promise<WorkspaceBillingSettings | null> {
if (!workspaceId) {
return null
}
const rows = await db
.select({
billedAccountUserId: workspaceTable.billedAccountUserId,
allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys,
})
.from(workspaceTable)
.where(eq(workspaceTable.id, workspaceId))
.limit(1)
if (!rows.length) {
return null
}
return {
billedAccountUserId: rows[0].billedAccountUserId ?? null,
allowPersonalApiKeys: rows[0].allowPersonalApiKeys ?? false,
}
}
export async function getWorkspaceBilledAccountUserId(workspaceId: string): Promise<string | null> {
const settings = await getWorkspaceBillingSettings(workspaceId)
return settings?.billedAccountUserId ?? null
}

View File

@@ -31,6 +31,7 @@ export interface SubscriptionData {
metadata: any | null
stripeSubscriptionId: string | null
periodEnd: Date | null
cancelAtPeriodEnd?: boolean
usage: UsageData
billingBlocked?: boolean
}

View File

@@ -50,7 +50,6 @@ export function getWorkflowWithValues(workflowId: string) {
name: metadata.name,
description: metadata.description,
color: metadata.color || '#3972F6',
marketplaceData: metadata.marketplaceData || null,
workspaceId: metadata.workspaceId,
folderId: metadata.folderId,
state: {
@@ -126,7 +125,6 @@ export function getAllWorkflowsWithValues() {
name: metadata.name,
description: metadata.description,
color: metadata.color || '#3972F6',
marketplaceData: metadata.marketplaceData || null,
folderId: metadata.folderId,
state: {
blocks: mergedBlocks,
@@ -136,7 +134,6 @@ export function getAllWorkflowsWithValues() {
lastSaved: workflowState.lastSaved,
isDeployed: workflowState.isDeployed,
deployedAt: workflowState.deployedAt,
marketplaceData: metadata.marketplaceData || null,
},
// Include API key if available
apiKey,

View File

@@ -92,7 +92,6 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
color,
variables,
createdAt,
marketplaceData,
workspaceId,
folderId,
isDeployed,
@@ -108,7 +107,6 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
color: color || '#3972F6',
lastModified: createdAt ? new Date(createdAt) : new Date(),
createdAt: createdAt ? new Date(createdAt) : new Date(),
marketplaceData: marketplaceData || null,
workspaceId,
folderId: folderId || null,
}
@@ -438,7 +436,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined,
apiKey: workflowData.apiKey,
lastSaved: Date.now(),
marketplaceData: workflowData.marketplaceData || null,
deploymentStatuses: {},
}
} else {
@@ -513,7 +510,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
body: JSON.stringify({
name: options.name || generateCreativeWorkflowName(),
description: options.description || 'New workflow',
color: options.marketplaceId ? '#808080' : getNextWorkflowColor(),
color: getNextWorkflowColor(),
workspaceId,
folderId: options.folderId || null,
}),
@@ -537,17 +534,10 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
createdAt: new Date(),
description: createdWorkflow.description,
color: createdWorkflow.color,
marketplaceData: options.marketplaceId
? { id: options.marketplaceId, status: 'temp' as const }
: undefined,
workspaceId,
folderId: createdWorkflow.folderId,
}
if (options.marketplaceId && options.marketplaceState) {
logger.info(`Created workflow from marketplace: ${options.marketplaceId}`)
}
// Add workflow to registry with server-generated ID
set((state) => ({
workflows: {
@@ -557,26 +547,16 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
error: null,
}))
// Initialize subblock values if this is a marketplace import
if (options.marketplaceId && options.marketplaceState?.blocks) {
useSubBlockStore
.getState()
.initializeFromWorkflow(serverWorkflowId, options.marketplaceState.blocks)
}
// Initialize subblock values to ensure they're available for sync
if (!options.marketplaceId) {
// For non-marketplace workflows, initialize empty subblock values
const subblockValues: Record<string, Record<string, any>> = {}
const subblockValues: Record<string, Record<string, any>> = {}
// Update the subblock store with the initial values
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[serverWorkflowId]: subblockValues,
},
}))
}
// Update the subblock store with the initial values
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[serverWorkflowId]: subblockValues,
},
}))
// Don't set as active workflow here - let the navigation/URL change handle that
// This prevents race conditions and flickering
@@ -594,107 +574,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
}
},
/**
* Creates a new workflow from a marketplace workflow
*/
createMarketplaceWorkflow: async (
marketplaceId: string,
state: any,
metadata: Partial<WorkflowMetadata>
) => {
const id = crypto.randomUUID()
// Generate workflow metadata with marketplace properties
const newWorkflow: WorkflowMetadata = {
id,
name: metadata.name || generateCreativeWorkflowName(),
lastModified: new Date(),
createdAt: new Date(),
description: metadata.description || 'Imported from marketplace',
color: metadata.color || getNextWorkflowColor(),
marketplaceData: { id: marketplaceId, status: 'temp' as const },
}
// Prepare workflow state based on the marketplace workflow state
const initialState = {
blocks: state.blocks || {},
edges: state.edges || [],
loops: state.loops || {},
parallels: state.parallels || {},
isDeployed: false,
deployedAt: undefined,
lastSaved: Date.now(),
}
// Add workflow to registry
set((state) => ({
workflows: {
...state.workflows,
[id]: newWorkflow,
},
error: null,
}))
// Initialize subblock values from state blocks
if (state.blocks) {
useSubBlockStore.getState().initializeFromWorkflow(id, state.blocks)
}
// Set as active workflow and update store
set({ activeWorkflowId: id })
useWorkflowStore.setState(initialState)
// Immediately persist the marketplace workflow to the database
const persistWorkflow = async () => {
try {
const workflowData = {
[id]: {
id,
name: newWorkflow.name,
description: newWorkflow.description,
color: newWorkflow.color,
state: initialState,
marketplaceData: newWorkflow.marketplaceData,
workspaceId: newWorkflow.workspaceId,
folderId: newWorkflow.folderId,
},
}
const response = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workflows: workflowData,
workspaceId: newWorkflow.workspaceId,
}),
})
if (!response.ok) {
throw new Error(`Failed to persist workflow: ${response.statusText}`)
}
logger.info(`Successfully persisted marketplace workflow ${id} to database`)
} catch (error) {
logger.error(`Failed to persist marketplace workflow ${id}:`, error)
}
}
// Persist synchronously to ensure workflow exists before Socket.IO operations
try {
await persistWorkflow()
} catch (error) {
logger.error(
`Critical: Failed to persist marketplace workflow ${id}, Socket.IO operations may fail:`,
error
)
// Don't throw - allow workflow creation to continue in memory
}
logger.info(`Created marketplace workflow ${id} imported from ${marketplaceId}`)
return id
},
/**
* Duplicates an existing workflow
*/

View File

@@ -1,8 +1,3 @@
export interface MarketplaceData {
id: string // Marketplace entry ID to track original marketplace source
status: 'owner' | 'temp'
}
export interface DeploymentStatus {
isDeployed: boolean
deployedAt?: Date
@@ -17,7 +12,6 @@ export interface WorkflowMetadata {
createdAt: Date
description?: string
color: string
marketplaceData?: MarketplaceData | null
workspaceId?: string
folderId?: string | null
}
@@ -39,18 +33,11 @@ export interface WorkflowRegistryActions {
updateWorkflow: (id: string, metadata: Partial<WorkflowMetadata>) => Promise<void>
createWorkflow: (options?: {
isInitial?: boolean
marketplaceId?: string
marketplaceState?: any
name?: string
description?: string
workspaceId?: string
folderId?: string | null
}) => Promise<string>
createMarketplaceWorkflow: (
marketplaceId: string,
state: any,
metadata: Partial<WorkflowMetadata>
) => Promise<string>
duplicateWorkflow: (sourceId: string) => Promise<string | null>
getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null
setDeploymentStatus: (

View File

@@ -0,0 +1,12 @@
ALTER TABLE "workflow" DROP CONSTRAINT "workflow_pinned_api_key_id_api_key_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "billed_account_user_id" text;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "allow_personal_api_keys" boolean DEFAULT true NOT NULL;--> statement-breakpoint
UPDATE "workspace" SET "billed_account_user_id" = "owner_id" WHERE "billed_account_user_id" IS NULL;--> statement-breakpoint
ALTER TABLE "workspace" ALTER COLUMN "billed_account_user_id" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "workspace" ADD CONSTRAINT "workspace_billed_account_user_id_user_id_fk" FOREIGN KEY ("billed_account_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workflow" DROP COLUMN "deployed_state";--> statement-breakpoint
ALTER TABLE "workflow" DROP COLUMN "pinned_api_key_id";--> statement-breakpoint
ALTER TABLE "workflow" DROP COLUMN "collaborators";--> statement-breakpoint
ALTER TABLE "workflow" DROP COLUMN "is_published";--> statement-breakpoint
ALTER TABLE "workflow" DROP COLUMN "marketplace_data";

File diff suppressed because it is too large Load Diff

View File

@@ -722,6 +722,13 @@
"when": 1761845605676,
"tag": "0103_careful_harpoon",
"breakpoints": true
},
{
"idx": 104,
"version": "7",
"when": 1761848118406,
"tag": "0104_orange_shinobi_shaw",
"breakpoints": true
}
]
}

View File

@@ -145,15 +145,10 @@ export const workflow = pgTable(
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
isDeployed: boolean('is_deployed').notNull().default(false),
deployedState: json('deployed_state'),
deployedAt: timestamp('deployed_at'),
pinnedApiKeyId: text('pinned_api_key_id').references(() => apiKey.id, { onDelete: 'set null' }),
collaborators: json('collaborators').notNull().default('[]'),
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'),
isPublished: boolean('is_published').notNull().default(false),
marketplaceData: json('marketplace_data'),
},
(table) => ({
userIdIdx: index('workflow_user_id_idx').on(table.userId),
@@ -727,6 +722,10 @@ export const workspace = pgTable('workspace', {
ownerId: text('owner_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
billedAccountUserId: text('billed_account_user_id')
.notNull()
.references(() => user.id, { onDelete: 'no action' }),
allowPersonalApiKeys: boolean('allow_personal_api_keys').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})

View File

@@ -158,7 +158,6 @@ class WorkflowExecutionResult:
class WorkflowStatus:
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
```

View File

@@ -77,7 +77,6 @@ def status_example():
status = client.get_workflow_status("your-workflow-id")
print(f"Status: {{\n"
f" deployed: {status.is_deployed},\n"
f" published: {status.is_published},\n"
f" needs_redeployment: {status.needs_redeployment},\n"
f" deployed_at: {status.deployed_at}\n"
f"}}")

View File

@@ -42,7 +42,6 @@ class WorkflowStatus:
"""Status of a workflow."""
is_deployed: bool
deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False
@@ -296,7 +295,6 @@ class SimStudioClient:
return WorkflowStatus(
is_deployed=status_data.get('isDeployed', False),
deployed_at=status_data.get('deployedAt'),
is_published=status_data.get('isPublished', False),
needs_redeployment=status_data.get('needsRedeployment', False)
)

View File

@@ -79,12 +79,10 @@ def test_workflow_status():
status = WorkflowStatus(
is_deployed=True,
deployed_at="2023-01-01T00:00:00Z",
is_published=False,
needs_redeployment=False
)
assert status.is_deployed is True
assert status.deployed_at == "2023-01-01T00:00:00Z"
assert status.is_published is False
assert status.needs_redeployment is False

View File

@@ -157,7 +157,6 @@ interface WorkflowExecutionResult {
interface WorkflowStatus {
isDeployed: boolean;
deployedAt?: string;
isPublished: boolean;
needsRedeployment: boolean;
}
```

View File

@@ -82,7 +82,6 @@ async function statusExample() {
const status = await client.getWorkflowStatus('your-workflow-id')
console.log('Status:', {
deployed: status.isDeployed,
published: status.isPublished,
needsRedeployment: status.needsRedeployment,
deployedAt: status.deployedAt,
})

View File

@@ -72,7 +72,6 @@ describe('SimStudioClient', () => {
json: vi.fn().mockResolvedValue({
isDeployed: true,
deployedAt: '2023-01-01T00:00:00Z',
isPublished: false,
needsRedeployment: false,
}),
}
@@ -89,7 +88,6 @@ describe('SimStudioClient', () => {
json: vi.fn().mockResolvedValue({
isDeployed: false,
deployedAt: null,
isPublished: false,
needsRedeployment: true,
}),
}

View File

@@ -22,7 +22,6 @@ export interface WorkflowExecutionResult {
export interface WorkflowStatus {
isDeployed: boolean
deployedAt?: string
isPublished: boolean
needsRedeployment: boolean
}