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: class WorkflowStatus:
is_deployed: bool is_deployed: bool
deployed_at: Optional[str] = None deployed_at: Optional[str] = None
is_published: bool = False
needs_redeployment: bool = False needs_redeployment: bool = False
``` ```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,17 +109,17 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({ globalMockData.workflows.push({
id: 'test-workflow-id', id: 'test-workflow-id',
userId: 'test-user-id', userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id', workspaceId: 'test-workspace-id',
}) })
vi.doMock('@/lib/api-key/service', async () => { vi.doMock('@/lib/workspaces/utils', async () => {
const actual = await vi.importActual('@/lib/api-key/service') const actual = await vi.importActual('@/lib/workspaces/utils')
return { return {
...(actual as Record<string, unknown>), ...(actual as Record<string, unknown>),
getApiKeyOwnerUserId: vi getWorkspaceBilledAccountUserId: vi
.fn() .fn()
.mockImplementation(async (pinnedApiKeyId: string | null | undefined) => .mockImplementation(async (workspaceId: string | null | undefined) =>
pinnedApiKeyId ? 'test-user-id' : null workspaceId ? 'test-user-id' : null
), ),
} }
}) })
@@ -240,7 +240,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({ globalMockData.workflows.push({
id: 'test-workflow-id', id: 'test-workflow-id',
userId: 'test-user-id', userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id', workspaceId: 'test-workspace-id',
}) })
const req = createMockRequest('POST', { event: 'test', id: 'test-123' }) const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
@@ -272,7 +272,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({ globalMockData.workflows.push({
id: 'test-workflow-id', id: 'test-workflow-id',
userId: 'test-user-id', userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id', workspaceId: 'test-workspace-id',
}) })
const headers = { const headers = {
@@ -307,7 +307,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({ globalMockData.workflows.push({
id: 'test-workflow-id', id: 'test-workflow-id',
userId: 'test-user-id', userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id', workspaceId: 'test-workspace-id',
}) })
const headers = { const headers = {
@@ -338,7 +338,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({ globalMockData.workflows.push({
id: 'test-workflow-id', id: 'test-workflow-id',
userId: 'test-user-id', userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id', workspaceId: 'test-workspace-id',
}) })
vi.doMock('@trigger.dev/sdk', () => ({ vi.doMock('@trigger.dev/sdk', () => ({
@@ -388,7 +388,7 @@ describe('Webhook Trigger API Route', () => {
globalMockData.workflows.push({ globalMockData.workflows.push({
id: 'test-workflow-id', id: 'test-workflow-id',
userId: 'test-user-id', userId: 'test-user-id',
pinnedApiKeyId: 'test-pinned-api-key-id', workspaceId: 'test-workspace-id',
}) })
vi.doMock('@trigger.dev/sdk', () => ({ 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 { 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 { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
import { deployWorkflow } from '@/lib/workflows/db-helpers' 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 let needsRedeployment = false
const [active] = await db const [active] = await db
.select({ state: workflowDeploymentVersion.state }) .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}`) 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({ return createSuccessResponse({
apiKey: responseApiKeyInfo, apiKey: responseApiKeyInfo,
@@ -127,101 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(error.message, error.status) 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 // Attribution: this route is UI-only; require session user as actor
const actorUserId: string | null = session?.user?.id ?? null const actorUserId: string | null = session?.user?.id ?? null
if (!actorUserId) { if (!actorUserId) {
@@ -232,8 +108,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployResult = await deployWorkflow({ const deployResult = await deployWorkflow({
workflowId: id, workflowId: id,
deployedBy: actorUserId, deployedBy: actorUserId,
pinnedApiKeyId: matchedKey?.id,
includeDeployedState: true,
workflowName: workflowData!.name, workflowName: workflowData!.name,
}) })
@@ -243,20 +117,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployedAt = deployResult.deployedAt! 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}`) 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({ return createSuccessResponse({
apiKey: responseApiKeyInfo, apiKey: responseApiKeyInfo,
@@ -298,7 +163,7 @@ export async function DELETE(
await tx await tx
.update(workflow) .update(workflow)
.set({ isDeployed: false, deployedAt: null, deployedState: null, pinnedApiKeyId: null }) .set({ isDeployed: false, deployedAt: null })
.where(eq(workflow.id, id)) .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 { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
@@ -19,11 +19,7 @@ export async function POST(
const { id, version } = await params const { id, version } = await params
try { try {
const { const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) { if (error) {
return createErrorResponse(error.message, error.status) return createErrorResponse(error.message, error.status)
} }
@@ -33,52 +29,6 @@ export async function POST(
return createErrorResponse('Invalid version', 400) 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() const now = new Date()
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
@@ -112,10 +62,6 @@ export async function POST(
deployedAt: now, deployedAt: now,
} }
if (pinnedApiKeyId) {
updateData.pinnedApiKeyId = pinnedApiKeyId
}
await tx.update(workflow).set(updateData).where(eq(workflow.id, id)) 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, createdAt: now,
updatedAt: now, updatedAt: now,
isDeployed: false, isDeployed: false,
collaborators: [],
runCount: 0, runCount: 0,
// Duplicate variables with new IDs and new workflowId // Duplicate variables with new IDs and new workflowId
variables: (() => { variables: (() => {
@@ -112,8 +111,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
} }
return remapped return remapped
})(), })(),
isPublished: false,
marketplaceData: null,
}) })
// Copy all blocks from source workflow with new IDs // 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({ return createSuccessResponse({
isDeployed: validation.workflow.isDeployed, isDeployed: validation.workflow.isDeployed,
deployedAt: validation.workflow.deployedAt, deployedAt: validation.workflow.deployedAt,
isPublished: validation.workflow.isPublished,
needsRedeployment, needsRedeployment,
}) })
} catch (error) { } catch (error) {

View File

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

View File

@@ -143,11 +143,8 @@ export async function POST(req: NextRequest) {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
isDeployed: false, isDeployed: false,
collaborators: [],
runCount: 0, runCount: 0,
variables: {}, variables: {},
isPublished: false,
marketplaceData: null,
}) })
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`) 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 userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) { if (permission !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) 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 body = await request.json()
const { name } = CreateKeySchema.parse(body) const { name } = CreateKeySchema.parse(body)
@@ -181,10 +198,27 @@ export async function DELETE(
const userId = session.user.id const userId = session.user.id
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (!permission || (permission !== 'admin' && permission !== 'write')) { if (permission !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) 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 body = await request.json()
const { keys } = DeleteKeysSchema.parse(body) const { keys } = DeleteKeysSchema.parse(body)

View File

@@ -1,6 +1,6 @@
import crypto from 'crypto' import crypto from 'crypto'
import { db } from '@sim/db' 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 { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' 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 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) const selfUpdate = body.updates.find((update) => update.userId === session.user.id)
if (selfUpdate && selfUpdate.permissions !== 'admin') { if (selfUpdate && selfUpdate.permissions !== 'admin') {
return NextResponse.json( 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) => { await db.transaction(async (tx) => {
for (const update of body.updates) { for (const update of body.updates) {
await tx await tx

View File

@@ -100,22 +100,95 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
} }
try { try {
const { name } = await request.json() const body = await request.json()
const {
name,
billedAccountUserId,
allowPersonalApiKeys,
}: {
name?: string
billedAccountUserId?: string
allowPersonalApiKeys?: boolean
} = body ?? {}
if (!name) { if (
return NextResponse.json({ error: 'Name is required' }, { status: 400 }) name === undefined &&
billedAccountUserId === undefined &&
allowPersonalApiKeys === undefined
) {
return NextResponse.json({ error: 'No updates provided' }, { status: 400 })
} }
// Update workspace const existingWorkspace = await db
await db .select()
.update(workspace) .from(workspace)
.set({
name,
updatedAt: new Date(),
})
.where(eq(workspace.id, workspaceId)) .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 const updatedWorkspace = await db
.select() .select()
.from(workspace) .from(workspace)

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' 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 { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth' 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 }) 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 // Check if the user to be removed actually has permissions for this workspace
const userPermission = await db const userPermission = await db
.select() .select()

View File

@@ -95,6 +95,8 @@ async function createWorkspace(userId: string, name: string) {
id: workspaceId, id: workspaceId,
name, name,
ownerId: userId, ownerId: userId,
billedAccountUserId: userId,
allowPersonalApiKeys: true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}) })
@@ -124,11 +126,8 @@ async function createWorkspace(userId: string, name: string) {
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
isDeployed: false, isDeployed: false,
collaborators: [],
runCount: 0, runCount: 0,
variables: {}, variables: {},
isPublished: false,
marketplaceData: null,
}) })
// No blocks are inserted - empty canvas // No blocks are inserted - empty canvas
@@ -147,6 +146,8 @@ async function createWorkspace(userId: string, name: string) {
id: workspaceId, id: workspaceId,
name, name,
ownerId: userId, ownerId: userId,
billedAccountUserId: userId,
allowPersonalApiKeys: true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
role: 'owner', 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' } from '@/components/ui'
import { import {
ApiEndpoint, ApiEndpoint,
ApiKey,
DeployStatus, DeployStatus,
ExampleCommand, ExampleCommand,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components' } 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 overflow-y-auto px-1'>
<div className='space-y-4'> <div className='space-y-4'>
<ApiEndpoint endpoint={deploymentInfo.endpoint} /> <ApiEndpoint endpoint={deploymentInfo.endpoint} />
<ApiKey apiKey={deploymentInfo.apiKey} />
<ExampleCommand <ExampleCommand
command={deploymentInfo.exampleCommand} command={deploymentInfo.exampleCommand}
apiKey={deploymentInfo.apiKey} apiKey={deploymentInfo.apiKey}

View File

@@ -1,4 +1,3 @@
export { ChatDeploy } from './chat-deploy/chat-deploy' export { ChatDeploy } from './chat-deploy/chat-deploy'
export { DeployForm } from './deploy-form/deploy-form'
export { DeploymentInfo } from './deployment-info/deployment-info' export { DeploymentInfo } from './deployment-info/deployment-info'
export { ImageSelector } from './image-selector/image-selector' 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 { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
import { import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
DeployForm,
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 { 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 { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' 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' import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('DeployModal') const logger = createLogger('DeployModal')
interface DeployModalProps { interface DeployModalProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -41,15 +37,6 @@ interface DeployModalProps {
refetchDeployedState: () => Promise<void> refetchDeployedState: () => Promise<void>
} }
interface ApiKey {
id: string
name: string
key: string
lastUsed?: string
createdAt: string
expiresAt?: string
}
interface WorkflowDeploymentInfo { interface WorkflowDeploymentInfo {
isDeployed: boolean isDeployed: boolean
deployedAt?: string deployedAt?: string
@@ -59,12 +46,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean needsRedeployment: boolean
} }
interface DeployFormValues { type TabView = 'api' | 'versions' | 'chat'
apiKey: string
newKeyName?: string
}
type TabView = 'general' | 'api' | 'versions' | 'chat'
export function DeployModal({ export function DeployModal({
open, open,
@@ -85,9 +67,11 @@ export function DeployModal({
const [isUndeploying, setIsUndeploying] = useState(false) const [isUndeploying, setIsUndeploying] = useState(false)
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null) const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]) const workflowMetadata = useWorkflowRegistry((state) =>
const [activeTab, setActiveTab] = useState<TabView>('general') workflowId ? state.workflows[workflowId] : undefined
const [selectedApiKeyId, setSelectedApiKeyId] = useState<string>('') )
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
const [activeTab, setActiveTab] = useState<TabView>('api')
const [chatSubmitting, setChatSubmitting] = useState(false) const [chatSubmitting, setChatSubmitting] = useState(false)
const [apiDeployError, setApiDeployError] = useState<string | null>(null) const [apiDeployError, setApiDeployError] = useState<string | null>(null)
const [chatExists, setChatExists] = useState(false) const [chatExists, setChatExists] = useState(false)
@@ -116,6 +100,16 @@ export function DeployModal({
} }
}, [editingVersion]) }, [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) => { const getInputFormatExample = (includeStreaming = false) => {
let inputFormatExample = '' let inputFormatExample = ''
try { try {
@@ -209,21 +203,6 @@ export function DeployModal({
return inputFormatExample 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 () => { const fetchChatDeploymentInfo = async () => {
if (!open || !workflowId) return if (!open || !workflowId) return
@@ -252,39 +231,19 @@ export function DeployModal({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setIsLoading(true) setIsLoading(true)
fetchApiKeys()
fetchChatDeploymentInfo() fetchChatDeploymentInfo()
setActiveTab('api') setActiveTab('api')
setVersionToActivate(null) setVersionToActivate(null)
} else { } else {
setSelectedApiKeyId('')
setVersionToActivate(null) setVersionToActivate(null)
} }
}, [open, workflowId]) }, [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(() => { useEffect(() => {
async function fetchDeploymentInfo() { async function fetchDeploymentInfo() {
if (!open || !workflowId || !isDeployed) { if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null) setDeploymentInfo(null)
if (!open) { setIsLoading(false)
setIsLoading(false)
}
return return
} }
@@ -305,13 +264,14 @@ export function DeployModal({
const data = await response.json() const data = await response.json()
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY'
setDeploymentInfo({ setDeploymentInfo({
isDeployed: data.isDeployed, isDeployed: data.isDeployed,
deployedAt: data.deployedAt, deployedAt: data.deployedAt,
apiKey: data.apiKey, apiKey: data.apiKey || placeholderKey,
endpoint, 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, needsRedeployment,
}) })
} catch (error) { } catch (error) {
@@ -324,14 +284,12 @@ export function DeployModal({
fetchDeploymentInfo() fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed]) }, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
const onDeploy = async (data: DeployFormValues) => { const onDeploy = async () => {
setApiDeployError(null) setApiDeployError(null)
try { try {
setIsSubmitting(true) setIsSubmitting(true)
const apiKeyToUse = data.apiKey || selectedApiKeyId
let deployEndpoint = `/api/workflows/${workflowId}/deploy` let deployEndpoint = `/api/workflows/${workflowId}/deploy`
if (versionToActivate !== null) { if (versionToActivate !== null) {
deployEndpoint = `/api/workflows/${workflowId}/deployments/${versionToActivate}/activate` deployEndpoint = `/api/workflows/${workflowId}/deployments/${versionToActivate}/activate`
@@ -343,7 +301,6 @@ export function DeployModal({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
apiKey: apiKeyToUse,
deployChatEnabled: false, deployChatEnabled: false,
}), }),
}) })
@@ -358,14 +315,9 @@ export function DeployModal({
const isActivating = versionToActivate !== null const isActivating = versionToActivate !== null
const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false) const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false)
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
const apiKeyFromResponse = responseData.apiKey || apiKeyToUse const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyFromResponse) setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
const matchingKey = apiKeys.find((k) => k.key === apiKeyFromResponse || k.id === apiKeyToUse)
if (matchingKey) {
setSelectedApiKeyId(matchingKey.id)
}
const isActivatingVersion = versionToActivate !== null const isActivatingVersion = versionToActivate !== null
setNeedsRedeployment(isActivatingVersion) setNeedsRedeployment(isActivatingVersion)
@@ -381,13 +333,14 @@ export function DeployModal({
const deploymentData = await deploymentInfoResponse.json() const deploymentData = await deploymentInfoResponse.json()
const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({ setDeploymentInfo({
isDeployed: deploymentData.isDeployed, isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt, deployedAt: deploymentData.deployedAt,
apiKey: deploymentData.apiKey, apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint, 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, needsRedeployment: isActivatingVersion,
}) })
} }
@@ -543,7 +496,7 @@ export function DeployModal({
workflowId, workflowId,
newDeployStatus, newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined, deployedAt ? new Date(deployedAt) : undefined,
apiKey getApiKeyLabel(apiKey)
) )
setNeedsRedeployment(false) setNeedsRedeployment(false)
@@ -573,7 +526,7 @@ export function DeployModal({
const isActivating = versionToActivate !== null const isActivating = versionToActivate !== null
setDeploymentStatus(workflowId, true, new Date()) setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`) const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
if (deploymentInfoResponse.ok) { if (deploymentInfoResponse.ok) {
@@ -581,12 +534,14 @@ export function DeployModal({
const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute` const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0) const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
const placeholderKey = getApiHeaderPlaceholder()
setDeploymentInfo({ setDeploymentInfo({
isDeployed: deploymentData.isDeployed, isDeployed: deploymentData.isDeployed,
deployedAt: deploymentData.deployedAt, deployedAt: deploymentData.deployedAt,
apiKey: deploymentData.apiKey, apiKey: getApiKeyLabel(deploymentData.apiKey),
endpoint: apiEndpoint, 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, needsRedeployment: isActivating,
}) })
} }
@@ -679,72 +634,67 @@ export function DeployModal({
<div className='flex-1 overflow-y-auto'> <div className='flex-1 overflow-y-auto'>
<div className='p-6' key={`${activeTab}-${versionToActivate}`}> <div className='p-6' key={`${activeTab}-${versionToActivate}`}>
{activeTab === 'api' && ( {activeTab === 'api' && (
<> <div className='space-y-4'>
{versionToActivate !== null ? ( {apiDeployError && (
<> <div className='rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
{apiDeployError && ( <div className='font-semibold'>API Deployment Error</div>
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'> <div>{apiDeployError}</div>
<div className='font-semibold'>API Deployment Error</div> </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>
</>
)} )}
</>
{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' && ( {activeTab === 'versions' && (
@@ -915,57 +865,23 @@ export function DeployModal({
)} )}
{activeTab === 'chat' && ( {activeTab === 'chat' && (
<> <ChatDeploy
<ChatDeploy workflowId={workflowId || ''}
workflowId={workflowId || ''} deploymentInfo={deploymentInfo}
deploymentInfo={deploymentInfo} onChatExistsChange={setChatExists}
onChatExistsChange={setChatExists} chatSubmitting={chatSubmitting}
chatSubmitting={chatSubmitting} setChatSubmitting={setChatSubmitting}
setChatSubmitting={setChatSubmitting} onValidationChange={setIsChatFormValid}
onValidationChange={setIsChatFormValid} onDeploymentComplete={handleCloseModal}
onDeploymentComplete={handleCloseModal} onDeployed={handlePostDeploymentUpdate}
onDeployed={handlePostDeploymentUpdate} onUndeploy={handleUndeploy}
onUndeploy={handleUndeploy} onVersionActivated={() => setVersionToActivate(null)}
onVersionActivated={() => setVersionToActivate(null)} />
/>
</>
)} )}
</div> </div>
</div> </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' && ( {activeTab === 'chat' && (
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'> <div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
<Button variant='outline' onClick={handleCloseModal}> <Button variant='outline' onClick={handleCloseModal}>

View File

@@ -37,7 +37,7 @@ export function DeploymentControls({
const workflowNeedsRedeployment = needsRedeployment const workflowNeedsRedeployment = needsRedeployment
const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment
const [isDeploying, _setIsDeploying] = useState(false) const [isDeploying, setIsDeploying] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const lastWorkflowIdRef = useRef<string | null>(null) const lastWorkflowIdRef = useRef<string | null>(null)
@@ -59,11 +59,52 @@ export function DeploymentControls({
const canDeploy = userPermissions.canAdmin const canDeploy = userPermissions.canAdmin
const isDisabled = isDeploying || !canDeploy const isDisabled = isDeploying || !canDeploy
const handleDeployClick = useCallback(() => { const handleDeployClick = useCallback(async () => {
if (canDeploy) { if (!canDeploy || !activeWorkflowId) return
setIsModalOpen(true)
// 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 = () => { const getTooltipText = () => {
if (!canDeploy) { 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 // Get and sort regular workflows by creation date (newest first) for stable ordering
const regularWorkflows = Object.values(workflows) const regularWorkflows = Object.values(workflows)
.filter((workflow) => workflow.workspaceId === workspaceId) .filter((workflow) => workflow.workspaceId === workspaceId)
.filter((workflow) => workflow.marketplaceData?.status !== 'temp')
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
// Group workflows by folder // Group workflows by folder

View File

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

View File

@@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' 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 { useParams } from 'next/navigation'
import { import {
AlertDialog, AlertDialog,
@@ -17,6 +17,8 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client' import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -58,7 +60,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
const params = useParams() const params = useParams()
const workspaceId = (params?.workspaceId as string) || '' const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext() const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin const canManageWorkspaceKeys = userPermissions.canAdmin
// State for both workspace and personal keys // State for both workspace and personal keys
const [workspaceKeys, setWorkspaceKeys] = useState<ApiKey[]>([]) const [workspaceKeys, setWorkspaceKeys] = useState<ApiKey[]>([])
@@ -79,6 +81,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false) const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const [createError, setCreateError] = useState<string | null>(null) 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 scrollContainerRef = useRef<HTMLDivElement>(null)
const filteredWorkspaceKeys = useMemo(() => { 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 () => { const handleCreateKey = async () => {
if (!userId || !newKeyName.trim()) return if (!userId || !newKeyName.trim()) return
@@ -281,6 +364,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
} }
}, [registerCloseHandler]) }, [registerCloseHandler])
useEffect(() => {
if (workspaceId) {
fetchWorkspaceSettings()
}
}, [workspaceId])
useEffect(() => {
if (!allowPersonalApiKeys && keyType === 'personal') {
setKeyType('workspace')
}
}, [allowPersonalApiKeys, keyType])
useEffect(() => { useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) { if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({ scrollContainerRef.current.scrollTo({
@@ -303,7 +398,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
return ( return (
<div className='relative flex h-full flex-col'> <div className='relative flex h-full flex-col'>
{/* Fixed Header */} {/* Fixed Header */}
<div className='px-6 pt-4 pb-2'> <div className='px-6 pt-2 pb-2'>
{/* Search Input */} {/* Search Input */}
{isLoading ? ( {isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' /> <Skeleton className='h-9 w-56 rounded-lg' />
@@ -338,6 +433,50 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div> </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 */} {/* Workspace section */}
{!searchTerm.trim() ? ( {!searchTerm.trim() ? (
<div className='mb-6 space-y-2'> <div className='mb-6 space-y-2'>
@@ -468,27 +607,26 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Footer */} {/* Footer */}
<div className='bg-background'> <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 ? ( {isLoading ? (
<> <Skeleton className='h-9 w-[117px] rounded-[8px]' />
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[108px]' />
</>
) : ( ) : (
<> <Button
<Button onClick={() => {
onClick={() => { if (createButtonDisabled) {
setIsCreateDialogOpen(true) return
setKeyType('personal') }
setCreateError(null) setIsCreateDialogOpen(true)
}} setKeyType(defaultKeyType)
variant='ghost' setCreateError(null)
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' }}
> variant='ghost'
<Plus className='h-4 w-4 stroke-[2px]' /> disabled={createButtonDisabled}
Create Key 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'
</Button> >
</> <Plus className='h-4 w-4 stroke-[2px]' />
Create Key
</Button>
)} )}
</div> </div>
</div> </div>
@@ -518,7 +656,8 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
setKeyType('personal') setKeyType('personal')
if (createError) setCreateError(null) 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 Personal
</Button> </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' 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={() => { onClick={() => {
setNewKeyName('') setNewKeyName('')
setKeyType('personal') setKeyType(defaultKeyType)
}} }}
> >
Cancel Cancel

View File

@@ -12,7 +12,6 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession, useSubscription } from '@/lib/auth-client' import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils' import { getBaseUrl } from '@/lib/urls/utils'
@@ -30,6 +29,7 @@ interface CancelSubscriptionProps {
} }
subscriptionData?: { subscriptionData?: {
periodEnd?: Date | null periodEnd?: Date | null
cancelAtPeriodEnd?: boolean
} }
} }
@@ -127,35 +127,48 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const subscriptionStatus = getSubscriptionStatus() const subscriptionStatus = getSubscriptionStatus()
const activeOrgId = activeOrganization?.id const activeOrgId = activeOrganization?.id
// For team/enterprise plans, get the subscription ID from organization store if (isCancelAtPeriodEnd) {
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) { if (!betterAuthSubscription.restore) {
const orgSubscription = useOrganizationStore.getState().subscriptionData throw new Error('Subscription restore not available')
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)
} }
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() await refresh()
if (activeOrgId) { if (activeOrgId) {
await loadOrganizationSubscription(activeOrgId) await loadOrganizationSubscription(activeOrgId)
await refreshOrganization().catch(() => {}) await refreshOrganization().catch(() => {})
} }
setIsDialogOpen(false) setIsDialogOpen(false)
} catch (error) { } 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) setError(errorMessage)
logger.error('Failed to keep subscription', { error }) logger.error('Failed to restore subscription', { error })
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -190,19 +203,15 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const periodEndDate = getPeriodEndDate() const periodEndDate = getPeriodEndDate()
// Check if subscription is set to cancel at period end // Check if subscription is set to cancel at period end
const isCancelAtPeriodEnd = (() => { const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
const subscriptionStatus = getSubscriptionStatus()
if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) {
return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true
}
return false
})()
return ( return (
<> <>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
<div> <div>
<span className='font-medium text-sm'>Manage Subscription</span> <span className='font-medium text-sm'>
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
</span>
{isCancelAtPeriodEnd && ( {isCancelAtPeriodEnd && (
<p className='mt-1 text-muted-foreground text-xs'> <p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)} 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', 'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
error error
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500' ? '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> </Button>
</div> </div>
@@ -228,11 +239,11 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{isCancelAtPeriodEnd ? 'Manage' : 'Cancel'} {subscription.plan} subscription? {isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} subscription?
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{isCancelAtPeriodEnd {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( : `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
periodEndDate periodEndDate
)}, then downgrade to free plan.`}{' '} )}, then downgrade to free plan.`}{' '}
@@ -260,38 +271,23 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
<AlertDialogFooter className='flex'> <AlertDialogFooter className='flex'>
<AlertDialogCancel <AlertDialogCancel
className='h-9 w-full rounded-[8px]' className='h-9 w-full rounded-[8px]'
onClick={handleKeep} onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
disabled={isLoading} disabled={isLoading}
> >
Keep Subscription {isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'}
</AlertDialogCancel> </AlertDialogCancel>
{(() => { {(() => {
const subscriptionStatus = getSubscriptionStatus() const subscriptionStatus = getSubscriptionStatus()
if ( if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) {
subscriptionStatus.isPaid &&
(activeOrganization?.id
? useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd
: false)
) {
return ( return (
<TooltipProvider delayDuration={0}> <AlertDialogAction
<Tooltip> onClick={handleKeep}
<TooltipTrigger asChild> 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'
<div className='w-full'> disabled={isLoading}
<AlertDialogAction >
disabled {isLoading ? 'Restoring...' : 'Restore Subscription'}
className='h-9 w-full cursor-not-allowed rounded-[8px] bg-muted text-muted-foreground opacity-50' </AlertDialogAction>
>
Continue
</AlertDialogAction>
</div>
</TooltipTrigger>
<TooltipContent side='top'>
<p>Subscription will be cancelled at end of billing period</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) )
} }
return ( return (

View File

@@ -1,10 +1,22 @@
'use client' 'use client'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Skeleton, Switch } from '@/components/ui' 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 { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade' import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
import { getBaseUrl } from '@/lib/urls/utils' import { getBaseUrl } from '@/lib/urls/utils'
import { cn } from '@/lib/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 { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header'
import { import {
CancelSubscription, CancelSubscription,
@@ -175,6 +187,11 @@ const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() +
export function Subscription({ onOpenChange }: SubscriptionProps) { export function Subscription({ onOpenChange }: SubscriptionProps) {
const { data: session } = useSession() const { data: session } = useSession()
const { handleUpgrade } = useSubscriptionUpgrade() const { handleUpgrade } = useSubscriptionUpgrade()
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const logger = createLogger('Subscription')
const { const {
isLoading, isLoading,
@@ -191,6 +208,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null) const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
const usageLimitRef = useRef<UsageLimitRef | 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 // Get real subscription data from store
const subscription = getSubscriptionStatus() const subscription = getSubscriptionStatus()
const usage = getUsage() const usage = getUsage()
@@ -203,6 +228,77 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
} }
}, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData]) }, [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 // Auto-clear upgrade error
useEffect(() => { useEffect(() => {
if (upgradeError) { if (upgradeError) {
@@ -540,10 +636,56 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
}} }}
subscriptionData={{ subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null, periodEnd: subscriptionData?.periodEnd || null,
cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd,
}} }}
/> />
</div> </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>
</div> </div>
) )

View File

@@ -40,15 +40,10 @@ function WorkflowItem({ workflow, active, isMarketplace }: WorkflowItemProps) {
interface WorkflowListProps { interface WorkflowListProps {
regularWorkflows: WorkflowMetadata[] regularWorkflows: WorkflowMetadata[]
marketplaceWorkflows: WorkflowMetadata[]
isLoading?: boolean isLoading?: boolean
} }
export function WorkflowList({ export function WorkflowList({ regularWorkflows, isLoading = false }: WorkflowListProps) {
regularWorkflows,
marketplaceWorkflows,
isLoading = false,
}: WorkflowListProps) {
const pathname = usePathname() const pathname = usePathname()
const params = useParams() const params = useParams()
const workspaceId = params.workspaceId as string 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 // Only show empty state when not loading and user is logged in
const showEmptyState = const showEmptyState = !isLoading && session?.user && regularWorkflows.length === 0
!isLoading &&
session?.user &&
regularWorkflows.length === 0 &&
marketplaceWorkflows.length === 0
return ( return (
<div className={`space-y-1 ${isLoading ? 'opacity-60' : ''}`}> <div className={`space-y-1 ${isLoading ? 'opacity-60' : ''}`}>
@@ -91,29 +82,6 @@ export function WorkflowList({
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`} 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> </div>

View File

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

View File

@@ -3,7 +3,6 @@ import { task } from '@trigger.dev/sdk'
import { Cron } from 'croner' import { Cron } from 'croner'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getApiKeyOwnerUserId } from '@/lib/api-key/service'
import { checkServerSideUsageLimits } from '@/lib/billing' import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
@@ -19,6 +18,7 @@ import {
import { decryptSecret } from '@/lib/utils' import { decryptSecret } from '@/lib/utils'
import { blockExistsInDeployment, loadDeployedWorkflowState } from '@/lib/workflows/db-helpers' import { blockExistsInDeployment, loadDeployedWorkflowState } from '@/lib/workflows/db-helpers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { Executor } from '@/executor' import { Executor } from '@/executor'
import { Serializer } from '@/serializer' import { Serializer } from '@/serializer'
import { RateLimiter } from '@/services/queue' import { RateLimiter } from '@/services/queue'
@@ -91,11 +91,19 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
return 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) { if (!actorUserId) {
logger.warn( 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 return
} }

View File

@@ -1,11 +1,13 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { db } from '@sim/db' 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 { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { authenticateApiKey } from '@/lib/api-key/auth' import { authenticateApiKey } from '@/lib/api-key/auth'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
const logger = createLogger('ApiKeyService') const logger = createLogger('ApiKeyService')
@@ -36,6 +38,18 @@ export async function authenticateApiKeyFromHeader(
} }
try { 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 // Build query based on options
let query = db let query = db
.select({ .select({
@@ -48,11 +62,6 @@ export async function authenticateApiKeyFromHeader(
}) })
.from(apiKeyTable) .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 // Apply filters
const conditions = [] const conditions = []
@@ -60,10 +69,6 @@ export async function authenticateApiKeyFromHeader(
conditions.push(eq(apiKeyTable.userId, options.userId)) 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) {
if (options.keyTypes.length === 1) { if (options.keyTypes.length === 1) {
conditions.push(eq(apiKeyTable.type, options.keyTypes[0])) conditions.push(eq(apiKeyTable.type, options.keyTypes[0]))
@@ -78,11 +83,27 @@ export async function authenticateApiKeyFromHeader(
const keyRecords = await query const keyRecords = await query
// Filter by keyTypes in memory if multiple types specified const filteredRecords = keyRecords.filter((record) => {
const filteredRecords = const keyType = record.type as 'personal' | 'workspace'
options.keyTypes?.length && options.keyTypes.length > 1
? keyRecords.filter((record) => options.keyTypes!.includes(record.type as any)) if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) {
: keyRecords 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 // Authenticate each key
for (const storedKey of filteredRecords) { for (const storedKey of filteredRecords) {
@@ -91,6 +112,29 @@ export async function authenticateApiKeyFromHeader(
continue 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 { try {
const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key) const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key)
if (isValid) { if (isValid) {
@@ -99,7 +143,7 @@ export async function authenticateApiKeyFromHeader(
userId: storedKey.userId, userId: storedKey.userId,
keyId: storedKey.id, keyId: storedKey.id,
keyType: storedKey.type as 'personal' | 'workspace', keyType: storedKey.type as 'personal' | 'workspace',
workspaceId: storedKey.workspaceId || undefined, workspaceId: storedKey.workspaceId || options.workspaceId || undefined,
} }
} }
} catch (error) { } 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 * Get the API encryption key from the environment
* @returns The API encryption key * @returns The API encryption key

View File

@@ -220,6 +220,7 @@ export async function getSimplifiedBillingSummary(
metadata: any metadata: any
stripeSubscriptionId: string | null stripeSubscriptionId: string | null
periodEnd: Date | string | null periodEnd: Date | string | null
cancelAtPeriodEnd?: boolean
// Usage details // Usage details
usage: { usage: {
current: number current: number
@@ -341,6 +342,7 @@ export async function getSimplifiedBillingSummary(
metadata: subscription.metadata || null, metadata: subscription.metadata || null,
stripeSubscriptionId: subscription.stripeSubscriptionId || null, stripeSubscriptionId: subscription.stripeSubscriptionId || null,
periodEnd: subscription.periodEnd || null, periodEnd: subscription.periodEnd || null,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined,
// Usage details // Usage details
usage: { usage: {
current: usageData.currentUsage, current: usageData.currentUsage,
@@ -463,6 +465,7 @@ export async function getSimplifiedBillingSummary(
metadata: subscription?.metadata || null, metadata: subscription?.metadata || null,
stripeSubscriptionId: subscription?.stripeSubscriptionId || null, stripeSubscriptionId: subscription?.stripeSubscriptionId || null,
periodEnd: subscription?.periodEnd || null, periodEnd: subscription?.periodEnd || null,
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || undefined,
// Usage details // Usage details
usage: { usage: {
current: currentUsage, current: currentUsage,
@@ -524,5 +527,14 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
daysRemaining: 0, daysRemaining: 0,
copilotCost: 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 { tasks } from '@trigger.dev/sdk'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getApiKeyOwnerUserId } from '@/lib/api-key/service'
import { checkServerSideUsageLimits } from '@/lib/billing' import { checkServerSideUsageLimits } from '@/lib/billing'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { env, isTruthy } from '@/lib/env' import { env, isTruthy } from '@/lib/env'
@@ -13,6 +12,7 @@ import {
validateMicrosoftTeamsSignature, validateMicrosoftTeamsSignature,
verifyProviderWebhook, verifyProviderWebhook,
} from '@/lib/webhooks/utils' } from '@/lib/webhooks/utils'
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
import { executeWebhookJob } from '@/background/webhook-execution' import { executeWebhookJob } from '@/background/webhook-execution'
import { RateLimiter } from '@/services/queue' import { RateLimiter } from '@/services/queue'
@@ -26,6 +26,20 @@ export interface WebhookProcessorOptions {
executionTarget?: 'deployed' | 'live' 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( export async function parseWebhookBody(
request: NextRequest, request: NextRequest,
requestId: string requestId: string
@@ -269,11 +283,11 @@ export async function checkRateLimits(
requestId: string requestId: string
): Promise<NextResponse | null> { ): Promise<NextResponse | null> {
try { try {
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId) const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
if (!actorUserId) { if (!actorUserId) {
logger.warn(`[${requestId}] Webhook requires pinned API key to attribute usage`) logger.warn(`[${requestId}] Webhook requires a workspace billing account to attribute usage`)
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 }) return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
} }
const userSubscription = await getHighestPrioritySubscription(actorUserId) const userSubscription = await getHighestPrioritySubscription(actorUserId)
@@ -327,11 +341,11 @@ export async function checkUsageLimits(
} }
try { try {
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId) const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
if (!actorUserId) { if (!actorUserId) {
logger.warn(`[${requestId}] Webhook requires pinned API key to attribute usage`) logger.warn(`[${requestId}] Webhook requires a workspace billing account to attribute usage`)
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 }) return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
} }
const usageCheck = await checkServerSideUsageLimits(actorUserId) const usageCheck = await checkServerSideUsageLimits(actorUserId)
@@ -376,10 +390,12 @@ export async function queueWebhookExecution(
options: WebhookProcessorOptions options: WebhookProcessorOptions
): Promise<NextResponse> { ): Promise<NextResponse> {
try { try {
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId) const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
if (!actorUserId) { if (!actorUserId) {
logger.warn(`[${options.requestId}] Webhook requires pinned API key to attribute usage`) logger.warn(
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 }) `[${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()) const headers = Object.fromEntries(request.headers.entries())

View File

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

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db' 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 type { InferSelectModel } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm' import { and, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
@@ -12,86 +12,12 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowUtils') 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 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) { export async function getWorkflowById(id: string) {
const rows = await db const rows = await db.select().from(workflowTable).where(eq(workflowTable.id, id)).limit(1)
.select(WORKFLOW_BASE_SELECTION)
.from(workflowTable)
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
.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> type WorkflowRecord = ReturnType<typeof getWorkflowById> extends Promise<infer R>
@@ -110,55 +36,50 @@ export async function getWorkflowAccessContext(
workflowId: string, workflowId: string,
userId?: string userId?: string
): Promise<WorkflowAccessContext | null> { ): Promise<WorkflowAccessContext | null> {
const rows = await db const workflow = await getWorkflowById(workflowId)
.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)
if (!workflow) { if (!workflow) {
return null return null
} }
const resolvedWorkspaceOwner = row.workspaceOwnerId ?? null let workspaceOwnerId: string | null = null
const resolvedWorkspacePermission = row.workspacePermission ?? 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 resolvedUserId = userId ?? null
const isOwner = resolvedUserId ? workflow.userId === resolvedUserId : false const isOwner = resolvedUserId ? workflow.userId === resolvedUserId : false
const isWorkspaceOwner = resolvedUserId ? resolvedWorkspaceOwner === resolvedUserId : false const isWorkspaceOwner = resolvedUserId ? workspaceOwnerId === resolvedUserId : false
return { return {
workflow, workflow,
workspaceOwnerId: resolvedWorkspaceOwner, workspaceOwnerId,
workspacePermission: resolvedWorkspacePermission, workspacePermission,
isOwner, isOwner,
isWorkspaceOwner, 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 metadata: any | null
stripeSubscriptionId: string | null stripeSubscriptionId: string | null
periodEnd: Date | null periodEnd: Date | null
cancelAtPeriodEnd?: boolean
usage: UsageData usage: UsageData
billingBlocked?: boolean billingBlocked?: boolean
} }

View File

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

View File

@@ -92,7 +92,6 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
color, color,
variables, variables,
createdAt, createdAt,
marketplaceData,
workspaceId, workspaceId,
folderId, folderId,
isDeployed, isDeployed,
@@ -108,7 +107,6 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
color: color || '#3972F6', color: color || '#3972F6',
lastModified: createdAt ? new Date(createdAt) : new Date(), lastModified: createdAt ? new Date(createdAt) : new Date(),
createdAt: createdAt ? new Date(createdAt) : new Date(), createdAt: createdAt ? new Date(createdAt) : new Date(),
marketplaceData: marketplaceData || null,
workspaceId, workspaceId,
folderId: folderId || null, folderId: folderId || null,
} }
@@ -438,7 +436,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined, deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined,
apiKey: workflowData.apiKey, apiKey: workflowData.apiKey,
lastSaved: Date.now(), lastSaved: Date.now(),
marketplaceData: workflowData.marketplaceData || null,
deploymentStatuses: {}, deploymentStatuses: {},
} }
} else { } else {
@@ -513,7 +510,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
body: JSON.stringify({ body: JSON.stringify({
name: options.name || generateCreativeWorkflowName(), name: options.name || generateCreativeWorkflowName(),
description: options.description || 'New workflow', description: options.description || 'New workflow',
color: options.marketplaceId ? '#808080' : getNextWorkflowColor(), color: getNextWorkflowColor(),
workspaceId, workspaceId,
folderId: options.folderId || null, folderId: options.folderId || null,
}), }),
@@ -537,17 +534,10 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
createdAt: new Date(), createdAt: new Date(),
description: createdWorkflow.description, description: createdWorkflow.description,
color: createdWorkflow.color, color: createdWorkflow.color,
marketplaceData: options.marketplaceId
? { id: options.marketplaceId, status: 'temp' as const }
: undefined,
workspaceId, workspaceId,
folderId: createdWorkflow.folderId, folderId: createdWorkflow.folderId,
} }
if (options.marketplaceId && options.marketplaceState) {
logger.info(`Created workflow from marketplace: ${options.marketplaceId}`)
}
// Add workflow to registry with server-generated ID // Add workflow to registry with server-generated ID
set((state) => ({ set((state) => ({
workflows: { workflows: {
@@ -557,26 +547,16 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
error: null, 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 // Initialize subblock values to ensure they're available for sync
if (!options.marketplaceId) { const subblockValues: Record<string, Record<string, any>> = {}
// For non-marketplace workflows, initialize empty subblock values
const subblockValues: Record<string, Record<string, any>> = {}
// Update the subblock store with the initial values // Update the subblock store with the initial values
useSubBlockStore.setState((state) => ({ useSubBlockStore.setState((state) => ({
workflowValues: { workflowValues: {
...state.workflowValues, ...state.workflowValues,
[serverWorkflowId]: subblockValues, [serverWorkflowId]: subblockValues,
}, },
})) }))
}
// Don't set as active workflow here - let the navigation/URL change handle that // Don't set as active workflow here - let the navigation/URL change handle that
// This prevents race conditions and flickering // 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 * 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 { export interface DeploymentStatus {
isDeployed: boolean isDeployed: boolean
deployedAt?: Date deployedAt?: Date
@@ -17,7 +12,6 @@ export interface WorkflowMetadata {
createdAt: Date createdAt: Date
description?: string description?: string
color: string color: string
marketplaceData?: MarketplaceData | null
workspaceId?: string workspaceId?: string
folderId?: string | null folderId?: string | null
} }
@@ -39,18 +33,11 @@ export interface WorkflowRegistryActions {
updateWorkflow: (id: string, metadata: Partial<WorkflowMetadata>) => Promise<void> updateWorkflow: (id: string, metadata: Partial<WorkflowMetadata>) => Promise<void>
createWorkflow: (options?: { createWorkflow: (options?: {
isInitial?: boolean isInitial?: boolean
marketplaceId?: string
marketplaceState?: any
name?: string name?: string
description?: string description?: string
workspaceId?: string workspaceId?: string
folderId?: string | null folderId?: string | null
}) => Promise<string> }) => Promise<string>
createMarketplaceWorkflow: (
marketplaceId: string,
state: any,
metadata: Partial<WorkflowMetadata>
) => Promise<string>
duplicateWorkflow: (sourceId: string) => Promise<string | null> duplicateWorkflow: (sourceId: string) => Promise<string | null>
getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null
setDeploymentStatus: ( 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, "when": 1761845605676,
"tag": "0103_careful_harpoon", "tag": "0103_careful_harpoon",
"breakpoints": true "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(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
isDeployed: boolean('is_deployed').notNull().default(false), isDeployed: boolean('is_deployed').notNull().default(false),
deployedState: json('deployed_state'),
deployedAt: timestamp('deployed_at'), 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), runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'), lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'), variables: json('variables').default('{}'),
isPublished: boolean('is_published').notNull().default(false),
marketplaceData: json('marketplace_data'),
}, },
(table) => ({ (table) => ({
userIdIdx: index('workflow_user_id_idx').on(table.userId), userIdIdx: index('workflow_user_id_idx').on(table.userId),
@@ -727,6 +722,10 @@ export const workspace = pgTable('workspace', {
ownerId: text('owner_id') ownerId: text('owner_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .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(), createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(),
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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