mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
committed by
GitHub
parent
c99bb0aaa2
commit
fe9ebbf81b
@@ -284,7 +284,6 @@ class AsyncExecutionResult:
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
|
||||
interface WorkflowStatus {
|
||||
isDeployed: boolean;
|
||||
deployedAt?: string;
|
||||
isPublished: boolean;
|
||||
needsRedeployment: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -283,7 +283,6 @@ class AsyncExecutionResult:
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -294,7 +294,6 @@ interface AsyncExecutionResult {
|
||||
interface WorkflowStatus {
|
||||
isDeployed: boolean;
|
||||
deployedAt?: string;
|
||||
isPublished: boolean;
|
||||
needsRedeployment: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -284,7 +284,6 @@ class AsyncExecutionResult:
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
|
||||
interface WorkflowStatus {
|
||||
isDeployed: boolean;
|
||||
deployedAt?: string;
|
||||
isPublished: boolean;
|
||||
needsRedeployment: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -284,7 +284,6 @@ class AsyncExecutionResult:
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
|
||||
interface WorkflowStatus {
|
||||
isDeployed: boolean;
|
||||
deployedAt?: string;
|
||||
isPublished: boolean;
|
||||
needsRedeployment: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -284,7 +284,6 @@ class AsyncExecutionResult:
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
|
||||
interface WorkflowStatus {
|
||||
isDeployed: boolean;
|
||||
deployedAt?: string;
|
||||
isPublished: boolean;
|
||||
needsRedeployment: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -284,7 +284,6 @@ class AsyncExecutionResult:
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -301,7 +301,6 @@ interface AsyncExecutionResult {
|
||||
interface WorkflowStatus {
|
||||
isDeployed: boolean;
|
||||
deployedAt?: string;
|
||||
isPublished: boolean;
|
||||
needsRedeployment: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { subscription as subscriptionTable, user } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
@@ -38,7 +38,10 @@ export async function POST(request: NextRequest) {
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionTable.referenceId, organizationId),
|
||||
eq(subscriptionTable.status, 'active')
|
||||
or(
|
||||
eq(subscriptionTable.status, 'active'),
|
||||
eq(subscriptionTable.cancelAtPeriodEnd, true)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
@@ -109,17 +109,17 @@ describe('Webhook Trigger API Route', () => {
|
||||
globalMockData.workflows.push({
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
pinnedApiKeyId: 'test-pinned-api-key-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
})
|
||||
|
||||
vi.doMock('@/lib/api-key/service', async () => {
|
||||
const actual = await vi.importActual('@/lib/api-key/service')
|
||||
vi.doMock('@/lib/workspaces/utils', async () => {
|
||||
const actual = await vi.importActual('@/lib/workspaces/utils')
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
getApiKeyOwnerUserId: vi
|
||||
getWorkspaceBilledAccountUserId: vi
|
||||
.fn()
|
||||
.mockImplementation(async (pinnedApiKeyId: string | null | undefined) =>
|
||||
pinnedApiKeyId ? 'test-user-id' : null
|
||||
.mockImplementation(async (workspaceId: string | null | undefined) =>
|
||||
workspaceId ? 'test-user-id' : null
|
||||
),
|
||||
}
|
||||
})
|
||||
@@ -240,7 +240,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
globalMockData.workflows.push({
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
pinnedApiKeyId: 'test-pinned-api-key-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', { event: 'test', id: 'test-123' })
|
||||
@@ -272,7 +272,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
globalMockData.workflows.push({
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
pinnedApiKeyId: 'test-pinned-api-key-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
@@ -307,7 +307,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
globalMockData.workflows.push({
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
pinnedApiKeyId: 'test-pinned-api-key-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
})
|
||||
|
||||
const headers = {
|
||||
@@ -338,7 +338,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
globalMockData.workflows.push({
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
pinnedApiKeyId: 'test-pinned-api-key-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
@@ -388,7 +388,7 @@ describe('Webhook Trigger API Route', () => {
|
||||
globalMockData.workflows.push({
|
||||
id: 'test-workflow-id',
|
||||
userId: 'test-user-id',
|
||||
pinnedApiKeyId: 'test-pinned-api-key-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
})
|
||||
|
||||
vi.doMock('@trigger.dev/sdk', () => ({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { deployWorkflow } from '@/lib/workflows/db-helpers'
|
||||
@@ -38,35 +38,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
|
||||
|
||||
if (workflowData.pinnedApiKeyId) {
|
||||
const pinnedKey = await db
|
||||
.select({ key: apiKey.key, name: apiKey.name, type: apiKey.type })
|
||||
.from(apiKey)
|
||||
.where(eq(apiKey.id, workflowData.pinnedApiKeyId))
|
||||
.limit(1)
|
||||
|
||||
if (pinnedKey.length > 0) {
|
||||
keyInfo = { name: pinnedKey[0].name, type: pinnedKey[0].type as 'personal' | 'workspace' }
|
||||
}
|
||||
} else {
|
||||
const userApiKey = await db
|
||||
.select({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
type: apiKey.type,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(and(eq(apiKey.userId, workflowData.userId), eq(apiKey.type, 'personal')))
|
||||
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (userApiKey.length > 0) {
|
||||
keyInfo = { name: userApiKey[0].name, type: userApiKey[0].type as 'personal' | 'workspace' }
|
||||
}
|
||||
}
|
||||
|
||||
let needsRedeployment = false
|
||||
const [active] = await db
|
||||
.select({ state: workflowDeploymentVersion.state })
|
||||
@@ -97,7 +68,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
|
||||
|
||||
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'No API key found'
|
||||
const responseApiKeyInfo = workflowData.workspaceId ? 'Workspace API keys' : 'Personal API keys'
|
||||
|
||||
return createSuccessResponse({
|
||||
apiKey: responseApiKeyInfo,
|
||||
@@ -127,101 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
const userId = workflowData!.userId
|
||||
|
||||
let providedApiKey: string | null = null
|
||||
try {
|
||||
const parsed = await request.json()
|
||||
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
|
||||
providedApiKey = parsed.apiKey.trim()
|
||||
}
|
||||
} catch (_err) {}
|
||||
|
||||
logger.debug(`[${requestId}] Validating API key for deployment`)
|
||||
|
||||
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
|
||||
let matchedKey: {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
type: 'personal' | 'workspace'
|
||||
} | null = null
|
||||
|
||||
// Use provided API key, or fall back to existing pinned API key for redeployment
|
||||
const apiKeyToUse = providedApiKey || workflowData!.pinnedApiKeyId
|
||||
|
||||
if (!apiKeyToUse) {
|
||||
return NextResponse.json(
|
||||
{ error: 'API key is required. Please create or select an API key before deploying.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let isValidKey = false
|
||||
|
||||
const currentUserId = session?.user?.id
|
||||
|
||||
if (currentUserId) {
|
||||
const [personalKey] = await db
|
||||
.select({
|
||||
id: apiKey.id,
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.id, apiKeyToUse),
|
||||
eq(apiKey.userId, currentUserId),
|
||||
eq(apiKey.type, 'personal')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (personalKey) {
|
||||
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
|
||||
matchedKey = { ...personalKey, type: 'personal' }
|
||||
isValidKey = true
|
||||
keyInfo = { name: personalKey.name, type: 'personal' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidKey) {
|
||||
if (workflowData!.workspaceId) {
|
||||
const [workspaceKey] = await db
|
||||
.select({
|
||||
id: apiKey.id,
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.id, apiKeyToUse),
|
||||
eq(apiKey.workspaceId, workflowData!.workspaceId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (workspaceKey) {
|
||||
if (!workspaceKey.expiresAt || workspaceKey.expiresAt >= new Date()) {
|
||||
matchedKey = { ...workspaceKey, type: 'workspace' }
|
||||
isValidKey = true
|
||||
keyInfo = { name: workspaceKey.name, type: 'workspace' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidKey) {
|
||||
logger.warn(`[${requestId}] Invalid API key ID provided for workflow deployment: ${id}`)
|
||||
return createErrorResponse('Invalid API key provided', 400)
|
||||
}
|
||||
|
||||
// Attribution: this route is UI-only; require session user as actor
|
||||
const actorUserId: string | null = session?.user?.id ?? null
|
||||
if (!actorUserId) {
|
||||
@@ -232,8 +108,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const deployResult = await deployWorkflow({
|
||||
workflowId: id,
|
||||
deployedBy: actorUserId,
|
||||
pinnedApiKeyId: matchedKey?.id,
|
||||
includeDeployedState: true,
|
||||
workflowName: workflowData!.name,
|
||||
})
|
||||
|
||||
@@ -243,20 +117,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const deployedAt = deployResult.deployedAt!
|
||||
|
||||
if (matchedKey) {
|
||||
try {
|
||||
await db
|
||||
.update(apiKey)
|
||||
.set({ lastUsed: new Date(), updatedAt: new Date() })
|
||||
.where(eq(apiKey.id, matchedKey.id))
|
||||
} catch (e) {
|
||||
logger.warn(`[${requestId}] Failed to update lastUsed for api key`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
|
||||
|
||||
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key'
|
||||
const responseApiKeyInfo = workflowData!.workspaceId
|
||||
? 'Workspace API keys'
|
||||
: 'Personal API keys'
|
||||
|
||||
return createSuccessResponse({
|
||||
apiKey: responseApiKeyInfo,
|
||||
@@ -298,7 +163,7 @@ export async function DELETE(
|
||||
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({ isDeployed: false, deployedAt: null, deployedState: null, pinnedApiKeyId: null })
|
||||
.set({ isDeployed: false, deployedAt: null })
|
||||
.where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiKey, db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -19,11 +19,7 @@ export async function POST(
|
||||
const { id, version } = await params
|
||||
|
||||
try {
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
@@ -33,52 +29,6 @@ export async function POST(
|
||||
return createErrorResponse('Invalid version', 400)
|
||||
}
|
||||
|
||||
let providedApiKey: string | null = null
|
||||
try {
|
||||
const parsed = await request.json()
|
||||
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
|
||||
providedApiKey = parsed.apiKey.trim()
|
||||
}
|
||||
} catch (_err) {}
|
||||
|
||||
let pinnedApiKeyId: string | null = null
|
||||
if (providedApiKey) {
|
||||
const currentUserId = session?.user?.id
|
||||
if (currentUserId) {
|
||||
const [personalKey] = await db
|
||||
.select({ id: apiKey.id })
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.id, providedApiKey),
|
||||
eq(apiKey.userId, currentUserId),
|
||||
eq(apiKey.type, 'personal')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (personalKey) {
|
||||
pinnedApiKeyId = personalKey.id
|
||||
} else if (workflowData!.workspaceId) {
|
||||
const [workspaceKey] = await db
|
||||
.select({ id: apiKey.id })
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.id, providedApiKey),
|
||||
eq(apiKey.workspaceId, workflowData!.workspaceId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (workspaceKey) {
|
||||
pinnedApiKeyId = workspaceKey.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -112,10 +62,6 @@ export async function POST(
|
||||
deployedAt: now,
|
||||
}
|
||||
|
||||
if (pinnedApiKeyId) {
|
||||
updateData.pinnedApiKeyId = pinnedApiKeyId
|
||||
}
|
||||
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
|
||||
})
|
||||
|
||||
|
||||
@@ -96,7 +96,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
collaborators: [],
|
||||
runCount: 0,
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
@@ -112,8 +111,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
}
|
||||
return remapped
|
||||
})(),
|
||||
isPublished: false,
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
// Copy all blocks from source workflow with new IDs
|
||||
|
||||
@@ -62,7 +62,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return createSuccessResponse({
|
||||
isDeployed: validation.workflow.isDeployed,
|
||||
deployedAt: validation.workflow.deployedAt,
|
||||
isPublished: validation.workflow.isPublished,
|
||||
needsRedeployment,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKey } from '@/lib/api-key/auth'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import {
|
||||
type ApiKeyAuthResult,
|
||||
authenticateApiKeyFromHeader,
|
||||
updateApiKeyLastUsed,
|
||||
} from '@/lib/api-key/service'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
@@ -60,50 +63,39 @@ export async function validateWorkflowAccess(
|
||||
}
|
||||
}
|
||||
|
||||
// If a pinned key exists, only accept that specific key
|
||||
if (workflow.pinnedApiKey?.key) {
|
||||
const isValidPinnedKey = await authenticateApiKey(apiKeyHeader, workflow.pinnedApiKey.key)
|
||||
if (!isValidPinnedKey) {
|
||||
return {
|
||||
error: {
|
||||
message: 'Unauthorized: Invalid API key',
|
||||
status: 401,
|
||||
},
|
||||
}
|
||||
let validResult: ApiKeyAuthResult | null = null
|
||||
|
||||
if (workflow.workspaceId) {
|
||||
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
workspaceId: workflow.workspaceId as string,
|
||||
keyTypes: ['workspace', 'personal'],
|
||||
})
|
||||
|
||||
if (workspaceResult.success) {
|
||||
validResult = workspaceResult
|
||||
}
|
||||
} else {
|
||||
// Try personal keys first
|
||||
const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
userId: workflow.userId as string,
|
||||
keyTypes: ['personal'],
|
||||
})
|
||||
|
||||
let validResult = null
|
||||
if (personalResult.success) {
|
||||
validResult = personalResult
|
||||
} else if (workflow.workspaceId) {
|
||||
// Try workspace keys
|
||||
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
workspaceId: workflow.workspaceId as string,
|
||||
keyTypes: ['workspace'],
|
||||
})
|
||||
|
||||
if (workspaceResult.success) {
|
||||
validResult = workspaceResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid key found, reject
|
||||
if (!validResult) {
|
||||
return {
|
||||
error: {
|
||||
message: 'Unauthorized: Invalid API key',
|
||||
status: 401,
|
||||
},
|
||||
}
|
||||
if (!validResult) {
|
||||
return {
|
||||
error: {
|
||||
message: 'Unauthorized: Invalid API key',
|
||||
status: 401,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
await updateApiKeyLastUsed(validResult.keyId!)
|
||||
if (validResult.keyId) {
|
||||
await updateApiKeyLastUsed(validResult.keyId)
|
||||
}
|
||||
}
|
||||
return { workflow }
|
||||
|
||||
@@ -143,11 +143,8 @@ export async function POST(req: NextRequest) {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
collaborators: [],
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
isPublished: false,
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)
|
||||
|
||||
@@ -94,10 +94,27 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can create workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name } = CreateKeySchema.parse(body)
|
||||
|
||||
@@ -181,10 +198,27 @@ export async function DELETE(
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can delete workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { keys } = DeleteKeysSchema.parse(body)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, type permissionTypeEnum } from '@sim/db/schema'
|
||||
import { permissions, type permissionTypeEnum, workspace } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -94,6 +94,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
|
||||
const body: UpdatePermissionsRequest = await request.json()
|
||||
|
||||
const workspaceRow = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRow.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const billedAccountUserId = workspaceRow[0].billedAccountUserId
|
||||
|
||||
const selfUpdate = body.updates.find((update) => update.userId === session.user.id)
|
||||
if (selfUpdate && selfUpdate.permissions !== 'admin') {
|
||||
return NextResponse.json(
|
||||
@@ -102,6 +114,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
billedAccountUserId &&
|
||||
body.updates.some(
|
||||
(update) => update.userId === billedAccountUserId && update.permissions !== 'admin'
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Workspace billing account must retain admin permissions' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const update of body.updates) {
|
||||
await tx
|
||||
|
||||
@@ -100,22 +100,95 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
}
|
||||
|
||||
try {
|
||||
const { name } = await request.json()
|
||||
const body = await request.json()
|
||||
const {
|
||||
name,
|
||||
billedAccountUserId,
|
||||
allowPersonalApiKeys,
|
||||
}: {
|
||||
name?: string
|
||||
billedAccountUserId?: string
|
||||
allowPersonalApiKeys?: boolean
|
||||
} = body ?? {}
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
|
||||
if (
|
||||
name === undefined &&
|
||||
billedAccountUserId === undefined &&
|
||||
allowPersonalApiKeys === undefined
|
||||
) {
|
||||
return NextResponse.json({ error: 'No updates provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Update workspace
|
||||
await db
|
||||
.update(workspace)
|
||||
.set({
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const existingWorkspace = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (!existingWorkspace) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (name !== undefined) {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 })
|
||||
}
|
||||
updateData.name = trimmedName
|
||||
}
|
||||
|
||||
if (allowPersonalApiKeys !== undefined) {
|
||||
updateData.allowPersonalApiKeys = Boolean(allowPersonalApiKeys)
|
||||
}
|
||||
|
||||
if (billedAccountUserId !== undefined) {
|
||||
const candidateId = billedAccountUserId?.trim()
|
||||
|
||||
if (!candidateId) {
|
||||
return NextResponse.json({ error: 'billedAccountUserId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const isOwner = candidateId === existingWorkspace.ownerId
|
||||
|
||||
let hasAdminAccess = isOwner
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
const adminPermission = await db
|
||||
.select({ id: permissions.id })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workspaceId),
|
||||
eq(permissions.userId, candidateId),
|
||||
eq(permissions.permissionType, 'admin')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
hasAdminAccess = adminPermission.length > 0
|
||||
}
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Billed account must be a workspace admin' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
updateData.billedAccountUserId = candidateId
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid updates provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
updateData.updatedAt = new Date()
|
||||
|
||||
await db.update(workspace).set(updateData).where(eq(workspace.id, workspaceId))
|
||||
|
||||
// Get updated workspace
|
||||
const updatedWorkspace = await db
|
||||
.select()
|
||||
.from(workspace)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions } from '@sim/db/schema'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -23,6 +23,23 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
||||
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const workspaceRow = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRow.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRow[0].billedAccountUserId === userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot remove the workspace billing account. Please reassign billing first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the user to be removed actually has permissions for this workspace
|
||||
const userPermission = await db
|
||||
.select()
|
||||
|
||||
@@ -95,6 +95,8 @@ async function createWorkspace(userId: string, name: string) {
|
||||
id: workspaceId,
|
||||
name,
|
||||
ownerId: userId,
|
||||
billedAccountUserId: userId,
|
||||
allowPersonalApiKeys: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -124,11 +126,8 @@ async function createWorkspace(userId: string, name: string) {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
collaborators: [],
|
||||
runCount: 0,
|
||||
variables: {},
|
||||
isPublished: false,
|
||||
marketplaceData: null,
|
||||
})
|
||||
|
||||
// No blocks are inserted - empty canvas
|
||||
@@ -147,6 +146,8 @@ async function createWorkspace(userId: string, name: string) {
|
||||
id: workspaceId,
|
||||
name,
|
||||
ownerId: userId,
|
||||
billedAccountUserId: userId,
|
||||
allowPersonalApiKeys: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
role: 'owner',
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
ApiEndpoint,
|
||||
ApiKey,
|
||||
DeployStatus,
|
||||
ExampleCommand,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/deployment-info/components'
|
||||
@@ -114,7 +113,6 @@ export function DeploymentInfo({
|
||||
<div className='space-y-4 overflow-y-auto px-1'>
|
||||
<div className='space-y-4'>
|
||||
<ApiEndpoint endpoint={deploymentInfo.endpoint} />
|
||||
<ApiKey apiKey={deploymentInfo.apiKey} />
|
||||
<ExampleCommand
|
||||
command={deploymentInfo.exampleCommand}
|
||||
apiKey={deploymentInfo.apiKey}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { ChatDeploy } from './chat-deploy/chat-deploy'
|
||||
export { DeployForm } from './deploy-form/deploy-form'
|
||||
export { DeploymentInfo } from './deployment-info/deployment-info'
|
||||
export { ImageSelector } from './image-selector/image-selector'
|
||||
|
||||
@@ -17,10 +17,7 @@ import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
|
||||
import {
|
||||
DeployForm,
|
||||
DeploymentInfo,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
|
||||
import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
|
||||
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
|
||||
import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -29,7 +26,6 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('DeployModal')
|
||||
|
||||
interface DeployModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -41,15 +37,6 @@ interface DeployModalProps {
|
||||
refetchDeployedState: () => Promise<void>
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
lastUsed?: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
interface WorkflowDeploymentInfo {
|
||||
isDeployed: boolean
|
||||
deployedAt?: string
|
||||
@@ -59,12 +46,7 @@ interface WorkflowDeploymentInfo {
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
interface DeployFormValues {
|
||||
apiKey: string
|
||||
newKeyName?: string
|
||||
}
|
||||
|
||||
type TabView = 'general' | 'api' | 'versions' | 'chat'
|
||||
type TabView = 'api' | 'versions' | 'chat'
|
||||
|
||||
export function DeployModal({
|
||||
open,
|
||||
@@ -85,9 +67,11 @@ export function DeployModal({
|
||||
const [isUndeploying, setIsUndeploying] = useState(false)
|
||||
const [deploymentInfo, setDeploymentInfo] = useState<WorkflowDeploymentInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
const [activeTab, setActiveTab] = useState<TabView>('general')
|
||||
const [selectedApiKeyId, setSelectedApiKeyId] = useState<string>('')
|
||||
const workflowMetadata = useWorkflowRegistry((state) =>
|
||||
workflowId ? state.workflows[workflowId] : undefined
|
||||
)
|
||||
const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null
|
||||
const [activeTab, setActiveTab] = useState<TabView>('api')
|
||||
const [chatSubmitting, setChatSubmitting] = useState(false)
|
||||
const [apiDeployError, setApiDeployError] = useState<string | null>(null)
|
||||
const [chatExists, setChatExists] = useState(false)
|
||||
@@ -116,6 +100,16 @@ export function DeployModal({
|
||||
}
|
||||
}, [editingVersion])
|
||||
|
||||
const getApiKeyLabel = (value?: string | null) => {
|
||||
if (value && value.trim().length > 0) {
|
||||
return value
|
||||
}
|
||||
return workflowWorkspaceId ? 'Workspace API keys' : 'Personal API keys'
|
||||
}
|
||||
|
||||
const getApiHeaderPlaceholder = () =>
|
||||
workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_PERSONAL_API_KEY'
|
||||
|
||||
const getInputFormatExample = (includeStreaming = false) => {
|
||||
let inputFormatExample = ''
|
||||
try {
|
||||
@@ -209,21 +203,6 @@ export function DeployModal({
|
||||
return inputFormatExample
|
||||
}
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
if (!open) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me/api-keys')
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setApiKeys(data.keys || [])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching API keys:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChatDeploymentInfo = async () => {
|
||||
if (!open || !workflowId) return
|
||||
|
||||
@@ -252,39 +231,19 @@ export function DeployModal({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setIsLoading(true)
|
||||
fetchApiKeys()
|
||||
fetchChatDeploymentInfo()
|
||||
setActiveTab('api')
|
||||
setVersionToActivate(null)
|
||||
} else {
|
||||
setSelectedApiKeyId('')
|
||||
setVersionToActivate(null)
|
||||
}
|
||||
}, [open, workflowId])
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys.length === 0) return
|
||||
|
||||
if (deploymentInfo?.apiKey) {
|
||||
const matchingKey = apiKeys.find((k) => k.key === deploymentInfo.apiKey)
|
||||
if (matchingKey) {
|
||||
setSelectedApiKeyId(matchingKey.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedApiKeyId) {
|
||||
setSelectedApiKeyId(apiKeys[0].id)
|
||||
}
|
||||
}, [deploymentInfo, apiKeys])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDeploymentInfo() {
|
||||
if (!open || !workflowId || !isDeployed) {
|
||||
setDeploymentInfo(null)
|
||||
if (!open) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -305,13 +264,14 @@ export function DeployModal({
|
||||
const data = await response.json()
|
||||
const endpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
const placeholderKey = workflowWorkspaceId ? 'YOUR_WORKSPACE_API_KEY' : 'YOUR_API_KEY'
|
||||
|
||||
setDeploymentInfo({
|
||||
isDeployed: data.isDeployed,
|
||||
deployedAt: data.deployedAt,
|
||||
apiKey: data.apiKey,
|
||||
apiKey: data.apiKey || placeholderKey,
|
||||
endpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
|
||||
needsRedeployment,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -324,14 +284,12 @@ export function DeployModal({
|
||||
fetchDeploymentInfo()
|
||||
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
|
||||
|
||||
const onDeploy = async (data: DeployFormValues) => {
|
||||
const onDeploy = async () => {
|
||||
setApiDeployError(null)
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
|
||||
const apiKeyToUse = data.apiKey || selectedApiKeyId
|
||||
|
||||
let deployEndpoint = `/api/workflows/${workflowId}/deploy`
|
||||
if (versionToActivate !== null) {
|
||||
deployEndpoint = `/api/workflows/${workflowId}/deployments/${versionToActivate}/activate`
|
||||
@@ -343,7 +301,6 @@ export function DeployModal({
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: apiKeyToUse,
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
@@ -358,14 +315,9 @@ export function DeployModal({
|
||||
const isActivating = versionToActivate !== null
|
||||
const isDeployedStatus = isActivating ? true : (responseData.isDeployed ?? false)
|
||||
const deployedAtTime = responseData.deployedAt ? new Date(responseData.deployedAt) : undefined
|
||||
const apiKeyFromResponse = responseData.apiKey || apiKeyToUse
|
||||
const apiKeyLabel = getApiKeyLabel(responseData.apiKey)
|
||||
|
||||
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyFromResponse)
|
||||
|
||||
const matchingKey = apiKeys.find((k) => k.key === apiKeyFromResponse || k.id === apiKeyToUse)
|
||||
if (matchingKey) {
|
||||
setSelectedApiKeyId(matchingKey.id)
|
||||
}
|
||||
setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, apiKeyLabel)
|
||||
|
||||
const isActivatingVersion = versionToActivate !== null
|
||||
setNeedsRedeployment(isActivatingVersion)
|
||||
@@ -381,13 +333,14 @@ export function DeployModal({
|
||||
const deploymentData = await deploymentInfoResponse.json()
|
||||
const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
const placeholderKey = getApiHeaderPlaceholder()
|
||||
|
||||
setDeploymentInfo({
|
||||
isDeployed: deploymentData.isDeployed,
|
||||
deployedAt: deploymentData.deployedAt,
|
||||
apiKey: deploymentData.apiKey,
|
||||
apiKey: getApiKeyLabel(deploymentData.apiKey),
|
||||
endpoint: apiEndpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
|
||||
needsRedeployment: isActivatingVersion,
|
||||
})
|
||||
}
|
||||
@@ -543,7 +496,7 @@ export function DeployModal({
|
||||
workflowId,
|
||||
newDeployStatus,
|
||||
deployedAt ? new Date(deployedAt) : undefined,
|
||||
apiKey
|
||||
getApiKeyLabel(apiKey)
|
||||
)
|
||||
|
||||
setNeedsRedeployment(false)
|
||||
@@ -573,7 +526,7 @@ export function DeployModal({
|
||||
|
||||
const isActivating = versionToActivate !== null
|
||||
|
||||
setDeploymentStatus(workflowId, true, new Date())
|
||||
setDeploymentStatus(workflowId, true, new Date(), getApiKeyLabel())
|
||||
|
||||
const deploymentInfoResponse = await fetch(`/api/workflows/${workflowId}/deploy`)
|
||||
if (deploymentInfoResponse.ok) {
|
||||
@@ -581,12 +534,14 @@ export function DeployModal({
|
||||
const apiEndpoint = `${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`
|
||||
const inputFormatExample = getInputFormatExample(selectedStreamingOutputs.length > 0)
|
||||
|
||||
const placeholderKey = getApiHeaderPlaceholder()
|
||||
|
||||
setDeploymentInfo({
|
||||
isDeployed: deploymentData.isDeployed,
|
||||
deployedAt: deploymentData.deployedAt,
|
||||
apiKey: deploymentData.apiKey,
|
||||
apiKey: getApiKeyLabel(deploymentData.apiKey),
|
||||
endpoint: apiEndpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${deploymentData.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${apiEndpoint}`,
|
||||
needsRedeployment: isActivating,
|
||||
})
|
||||
}
|
||||
@@ -679,72 +634,67 @@ export function DeployModal({
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
<div className='p-6' key={`${activeTab}-${versionToActivate}`}>
|
||||
{activeTab === 'api' && (
|
||||
<>
|
||||
{versionToActivate !== null ? (
|
||||
<>
|
||||
{apiDeployError && (
|
||||
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
|
||||
<div className='font-semibold'>API Deployment Error</div>
|
||||
<div>{apiDeployError}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='-mx-1 px-1'>
|
||||
<DeployForm
|
||||
apiKeys={apiKeys}
|
||||
selectedApiKeyId={selectedApiKeyId}
|
||||
onApiKeyChange={setSelectedApiKeyId}
|
||||
onSubmit={onDeploy}
|
||||
onApiKeyCreated={fetchApiKeys}
|
||||
formId='deploy-api-form'
|
||||
isDeployed={false}
|
||||
deployedApiKeyDisplay={undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : isDeployed ? (
|
||||
<>
|
||||
<DeploymentInfo
|
||||
isLoading={isLoading}
|
||||
deploymentInfo={
|
||||
deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
|
||||
}
|
||||
onRedeploy={handleRedeploy}
|
||||
onUndeploy={handleUndeploy}
|
||||
isSubmitting={isSubmitting}
|
||||
isUndeploying={isUndeploying}
|
||||
workflowId={workflowId}
|
||||
deployedState={deployedState}
|
||||
isLoadingDeployedState={isLoadingDeployedState}
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
selectedStreamingOutputs={selectedStreamingOutputs}
|
||||
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{apiDeployError && (
|
||||
<div className='mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
|
||||
<div className='font-semibold'>API Deployment Error</div>
|
||||
<div>{apiDeployError}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='-mx-1 px-1'>
|
||||
<DeployForm
|
||||
apiKeys={apiKeys}
|
||||
selectedApiKeyId={selectedApiKeyId}
|
||||
onApiKeyChange={setSelectedApiKeyId}
|
||||
onSubmit={onDeploy}
|
||||
onApiKeyCreated={fetchApiKeys}
|
||||
formId='deploy-api-form'
|
||||
isDeployed={false}
|
||||
deployedApiKeyDisplay={undefined}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className='space-y-4'>
|
||||
{apiDeployError && (
|
||||
<div className='rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive text-sm'>
|
||||
<div className='font-semibold'>API Deployment Error</div>
|
||||
<div>{apiDeployError}</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
{versionToActivate !== null ? (
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-md border bg-muted/40 p-4 text-muted-foreground text-sm'>
|
||||
{`Deploy version ${
|
||||
versions.find((v) => v.version === versionToActivate)?.name ||
|
||||
`v${versionToActivate}`
|
||||
} to production.`}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
onClick={onDeploy}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[var(--brand-primary-hover-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hover-hex)] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
Deploying...
|
||||
</>
|
||||
) : (
|
||||
'Deploy version'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant='outline' onClick={() => setVersionToActivate(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<DeploymentInfo
|
||||
isLoading={isLoading}
|
||||
deploymentInfo={
|
||||
deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
|
||||
}
|
||||
onRedeploy={handleRedeploy}
|
||||
onUndeploy={handleUndeploy}
|
||||
isSubmitting={isSubmitting}
|
||||
isUndeploying={isUndeploying}
|
||||
workflowId={workflowId}
|
||||
deployedState={deployedState}
|
||||
isLoadingDeployedState={isLoadingDeployedState}
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
selectedStreamingOutputs={selectedStreamingOutputs}
|
||||
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'versions' && (
|
||||
@@ -915,57 +865,23 @@ export function DeployModal({
|
||||
)}
|
||||
|
||||
{activeTab === 'chat' && (
|
||||
<>
|
||||
<ChatDeploy
|
||||
workflowId={workflowId || ''}
|
||||
deploymentInfo={deploymentInfo}
|
||||
onChatExistsChange={setChatExists}
|
||||
chatSubmitting={chatSubmitting}
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
onDeploymentComplete={handleCloseModal}
|
||||
onDeployed={handlePostDeploymentUpdate}
|
||||
onUndeploy={handleUndeploy}
|
||||
onVersionActivated={() => setVersionToActivate(null)}
|
||||
/>
|
||||
</>
|
||||
<ChatDeploy
|
||||
workflowId={workflowId || ''}
|
||||
deploymentInfo={deploymentInfo}
|
||||
onChatExistsChange={setChatExists}
|
||||
chatSubmitting={chatSubmitting}
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
onDeploymentComplete={handleCloseModal}
|
||||
onDeployed={handlePostDeploymentUpdate}
|
||||
onUndeploy={handleUndeploy}
|
||||
onVersionActivated={() => setVersionToActivate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'api' && (versionToActivate !== null || !isDeployed) && (
|
||||
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
|
||||
<Button variant='outline' onClick={handleCloseModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
form='deploy-api-form'
|
||||
disabled={isSubmitting || !apiKeys.length}
|
||||
className={cn(
|
||||
'gap-2 font-medium',
|
||||
'bg-[var(--brand-primary-hover-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
|
||||
'shadow-[0_0_0_0_var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
||||
'text-white transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:hover:bg-[var(--brand-primary-hover-hex)] disabled:hover:shadow-none'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
Deploying...
|
||||
</>
|
||||
) : versionToActivate !== null ? (
|
||||
`Deploy ${versions.find((v) => v.version === versionToActivate)?.name || `v${versionToActivate}`}`
|
||||
) : (
|
||||
'Deploy API'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'chat' && (
|
||||
<div className='flex flex-shrink-0 justify-between border-t px-6 py-4'>
|
||||
<Button variant='outline' onClick={handleCloseModal}>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function DeploymentControls({
|
||||
const workflowNeedsRedeployment = needsRedeployment
|
||||
const isPreviousVersionActive = isDeployed && workflowNeedsRedeployment
|
||||
|
||||
const [isDeploying, _setIsDeploying] = useState(false)
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const lastWorkflowIdRef = useRef<string | null>(null)
|
||||
@@ -59,11 +59,52 @@ export function DeploymentControls({
|
||||
const canDeploy = userPermissions.canAdmin
|
||||
const isDisabled = isDeploying || !canDeploy
|
||||
|
||||
const handleDeployClick = useCallback(() => {
|
||||
if (canDeploy) {
|
||||
setIsModalOpen(true)
|
||||
const handleDeployClick = useCallback(async () => {
|
||||
if (!canDeploy || !activeWorkflowId) return
|
||||
|
||||
// If undeployed, deploy first then open modal
|
||||
if (!isDeployed) {
|
||||
setIsDeploying(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${activeWorkflowId}/deploy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deployChatEnabled: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const responseData = await response.json()
|
||||
const setDeploymentStatus = useWorkflowRegistry.getState().setDeploymentStatus
|
||||
const isDeployedStatus = responseData.isDeployed ?? false
|
||||
const deployedAtTime = responseData.deployedAt
|
||||
? new Date(responseData.deployedAt)
|
||||
: undefined
|
||||
setDeploymentStatus(
|
||||
activeWorkflowId,
|
||||
isDeployedStatus,
|
||||
deployedAtTime,
|
||||
responseData.apiKey || ''
|
||||
)
|
||||
await refetchWithErrorHandling()
|
||||
// Open modal after successful deployment
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
} catch (error) {
|
||||
// On error, still open modal to show error
|
||||
setIsModalOpen(true)
|
||||
} finally {
|
||||
setIsDeploying(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}, [canDeploy, setIsModalOpen])
|
||||
|
||||
// If already deployed, just open modal
|
||||
setIsModalOpen(true)
|
||||
}, [canDeploy, isDeployed, activeWorkflowId, refetchWithErrorHandling])
|
||||
|
||||
const getTooltipText = () => {
|
||||
if (!canDeploy) {
|
||||
|
||||
@@ -1151,7 +1151,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
// Get and sort regular workflows by creation date (newest first) for stable ordering
|
||||
const regularWorkflows = Object.values(workflows)
|
||||
.filter((workflow) => workflow.workspaceId === workspaceId)
|
||||
.filter((workflow) => workflow.marketplaceData?.status !== 'temp')
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
|
||||
// Group workflows by folder
|
||||
|
||||
@@ -368,14 +368,12 @@ function useDragHandlers(
|
||||
|
||||
interface FolderTreeProps {
|
||||
regularWorkflows: WorkflowMetadata[]
|
||||
marketplaceWorkflows: WorkflowMetadata[]
|
||||
isLoading?: boolean
|
||||
onCreateWorkflow: (folderId?: string) => void
|
||||
}
|
||||
|
||||
export function FolderTree({
|
||||
regularWorkflows,
|
||||
marketplaceWorkflows,
|
||||
isLoading = false,
|
||||
onCreateWorkflow,
|
||||
}: FolderTreeProps) {
|
||||
@@ -565,15 +563,12 @@ export function FolderTree({
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{!showLoading &&
|
||||
regularWorkflows.length === 0 &&
|
||||
marketplaceWorkflows.length === 0 &&
|
||||
folderTree.length === 0 && (
|
||||
<div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'>
|
||||
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create
|
||||
one to get started.
|
||||
</div>
|
||||
)}
|
||||
{!showLoading && regularWorkflows.length === 0 && folderTree.length === 0 && (
|
||||
<div className='break-words px-2 py-1.5 pr-12 text-muted-foreground text-xs'>
|
||||
No workflows or folders in {workspaceId ? 'this workspace' : 'your account'}. Create
|
||||
one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Copy, Plus, Search } from 'lucide-react'
|
||||
import { Check, Copy, Info, Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -17,6 +17,8 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -58,7 +60,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
|
||||
// State for both workspace and personal keys
|
||||
const [workspaceKeys, setWorkspaceKeys] = useState<ApiKey[]>([])
|
||||
@@ -79,6 +81,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
const [billedAccountUserId, setBilledAccountUserId] = useState<string | null>(null)
|
||||
const [allowPersonalApiKeys, setAllowPersonalApiKeys] = useState<boolean>(true)
|
||||
const [workspaceAdmins, setWorkspaceAdmins] = useState<
|
||||
Array<{ userId: string; name: string; email: string; permissionType: string }>
|
||||
>([])
|
||||
const [workspaceSettingsLoading, setWorkspaceSettingsLoading] = useState<boolean>(true)
|
||||
const [workspaceSettingsUpdating, setWorkspaceSettingsUpdating] = useState<boolean>(false)
|
||||
|
||||
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
|
||||
const createButtonDisabled =
|
||||
workspaceSettingsLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredWorkspaceKeys = useMemo(() => {
|
||||
@@ -147,6 +161,75 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWorkspaceSettings = async () => {
|
||||
if (!workspaceId) return
|
||||
|
||||
setWorkspaceSettingsLoading(true)
|
||||
try {
|
||||
const [workspaceResponse, permissionsResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}`),
|
||||
fetch(`/api/workspaces/${workspaceId}/permissions`),
|
||||
])
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const data = await workspaceResponse.json()
|
||||
const workspaceData = data.workspace ?? {}
|
||||
setBilledAccountUserId(workspaceData.billedAccountUserId ?? null)
|
||||
setAllowPersonalApiKeys(
|
||||
workspaceData.allowPersonalApiKeys === undefined
|
||||
? true
|
||||
: Boolean(workspaceData.allowPersonalApiKeys)
|
||||
)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace details', { status: workspaceResponse.status })
|
||||
}
|
||||
|
||||
if (permissionsResponse.ok) {
|
||||
const data = await permissionsResponse.json()
|
||||
const users = Array.isArray(data.users) ? data.users : []
|
||||
const admins = users.filter((user: any) => user.permissionType === 'admin')
|
||||
setWorkspaceAdmins(admins)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace permissions', {
|
||||
status: permissionsResponse.status,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workspace settings:', { error })
|
||||
} finally {
|
||||
setWorkspaceSettingsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateWorkspaceSettings = async (updates: {
|
||||
billedAccountUserId?: string
|
||||
allowPersonalApiKeys?: boolean
|
||||
}) => {
|
||||
if (!workspaceId) return
|
||||
setWorkspaceSettingsUpdating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to update workspace settings')
|
||||
}
|
||||
|
||||
await fetchWorkspaceSettings()
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace settings:', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setWorkspaceSettingsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!userId || !newKeyName.trim()) return
|
||||
|
||||
@@ -281,6 +364,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
}
|
||||
}, [registerCloseHandler])
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchWorkspaceSettings()
|
||||
}
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowPersonalApiKeys && keyType === 'personal') {
|
||||
setKeyType('workspace')
|
||||
}
|
||||
}, [allowPersonalApiKeys, keyType])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
@@ -303,7 +398,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
return (
|
||||
<div className='relative flex h-full flex-col'>
|
||||
{/* Fixed Header */}
|
||||
<div className='px-6 pt-4 pb-2'>
|
||||
<div className='px-6 pt-2 pb-2'>
|
||||
{/* Search Input */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-9 w-56 rounded-lg' />
|
||||
@@ -338,6 +433,50 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Allow Personal API Keys Toggle */}
|
||||
{!searchTerm.trim() && (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-[12px] text-foreground'>
|
||||
Allow personal API keys
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-full p-1 text-muted-foreground transition hover:text-foreground'
|
||||
>
|
||||
<Info className='h-3 w-3' strokeWidth={2} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs text-xs'>
|
||||
Allow collaborators to create and use their own keys with billing charged
|
||||
to them.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{workspaceSettingsLoading ? (
|
||||
<Skeleton className='h-5 w-16 rounded-full' />
|
||||
) : (
|
||||
<Switch
|
||||
checked={allowPersonalApiKeys}
|
||||
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
|
||||
onCheckedChange={async (checked) => {
|
||||
const previous = allowPersonalApiKeys
|
||||
setAllowPersonalApiKeys(checked)
|
||||
try {
|
||||
await updateWorkspaceSettings({ allowPersonalApiKeys: checked })
|
||||
} catch (error) {
|
||||
setAllowPersonalApiKeys(previous)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Workspace section */}
|
||||
{!searchTerm.trim() ? (
|
||||
<div className='mb-6 space-y-2'>
|
||||
@@ -468,27 +607,26 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
|
||||
{/* Footer */}
|
||||
<div className='bg-background'>
|
||||
<div className='flex w-full items-center justify-between px-6 py-4'>
|
||||
<div className='flex w-full items-center px-6 py-4'>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
|
||||
<div className='w-[108px]' />
|
||||
</>
|
||||
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsCreateDialogOpen(true)
|
||||
setKeyType('personal')
|
||||
setCreateError(null)
|
||||
}}
|
||||
variant='ghost'
|
||||
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
>
|
||||
<Plus className='h-4 w-4 stroke-[2px]' />
|
||||
Create Key
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (createButtonDisabled) {
|
||||
return
|
||||
}
|
||||
setIsCreateDialogOpen(true)
|
||||
setKeyType(defaultKeyType)
|
||||
setCreateError(null)
|
||||
}}
|
||||
variant='ghost'
|
||||
disabled={createButtonDisabled}
|
||||
className='h-8 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
>
|
||||
<Plus className='h-4 w-4 stroke-[2px]' />
|
||||
Create Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -518,7 +656,8 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
setKeyType('personal')
|
||||
if (createError) setCreateError(null)
|
||||
}}
|
||||
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
|
||||
disabled={!allowPersonalApiKeys}
|
||||
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80 disabled:opacity-60 disabled:cursor-not-allowed'
|
||||
>
|
||||
Personal
|
||||
</Button>
|
||||
@@ -560,7 +699,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
className='h-9 w-full rounded-[8px] border-border bg-background text-foreground hover:bg-muted dark:border-border dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
|
||||
onClick={() => {
|
||||
setNewKeyName('')
|
||||
setKeyType('personal')
|
||||
setKeyType(defaultKeyType)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession, useSubscription } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
@@ -30,6 +29,7 @@ interface CancelSubscriptionProps {
|
||||
}
|
||||
subscriptionData?: {
|
||||
periodEnd?: Date | null
|
||||
cancelAtPeriodEnd?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,35 +127,48 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
const activeOrgId = activeOrganization?.id
|
||||
|
||||
// For team/enterprise plans, get the subscription ID from organization store
|
||||
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
|
||||
const orgSubscription = useOrganizationStore.getState().subscriptionData
|
||||
|
||||
if (orgSubscription?.id && orgSubscription?.cancelAtPeriodEnd) {
|
||||
// Restore the organization subscription
|
||||
if (!betterAuthSubscription.restore) {
|
||||
throw new Error('Subscription restore not available')
|
||||
}
|
||||
|
||||
const result = await betterAuthSubscription.restore({
|
||||
referenceId: activeOrgId,
|
||||
subscriptionId: orgSubscription.id,
|
||||
})
|
||||
logger.info('Organization subscription restored successfully', result)
|
||||
if (isCancelAtPeriodEnd) {
|
||||
if (!betterAuthSubscription.restore) {
|
||||
throw new Error('Subscription restore not available')
|
||||
}
|
||||
|
||||
let referenceId: string
|
||||
let subscriptionId: string | undefined
|
||||
|
||||
if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) {
|
||||
const orgSubscription = useOrganizationStore.getState().subscriptionData
|
||||
referenceId = activeOrgId
|
||||
subscriptionId = orgSubscription?.id
|
||||
} else {
|
||||
// For personal subscriptions, use user ID and let better-auth find the subscription
|
||||
referenceId = session.user.id
|
||||
subscriptionId = undefined
|
||||
}
|
||||
|
||||
logger.info('Restoring subscription', { referenceId, subscriptionId })
|
||||
|
||||
// Build restore params - only include subscriptionId if we have one (team/enterprise)
|
||||
const restoreParams: any = { referenceId }
|
||||
if (subscriptionId) {
|
||||
restoreParams.subscriptionId = subscriptionId
|
||||
}
|
||||
|
||||
const result = await betterAuthSubscription.restore(restoreParams)
|
||||
|
||||
logger.info('Subscription restored successfully', result)
|
||||
}
|
||||
|
||||
// Refresh state and close
|
||||
await refresh()
|
||||
if (activeOrgId) {
|
||||
await loadOrganizationSubscription(activeOrgId)
|
||||
await refreshOrganization().catch(() => {})
|
||||
}
|
||||
|
||||
setIsDialogOpen(false)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to keep subscription'
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to restore subscription'
|
||||
setError(errorMessage)
|
||||
logger.error('Failed to keep subscription', { error })
|
||||
logger.error('Failed to restore subscription', { error })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -190,19 +203,15 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
const periodEndDate = getPeriodEndDate()
|
||||
|
||||
// Check if subscription is set to cancel at period end
|
||||
const isCancelAtPeriodEnd = (() => {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) {
|
||||
return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true
|
||||
}
|
||||
return false
|
||||
})()
|
||||
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<span className='font-medium text-sm'>Manage Subscription</span>
|
||||
<span className='font-medium text-sm'>
|
||||
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
|
||||
</span>
|
||||
{isCancelAtPeriodEnd && (
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
You'll keep access until {formatDate(periodEndDate)}
|
||||
@@ -217,10 +226,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
|
||||
error
|
||||
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
|
||||
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
|
||||
: isCancelAtPeriodEnd
|
||||
? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500'
|
||||
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
|
||||
)}
|
||||
>
|
||||
{error ? 'Error' : 'Manage'}
|
||||
{error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -228,11 +239,11 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{isCancelAtPeriodEnd ? 'Manage' : 'Cancel'} {subscription.plan} subscription?
|
||||
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} subscription?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isCancelAtPeriodEnd
|
||||
? 'Your subscription is set to cancel at the end of the billing period. You can reactivate it or manage other settings.'
|
||||
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
||||
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
||||
periodEndDate
|
||||
)}, then downgrade to free plan.`}{' '}
|
||||
@@ -260,38 +271,23 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel
|
||||
className='h-9 w-full rounded-[8px]'
|
||||
onClick={handleKeep}
|
||||
onClick={isCancelAtPeriodEnd ? () => setIsDialogOpen(false) : handleKeep}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Keep Subscription
|
||||
{isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'}
|
||||
</AlertDialogCancel>
|
||||
|
||||
{(() => {
|
||||
const subscriptionStatus = getSubscriptionStatus()
|
||||
if (
|
||||
subscriptionStatus.isPaid &&
|
||||
(activeOrganization?.id
|
||||
? useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd
|
||||
: false)
|
||||
) {
|
||||
if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className='w-full'>
|
||||
<AlertDialogAction
|
||||
disabled
|
||||
className='h-9 w-full cursor-not-allowed rounded-[8px] bg-muted text-muted-foreground opacity-50'
|
||||
>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p>Subscription will be cancelled at end of billing period</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<AlertDialogAction
|
||||
onClick={handleKeep}
|
||||
className='h-9 w-full rounded-[8px] bg-green-500 text-white transition-all duration-200 hover:bg-green-600 dark:bg-green-500 dark:hover:bg-green-600'
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Restoring...' : 'Restore Subscription'}
|
||||
</AlertDialogAction>
|
||||
)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Skeleton, Switch } from '@/components/ui'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/shared/usage-header'
|
||||
import {
|
||||
CancelSubscription,
|
||||
@@ -175,6 +187,11 @@ const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() +
|
||||
export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const { data: session } = useSession()
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
@@ -191,6 +208,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
|
||||
const usageLimitRef = useRef<UsageLimitRef | null>(null)
|
||||
|
||||
// Workspace billing state
|
||||
const [billedAccountUserId, setBilledAccountUserId] = useState<string | null>(null)
|
||||
const [workspaceAdmins, setWorkspaceAdmins] = useState<
|
||||
Array<{ userId: string; name: string; email: string; permissionType: string }>
|
||||
>([])
|
||||
const [workspaceSettingsLoading, setWorkspaceSettingsLoading] = useState<boolean>(true)
|
||||
const [workspaceSettingsUpdating, setWorkspaceSettingsUpdating] = useState<boolean>(false)
|
||||
|
||||
// Get real subscription data from store
|
||||
const subscription = getSubscriptionStatus()
|
||||
const usage = getUsage()
|
||||
@@ -203,6 +228,77 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
}, [activeOrgId, subscription.isTeam, subscription.isEnterprise, loadOrganizationBillingData])
|
||||
|
||||
// Fetch workspace billing settings
|
||||
const fetchWorkspaceSettings = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
|
||||
setWorkspaceSettingsLoading(true)
|
||||
try {
|
||||
const [workspaceResponse, permissionsResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}`),
|
||||
fetch(`/api/workspaces/${workspaceId}/permissions`),
|
||||
])
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const data = await workspaceResponse.json()
|
||||
const workspaceData = data.workspace ?? {}
|
||||
setBilledAccountUserId(workspaceData.billedAccountUserId ?? null)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace details', { status: workspaceResponse.status })
|
||||
}
|
||||
|
||||
if (permissionsResponse.ok) {
|
||||
const data = await permissionsResponse.json()
|
||||
const users = Array.isArray(data.users) ? data.users : []
|
||||
const admins = users.filter((user: any) => user.permissionType === 'admin')
|
||||
setWorkspaceAdmins(admins)
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace permissions', {
|
||||
status: permissionsResponse.status,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching workspace settings:', { error })
|
||||
} finally {
|
||||
setWorkspaceSettingsLoading(false)
|
||||
}
|
||||
}, [workspaceId, logger])
|
||||
|
||||
const updateWorkspaceSettings = async (updates: { billedAccountUserId?: string }) => {
|
||||
if (!workspaceId) return
|
||||
setWorkspaceSettingsUpdating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/workspaces/${workspaceId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to update workspace settings')
|
||||
}
|
||||
|
||||
await fetchWorkspaceSettings()
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace settings:', { error })
|
||||
throw error
|
||||
} finally {
|
||||
setWorkspaceSettingsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceId) {
|
||||
fetchWorkspaceSettings()
|
||||
} else {
|
||||
setWorkspaceSettingsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceId])
|
||||
|
||||
// Auto-clear upgrade error
|
||||
useEffect(() => {
|
||||
if (upgradeError) {
|
||||
@@ -540,10 +636,56 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}}
|
||||
subscriptionData={{
|
||||
periodEnd: subscriptionData?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workspace API Billing Settings */}
|
||||
{canManageWorkspaceKeys && (
|
||||
<div className='mt-6 flex items-center justify-between'>
|
||||
<span className='font-medium text-sm'>Billed Account for Workspace</span>
|
||||
{workspaceSettingsLoading ? (
|
||||
<Skeleton className='h-8 w-[200px] rounded-md' />
|
||||
) : workspaceAdmins.length === 0 ? (
|
||||
<div className='rounded-md border border-dashed px-3 py-1.5 text-muted-foreground text-xs'>
|
||||
No admin members available
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={billedAccountUserId ?? ''}
|
||||
onValueChange={async (value) => {
|
||||
if (value === billedAccountUserId) return
|
||||
const previous = billedAccountUserId
|
||||
setBilledAccountUserId(value)
|
||||
try {
|
||||
await updateWorkspaceSettings({ billedAccountUserId: value })
|
||||
} catch (error) {
|
||||
setBilledAccountUserId(previous ?? null)
|
||||
}
|
||||
}}
|
||||
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
|
||||
<SelectValue placeholder='Select admin' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start'>
|
||||
<SelectGroup>
|
||||
<SelectLabel className='px-3 py-1 text-muted-foreground text-[11px] uppercase'>
|
||||
Workspace admins
|
||||
</SelectLabel>
|
||||
{workspaceAdmins.map((admin) => (
|
||||
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
|
||||
{admin.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -40,15 +40,10 @@ function WorkflowItem({ workflow, active, isMarketplace }: WorkflowItemProps) {
|
||||
|
||||
interface WorkflowListProps {
|
||||
regularWorkflows: WorkflowMetadata[]
|
||||
marketplaceWorkflows: WorkflowMetadata[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function WorkflowList({
|
||||
regularWorkflows,
|
||||
marketplaceWorkflows,
|
||||
isLoading = false,
|
||||
}: WorkflowListProps) {
|
||||
export function WorkflowList({ regularWorkflows, isLoading = false }: WorkflowListProps) {
|
||||
const pathname = usePathname()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -70,11 +65,7 @@ export function WorkflowList({
|
||||
}, [])
|
||||
|
||||
// Only show empty state when not loading and user is logged in
|
||||
const showEmptyState =
|
||||
!isLoading &&
|
||||
session?.user &&
|
||||
regularWorkflows.length === 0 &&
|
||||
marketplaceWorkflows.length === 0
|
||||
const showEmptyState = !isLoading && session?.user && regularWorkflows.length === 0
|
||||
|
||||
return (
|
||||
<div className={`space-y-1 ${isLoading ? 'opacity-60' : ''}`}>
|
||||
@@ -91,29 +82,6 @@ export function WorkflowList({
|
||||
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Marketplace Temp Workflows (if any) */}
|
||||
{marketplaceWorkflows.length > 0 && (
|
||||
<div className='mt-2 border-border/30 border-t pt-2'>
|
||||
<h3 className='mb-1 px-2 font-medium text-muted-foreground text-xs'>Marketplace</h3>
|
||||
{marketplaceWorkflows.map((workflow) => (
|
||||
<WorkflowItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
active={pathname === `/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isMarketplace
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmptyState && (
|
||||
<div className='px-2 py-1.5 text-muted-foreground text-xs'>
|
||||
No workflows in {workspaceId ? 'this workspace' : 'your account'}. Create one to get
|
||||
started.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -679,46 +679,34 @@ export function Sidebar() {
|
||||
const [showSearchModal, setShowSearchModal] = useState(false)
|
||||
const [showSubscriptionModal, setShowSubscriptionModal] = useState(false)
|
||||
|
||||
// Separate regular workflows from temporary marketplace workflows
|
||||
const { regularWorkflows, tempWorkflows } = useMemo(() => {
|
||||
// Get workflows for the current workspace
|
||||
const regularWorkflows = useMemo(() => {
|
||||
if (isLoading) return []
|
||||
|
||||
const regular: WorkflowMetadata[] = []
|
||||
const temp: WorkflowMetadata[] = []
|
||||
|
||||
if (!isLoading) {
|
||||
Object.values(workflows).forEach((workflow) => {
|
||||
if (workflow.workspaceId === workspaceId || !workflow.workspaceId) {
|
||||
if (workflow.marketplaceData?.status === 'temp') {
|
||||
temp.push(workflow)
|
||||
} else {
|
||||
regular.push(workflow)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by creation date (newest first) for stable ordering
|
||||
const sortByCreatedAt = (a: WorkflowMetadata, b: WorkflowMetadata) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
Object.values(workflows).forEach((workflow) => {
|
||||
if (workflow.workspaceId === workspaceId || !workflow.workspaceId) {
|
||||
regular.push(workflow)
|
||||
}
|
||||
})
|
||||
|
||||
regular.sort(sortByCreatedAt)
|
||||
temp.sort(sortByCreatedAt)
|
||||
}
|
||||
// Sort by creation date (newest first) for stable ordering
|
||||
regular.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
|
||||
return { regularWorkflows: regular, tempWorkflows: temp }
|
||||
return regular
|
||||
}, [workflows, isLoading, workspaceId])
|
||||
|
||||
// Prepare workflows for search modal
|
||||
const searchWorkflows = useMemo(() => {
|
||||
if (isLoading) return []
|
||||
|
||||
const allWorkflows = [...regularWorkflows, ...tempWorkflows]
|
||||
return allWorkflows.map((workflow) => ({
|
||||
return regularWorkflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
href: `/workspace/${workspaceId}/w/${workflow.id}`,
|
||||
isCurrent: workflow.id === workflowId,
|
||||
}))
|
||||
}, [regularWorkflows, tempWorkflows, workspaceId, workflowId, isLoading])
|
||||
}, [regularWorkflows, workspaceId, workflowId, isLoading])
|
||||
|
||||
// Prepare workspaces for search modal (include all workspaces)
|
||||
const searchWorkspaces = useMemo(() => {
|
||||
@@ -942,7 +930,6 @@ export function Sidebar() {
|
||||
<div ref={workflowScrollAreaRef}>
|
||||
<FolderTree
|
||||
regularWorkflows={regularWorkflows}
|
||||
marketplaceWorkflows={tempWorkflows}
|
||||
isLoading={isLoading}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { task } from '@trigger.dev/sdk'
|
||||
import { Cron } from 'croner'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getApiKeyOwnerUserId } from '@/lib/api-key/service'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { blockExistsInDeployment, loadDeployedWorkflowState } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
@@ -91,11 +91,19 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) {
|
||||
return
|
||||
}
|
||||
|
||||
const actorUserId = await getApiKeyOwnerUserId(workflowRecord.pinnedApiKeyId)
|
||||
let actorUserId: string | null = null
|
||||
|
||||
if (workflowRecord.workspaceId) {
|
||||
actorUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
|
||||
}
|
||||
|
||||
if (!actorUserId) {
|
||||
actorUserId = workflowRecord.userId ?? null
|
||||
}
|
||||
|
||||
if (!actorUserId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Skipping schedule ${payload.scheduleId}: pinned API key required to attribute usage.`
|
||||
`[${requestId}] Skipping schedule ${payload.scheduleId}: unable to resolve billed account.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey as apiKeyTable, workspace } from '@sim/db/schema'
|
||||
import { apiKey as apiKeyTable } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { authenticateApiKey } from '@/lib/api-key/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
|
||||
|
||||
const logger = createLogger('ApiKeyService')
|
||||
|
||||
@@ -36,6 +38,18 @@ export async function authenticateApiKeyFromHeader(
|
||||
}
|
||||
|
||||
try {
|
||||
let workspaceSettings: {
|
||||
billedAccountUserId: string | null
|
||||
allowPersonalApiKeys: boolean
|
||||
} | null = null
|
||||
|
||||
if (options.workspaceId) {
|
||||
workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId)
|
||||
if (!workspaceSettings) {
|
||||
return { success: false, error: 'Workspace not found' }
|
||||
}
|
||||
}
|
||||
|
||||
// Build query based on options
|
||||
let query = db
|
||||
.select({
|
||||
@@ -48,11 +62,6 @@ export async function authenticateApiKeyFromHeader(
|
||||
})
|
||||
.from(apiKeyTable)
|
||||
|
||||
// Add workspace join if needed for workspace keys
|
||||
if (options.workspaceId || options.keyTypes?.includes('workspace')) {
|
||||
query = query.leftJoin(workspace, eq(apiKeyTable.workspaceId, workspace.id)) as any
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
const conditions = []
|
||||
|
||||
@@ -60,10 +69,6 @@ export async function authenticateApiKeyFromHeader(
|
||||
conditions.push(eq(apiKeyTable.userId, options.userId))
|
||||
}
|
||||
|
||||
if (options.workspaceId) {
|
||||
conditions.push(eq(apiKeyTable.workspaceId, options.workspaceId))
|
||||
}
|
||||
|
||||
if (options.keyTypes?.length) {
|
||||
if (options.keyTypes.length === 1) {
|
||||
conditions.push(eq(apiKeyTable.type, options.keyTypes[0]))
|
||||
@@ -78,11 +83,27 @@ export async function authenticateApiKeyFromHeader(
|
||||
|
||||
const keyRecords = await query
|
||||
|
||||
// Filter by keyTypes in memory if multiple types specified
|
||||
const filteredRecords =
|
||||
options.keyTypes?.length && options.keyTypes.length > 1
|
||||
? keyRecords.filter((record) => options.keyTypes!.includes(record.type as any))
|
||||
: keyRecords
|
||||
const filteredRecords = keyRecords.filter((record) => {
|
||||
const keyType = record.type as 'personal' | 'workspace'
|
||||
|
||||
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (options.workspaceId) {
|
||||
if (keyType === 'workspace') {
|
||||
return record.workspaceId === options.workspaceId
|
||||
}
|
||||
|
||||
if (keyType === 'personal') {
|
||||
return workspaceSettings?.allowPersonalApiKeys ?? false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const permissionCache = new Map<string, boolean>()
|
||||
|
||||
// Authenticate each key
|
||||
for (const storedKey of filteredRecords) {
|
||||
@@ -91,6 +112,29 @@ export async function authenticateApiKeyFromHeader(
|
||||
continue
|
||||
}
|
||||
|
||||
if (options.workspaceId && (storedKey.type as 'personal' | 'workspace') === 'personal') {
|
||||
if (!workspaceSettings?.allowPersonalApiKeys) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!storedKey.userId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!permissionCache.has(storedKey.userId)) {
|
||||
const permission = await getUserEntityPermissions(
|
||||
storedKey.userId,
|
||||
'workspace',
|
||||
options.workspaceId
|
||||
)
|
||||
permissionCache.set(storedKey.userId, permission !== null)
|
||||
}
|
||||
|
||||
if (!permissionCache.get(storedKey.userId)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key)
|
||||
if (isValid) {
|
||||
@@ -99,7 +143,7 @@ export async function authenticateApiKeyFromHeader(
|
||||
userId: storedKey.userId,
|
||||
keyId: storedKey.id,
|
||||
keyType: storedKey.type as 'personal' | 'workspace',
|
||||
workspaceId: storedKey.workspaceId || undefined,
|
||||
workspaceId: storedKey.workspaceId || options.workspaceId || undefined,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -125,27 +169,6 @@ export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a pinned API key ID, resolve the owning userId (actor).
|
||||
* Returns null if not found.
|
||||
*/
|
||||
export async function getApiKeyOwnerUserId(
|
||||
pinnedApiKeyId: string | null | undefined
|
||||
): Promise<string | null> {
|
||||
if (!pinnedApiKeyId) return null
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ userId: apiKeyTable.userId })
|
||||
.from(apiKeyTable)
|
||||
.where(eq(apiKeyTable.id, pinnedApiKeyId))
|
||||
.limit(1)
|
||||
return rows[0]?.userId ?? null
|
||||
} catch (error) {
|
||||
logger.error('Error resolving API key owner', { error, pinnedApiKeyId })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API encryption key from the environment
|
||||
* @returns The API encryption key
|
||||
|
||||
@@ -220,6 +220,7 @@ export async function getSimplifiedBillingSummary(
|
||||
metadata: any
|
||||
stripeSubscriptionId: string | null
|
||||
periodEnd: Date | string | null
|
||||
cancelAtPeriodEnd?: boolean
|
||||
// Usage details
|
||||
usage: {
|
||||
current: number
|
||||
@@ -341,6 +342,7 @@ export async function getSimplifiedBillingSummary(
|
||||
metadata: subscription.metadata || null,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId || null,
|
||||
periodEnd: subscription.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined,
|
||||
// Usage details
|
||||
usage: {
|
||||
current: usageData.currentUsage,
|
||||
@@ -463,6 +465,7 @@ export async function getSimplifiedBillingSummary(
|
||||
metadata: subscription?.metadata || null,
|
||||
stripeSubscriptionId: subscription?.stripeSubscriptionId || null,
|
||||
periodEnd: subscription?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || undefined,
|
||||
// Usage details
|
||||
usage: {
|
||||
current: currentUsage,
|
||||
@@ -524,5 +527,14 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
|
||||
daysRemaining: 0,
|
||||
copilotCost: 0,
|
||||
},
|
||||
...(type === 'organization' && {
|
||||
organizationData: {
|
||||
seatCount: 0,
|
||||
memberCount: 0,
|
||||
totalBasePrice: 0,
|
||||
totalCurrentUsage: 0,
|
||||
totalOverage: 0,
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { db, webhook, workflow } from '@sim/db'
|
||||
import { tasks } from '@trigger.dev/sdk'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getApiKeyOwnerUserId } from '@/lib/api-key/service'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { env, isTruthy } from '@/lib/env'
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
validateMicrosoftTeamsSignature,
|
||||
verifyProviderWebhook,
|
||||
} from '@/lib/webhooks/utils'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { executeWebhookJob } from '@/background/webhook-execution'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
|
||||
@@ -26,6 +26,20 @@ export interface WebhookProcessorOptions {
|
||||
executionTarget?: 'deployed' | 'live'
|
||||
}
|
||||
|
||||
async function resolveWorkflowActorUserId(foundWorkflow: {
|
||||
workspaceId?: string | null
|
||||
userId?: string | null
|
||||
}): Promise<string | null> {
|
||||
if (foundWorkflow?.workspaceId) {
|
||||
const billedAccount = await getWorkspaceBilledAccountUserId(foundWorkflow.workspaceId)
|
||||
if (billedAccount) {
|
||||
return billedAccount
|
||||
}
|
||||
}
|
||||
|
||||
return foundWorkflow?.userId ?? null
|
||||
}
|
||||
|
||||
export async function parseWebhookBody(
|
||||
request: NextRequest,
|
||||
requestId: string
|
||||
@@ -269,11 +283,11 @@ export async function checkRateLimits(
|
||||
requestId: string
|
||||
): Promise<NextResponse | null> {
|
||||
try {
|
||||
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId)
|
||||
const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
|
||||
|
||||
if (!actorUserId) {
|
||||
logger.warn(`[${requestId}] Webhook requires pinned API key to attribute usage`)
|
||||
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 })
|
||||
logger.warn(`[${requestId}] Webhook requires a workspace billing account to attribute usage`)
|
||||
return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
|
||||
}
|
||||
|
||||
const userSubscription = await getHighestPrioritySubscription(actorUserId)
|
||||
@@ -327,11 +341,11 @@ export async function checkUsageLimits(
|
||||
}
|
||||
|
||||
try {
|
||||
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId)
|
||||
const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
|
||||
|
||||
if (!actorUserId) {
|
||||
logger.warn(`[${requestId}] Webhook requires pinned API key to attribute usage`)
|
||||
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 })
|
||||
logger.warn(`[${requestId}] Webhook requires a workspace billing account to attribute usage`)
|
||||
return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
|
||||
}
|
||||
|
||||
const usageCheck = await checkServerSideUsageLimits(actorUserId)
|
||||
@@ -376,10 +390,12 @@ export async function queueWebhookExecution(
|
||||
options: WebhookProcessorOptions
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const actorUserId = await getApiKeyOwnerUserId(foundWorkflow.pinnedApiKeyId)
|
||||
const actorUserId = await resolveWorkflowActorUserId(foundWorkflow)
|
||||
if (!actorUserId) {
|
||||
logger.warn(`[${options.requestId}] Webhook requires pinned API key to attribute usage`)
|
||||
return NextResponse.json({ message: 'Pinned API key required' }, { status: 200 })
|
||||
logger.warn(
|
||||
`[${options.requestId}] Webhook requires a workspace billing account to attribute usage`
|
||||
)
|
||||
return NextResponse.json({ message: 'Workspace billing account required' }, { status: 200 })
|
||||
}
|
||||
|
||||
const headers = Object.fromEntries(request.headers.entries())
|
||||
|
||||
@@ -369,8 +369,6 @@ export async function migrateWorkflowToNormalizedTables(
|
||||
export async function deployWorkflow(params: {
|
||||
workflowId: string
|
||||
deployedBy: string // User ID of the person deploying
|
||||
pinnedApiKeyId?: string
|
||||
includeDeployedState?: boolean
|
||||
workflowName?: string
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
@@ -379,13 +377,7 @@ export async function deployWorkflow(params: {
|
||||
currentState?: any
|
||||
error?: string
|
||||
}> {
|
||||
const {
|
||||
workflowId,
|
||||
deployedBy,
|
||||
pinnedApiKeyId,
|
||||
includeDeployedState = false,
|
||||
workflowName,
|
||||
} = params
|
||||
const { workflowId, deployedBy, workflowName } = params
|
||||
|
||||
try {
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
@@ -435,14 +427,6 @@ export async function deployWorkflow(params: {
|
||||
deployedAt: now,
|
||||
}
|
||||
|
||||
if (includeDeployedState) {
|
||||
updateData.deployedState = currentState
|
||||
}
|
||||
|
||||
if (pinnedApiKeyId) {
|
||||
updateData.pinnedApiKeyId = pinnedApiKeyId
|
||||
}
|
||||
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
|
||||
|
||||
return nextVersion
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey, permissions, workflow as workflowTable, workspace } from '@sim/db/schema'
|
||||
import { permissions, workflow as workflowTable, workspace } from '@sim/db/schema'
|
||||
import type { InferSelectModel } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
@@ -12,86 +12,12 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
const WORKFLOW_BASE_SELECTION = {
|
||||
id: workflowTable.id,
|
||||
userId: workflowTable.userId,
|
||||
workspaceId: workflowTable.workspaceId,
|
||||
folderId: workflowTable.folderId,
|
||||
name: workflowTable.name,
|
||||
description: workflowTable.description,
|
||||
color: workflowTable.color,
|
||||
lastSynced: workflowTable.lastSynced,
|
||||
createdAt: workflowTable.createdAt,
|
||||
updatedAt: workflowTable.updatedAt,
|
||||
isDeployed: workflowTable.isDeployed,
|
||||
deployedState: workflowTable.deployedState,
|
||||
deployedAt: workflowTable.deployedAt,
|
||||
pinnedApiKeyId: workflowTable.pinnedApiKeyId,
|
||||
collaborators: workflowTable.collaborators,
|
||||
runCount: workflowTable.runCount,
|
||||
lastRunAt: workflowTable.lastRunAt,
|
||||
variables: workflowTable.variables,
|
||||
isPublished: workflowTable.isPublished,
|
||||
marketplaceData: workflowTable.marketplaceData,
|
||||
pinnedApiKeyKey: apiKey.key,
|
||||
pinnedApiKeyName: apiKey.name,
|
||||
pinnedApiKeyType: apiKey.type,
|
||||
pinnedApiKeyWorkspaceId: apiKey.workspaceId,
|
||||
}
|
||||
|
||||
type WorkflowSelection = InferSelectModel<typeof workflowTable>
|
||||
type ApiKeySelection = InferSelectModel<typeof apiKey>
|
||||
|
||||
type WorkflowRow = WorkflowSelection & {
|
||||
pinnedApiKeyKey: ApiKeySelection['key'] | null
|
||||
pinnedApiKeyName: ApiKeySelection['name'] | null
|
||||
pinnedApiKeyType: ApiKeySelection['type'] | null
|
||||
pinnedApiKeyWorkspaceId: ApiKeySelection['workspaceId'] | null
|
||||
}
|
||||
|
||||
type WorkflowWithPinnedKey = WorkflowSelection & {
|
||||
pinnedApiKey: Pick<ApiKeySelection, 'id' | 'name' | 'key' | 'type' | 'workspaceId'> | null
|
||||
}
|
||||
|
||||
function mapWorkflowRow(row: WorkflowRow | undefined): WorkflowWithPinnedKey | undefined {
|
||||
if (!row) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const {
|
||||
pinnedApiKeyKey,
|
||||
pinnedApiKeyName,
|
||||
pinnedApiKeyType,
|
||||
pinnedApiKeyWorkspaceId,
|
||||
...workflowWithoutDerived
|
||||
} = row
|
||||
|
||||
const pinnedApiKey =
|
||||
workflowWithoutDerived.pinnedApiKeyId && pinnedApiKeyKey && pinnedApiKeyName && pinnedApiKeyType
|
||||
? {
|
||||
id: workflowWithoutDerived.pinnedApiKeyId,
|
||||
name: pinnedApiKeyName,
|
||||
key: pinnedApiKeyKey,
|
||||
type: pinnedApiKeyType,
|
||||
workspaceId: pinnedApiKeyWorkspaceId,
|
||||
}
|
||||
: null
|
||||
|
||||
return {
|
||||
...workflowWithoutDerived,
|
||||
pinnedApiKey,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWorkflowById(id: string) {
|
||||
const rows = await db
|
||||
.select(WORKFLOW_BASE_SELECTION)
|
||||
.from(workflowTable)
|
||||
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
|
||||
.where(eq(workflowTable.id, id))
|
||||
.limit(1)
|
||||
const rows = await db.select().from(workflowTable).where(eq(workflowTable.id, id)).limit(1)
|
||||
|
||||
return mapWorkflowRow(rows[0] as WorkflowRow | undefined)
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
type WorkflowRecord = ReturnType<typeof getWorkflowById> extends Promise<infer R>
|
||||
@@ -110,55 +36,50 @@ export async function getWorkflowAccessContext(
|
||||
workflowId: string,
|
||||
userId?: string
|
||||
): Promise<WorkflowAccessContext | null> {
|
||||
const rows = await db
|
||||
.select({
|
||||
...WORKFLOW_BASE_SELECTION,
|
||||
workspaceOwnerId: workspace.ownerId,
|
||||
workspacePermission: permissions.permissionType,
|
||||
})
|
||||
.from(workflowTable)
|
||||
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
|
||||
.leftJoin(workspace, eq(workspace.id, workflowTable.workspaceId))
|
||||
.leftJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflowTable.workspaceId),
|
||||
userId ? eq(permissions.userId, userId) : eq(permissions.userId, '' as unknown as string)
|
||||
)
|
||||
)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0] as
|
||||
| (WorkflowRow & {
|
||||
workspaceOwnerId: string | null
|
||||
workspacePermission: PermissionType | null
|
||||
})
|
||||
| undefined
|
||||
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
const workflow = mapWorkflowRow(row as WorkflowRow)
|
||||
const workflow = await getWorkflowById(workflowId)
|
||||
|
||||
if (!workflow) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolvedWorkspaceOwner = row.workspaceOwnerId ?? null
|
||||
const resolvedWorkspacePermission = row.workspacePermission ?? null
|
||||
let workspaceOwnerId: string | null = null
|
||||
let workspacePermission: PermissionType | null = null
|
||||
|
||||
if (workflow.workspaceId) {
|
||||
const [workspaceRow] = await db
|
||||
.select({ ownerId: workspace.ownerId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workflow.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
workspaceOwnerId = workspaceRow?.ownerId ?? null
|
||||
|
||||
if (userId) {
|
||||
const [permissionRow] = await db
|
||||
.select({ permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.userId, userId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
workspacePermission = permissionRow?.permissionType ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedUserId = userId ?? null
|
||||
|
||||
const isOwner = resolvedUserId ? workflow.userId === resolvedUserId : false
|
||||
const isWorkspaceOwner = resolvedUserId ? resolvedWorkspaceOwner === resolvedUserId : false
|
||||
const isWorkspaceOwner = resolvedUserId ? workspaceOwnerId === resolvedUserId : false
|
||||
|
||||
return {
|
||||
workflow,
|
||||
workspaceOwnerId: resolvedWorkspaceOwner,
|
||||
workspacePermission: resolvedWorkspacePermission,
|
||||
workspaceOwnerId,
|
||||
workspacePermission,
|
||||
isOwner,
|
||||
isWorkspaceOwner,
|
||||
}
|
||||
|
||||
39
apps/sim/lib/workspaces/utils.ts
Normal file
39
apps/sim/lib/workspaces/utils.ts
Normal 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
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export interface SubscriptionData {
|
||||
metadata: any | null
|
||||
stripeSubscriptionId: string | null
|
||||
periodEnd: Date | null
|
||||
cancelAtPeriodEnd?: boolean
|
||||
usage: UsageData
|
||||
billingBlocked?: boolean
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ export function getWorkflowWithValues(workflowId: string) {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
color: metadata.color || '#3972F6',
|
||||
marketplaceData: metadata.marketplaceData || null,
|
||||
workspaceId: metadata.workspaceId,
|
||||
folderId: metadata.folderId,
|
||||
state: {
|
||||
@@ -126,7 +125,6 @@ export function getAllWorkflowsWithValues() {
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
color: metadata.color || '#3972F6',
|
||||
marketplaceData: metadata.marketplaceData || null,
|
||||
folderId: metadata.folderId,
|
||||
state: {
|
||||
blocks: mergedBlocks,
|
||||
@@ -136,7 +134,6 @@ export function getAllWorkflowsWithValues() {
|
||||
lastSaved: workflowState.lastSaved,
|
||||
isDeployed: workflowState.isDeployed,
|
||||
deployedAt: workflowState.deployedAt,
|
||||
marketplaceData: metadata.marketplaceData || null,
|
||||
},
|
||||
// Include API key if available
|
||||
apiKey,
|
||||
|
||||
@@ -92,7 +92,6 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
|
||||
color,
|
||||
variables,
|
||||
createdAt,
|
||||
marketplaceData,
|
||||
workspaceId,
|
||||
folderId,
|
||||
isDeployed,
|
||||
@@ -108,7 +107,6 @@ async function fetchWorkflowsFromDB(workspaceId?: string): Promise<void> {
|
||||
color: color || '#3972F6',
|
||||
lastModified: createdAt ? new Date(createdAt) : new Date(),
|
||||
createdAt: createdAt ? new Date(createdAt) : new Date(),
|
||||
marketplaceData: marketplaceData || null,
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
}
|
||||
@@ -438,7 +436,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
deployedAt: workflowData.deployedAt ? new Date(workflowData.deployedAt) : undefined,
|
||||
apiKey: workflowData.apiKey,
|
||||
lastSaved: Date.now(),
|
||||
marketplaceData: workflowData.marketplaceData || null,
|
||||
deploymentStatuses: {},
|
||||
}
|
||||
} else {
|
||||
@@ -513,7 +510,7 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
body: JSON.stringify({
|
||||
name: options.name || generateCreativeWorkflowName(),
|
||||
description: options.description || 'New workflow',
|
||||
color: options.marketplaceId ? '#808080' : getNextWorkflowColor(),
|
||||
color: getNextWorkflowColor(),
|
||||
workspaceId,
|
||||
folderId: options.folderId || null,
|
||||
}),
|
||||
@@ -537,17 +534,10 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
createdAt: new Date(),
|
||||
description: createdWorkflow.description,
|
||||
color: createdWorkflow.color,
|
||||
marketplaceData: options.marketplaceId
|
||||
? { id: options.marketplaceId, status: 'temp' as const }
|
||||
: undefined,
|
||||
workspaceId,
|
||||
folderId: createdWorkflow.folderId,
|
||||
}
|
||||
|
||||
if (options.marketplaceId && options.marketplaceState) {
|
||||
logger.info(`Created workflow from marketplace: ${options.marketplaceId}`)
|
||||
}
|
||||
|
||||
// Add workflow to registry with server-generated ID
|
||||
set((state) => ({
|
||||
workflows: {
|
||||
@@ -557,26 +547,16 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
error: null,
|
||||
}))
|
||||
|
||||
// Initialize subblock values if this is a marketplace import
|
||||
if (options.marketplaceId && options.marketplaceState?.blocks) {
|
||||
useSubBlockStore
|
||||
.getState()
|
||||
.initializeFromWorkflow(serverWorkflowId, options.marketplaceState.blocks)
|
||||
}
|
||||
|
||||
// Initialize subblock values to ensure they're available for sync
|
||||
if (!options.marketplaceId) {
|
||||
// For non-marketplace workflows, initialize empty subblock values
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
const subblockValues: Record<string, Record<string, any>> = {}
|
||||
|
||||
// Update the subblock store with the initial values
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[serverWorkflowId]: subblockValues,
|
||||
},
|
||||
}))
|
||||
}
|
||||
// Update the subblock store with the initial values
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[serverWorkflowId]: subblockValues,
|
||||
},
|
||||
}))
|
||||
|
||||
// Don't set as active workflow here - let the navigation/URL change handle that
|
||||
// This prevents race conditions and flickering
|
||||
@@ -594,107 +574,6 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a new workflow from a marketplace workflow
|
||||
*/
|
||||
createMarketplaceWorkflow: async (
|
||||
marketplaceId: string,
|
||||
state: any,
|
||||
metadata: Partial<WorkflowMetadata>
|
||||
) => {
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Generate workflow metadata with marketplace properties
|
||||
const newWorkflow: WorkflowMetadata = {
|
||||
id,
|
||||
name: metadata.name || generateCreativeWorkflowName(),
|
||||
lastModified: new Date(),
|
||||
createdAt: new Date(),
|
||||
description: metadata.description || 'Imported from marketplace',
|
||||
color: metadata.color || getNextWorkflowColor(),
|
||||
marketplaceData: { id: marketplaceId, status: 'temp' as const },
|
||||
}
|
||||
|
||||
// Prepare workflow state based on the marketplace workflow state
|
||||
const initialState = {
|
||||
blocks: state.blocks || {},
|
||||
edges: state.edges || [],
|
||||
loops: state.loops || {},
|
||||
parallels: state.parallels || {},
|
||||
isDeployed: false,
|
||||
deployedAt: undefined,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
// Add workflow to registry
|
||||
set((state) => ({
|
||||
workflows: {
|
||||
...state.workflows,
|
||||
[id]: newWorkflow,
|
||||
},
|
||||
error: null,
|
||||
}))
|
||||
|
||||
// Initialize subblock values from state blocks
|
||||
if (state.blocks) {
|
||||
useSubBlockStore.getState().initializeFromWorkflow(id, state.blocks)
|
||||
}
|
||||
|
||||
// Set as active workflow and update store
|
||||
set({ activeWorkflowId: id })
|
||||
useWorkflowStore.setState(initialState)
|
||||
|
||||
// Immediately persist the marketplace workflow to the database
|
||||
const persistWorkflow = async () => {
|
||||
try {
|
||||
const workflowData = {
|
||||
[id]: {
|
||||
id,
|
||||
name: newWorkflow.name,
|
||||
description: newWorkflow.description,
|
||||
color: newWorkflow.color,
|
||||
state: initialState,
|
||||
marketplaceData: newWorkflow.marketplaceData,
|
||||
workspaceId: newWorkflow.workspaceId,
|
||||
folderId: newWorkflow.folderId,
|
||||
},
|
||||
}
|
||||
|
||||
const response = await fetch('/api/workflows', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workflows: workflowData,
|
||||
workspaceId: newWorkflow.workspaceId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to persist workflow: ${response.statusText}`)
|
||||
}
|
||||
|
||||
logger.info(`Successfully persisted marketplace workflow ${id} to database`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to persist marketplace workflow ${id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist synchronously to ensure workflow exists before Socket.IO operations
|
||||
try {
|
||||
await persistWorkflow()
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Critical: Failed to persist marketplace workflow ${id}, Socket.IO operations may fail:`,
|
||||
error
|
||||
)
|
||||
// Don't throw - allow workflow creation to continue in memory
|
||||
}
|
||||
|
||||
logger.info(`Created marketplace workflow ${id} imported from ${marketplaceId}`)
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
/**
|
||||
* Duplicates an existing workflow
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export interface MarketplaceData {
|
||||
id: string // Marketplace entry ID to track original marketplace source
|
||||
status: 'owner' | 'temp'
|
||||
}
|
||||
|
||||
export interface DeploymentStatus {
|
||||
isDeployed: boolean
|
||||
deployedAt?: Date
|
||||
@@ -17,7 +12,6 @@ export interface WorkflowMetadata {
|
||||
createdAt: Date
|
||||
description?: string
|
||||
color: string
|
||||
marketplaceData?: MarketplaceData | null
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
}
|
||||
@@ -39,18 +33,11 @@ export interface WorkflowRegistryActions {
|
||||
updateWorkflow: (id: string, metadata: Partial<WorkflowMetadata>) => Promise<void>
|
||||
createWorkflow: (options?: {
|
||||
isInitial?: boolean
|
||||
marketplaceId?: string
|
||||
marketplaceState?: any
|
||||
name?: string
|
||||
description?: string
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
}) => Promise<string>
|
||||
createMarketplaceWorkflow: (
|
||||
marketplaceId: string,
|
||||
state: any,
|
||||
metadata: Partial<WorkflowMetadata>
|
||||
) => Promise<string>
|
||||
duplicateWorkflow: (sourceId: string) => Promise<string | null>
|
||||
getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null
|
||||
setDeploymentStatus: (
|
||||
|
||||
12
packages/db/migrations/0104_orange_shinobi_shaw.sql
Normal file
12
packages/db/migrations/0104_orange_shinobi_shaw.sql
Normal 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";
|
||||
7245
packages/db/migrations/meta/0104_snapshot.json
Normal file
7245
packages/db/migrations/meta/0104_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -722,6 +722,13 @@
|
||||
"when": 1761845605676,
|
||||
"tag": "0103_careful_harpoon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 104,
|
||||
"version": "7",
|
||||
"when": 1761848118406,
|
||||
"tag": "0104_orange_shinobi_shaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -145,15 +145,10 @@ export const workflow = pgTable(
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
isDeployed: boolean('is_deployed').notNull().default(false),
|
||||
deployedState: json('deployed_state'),
|
||||
deployedAt: timestamp('deployed_at'),
|
||||
pinnedApiKeyId: text('pinned_api_key_id').references(() => apiKey.id, { onDelete: 'set null' }),
|
||||
collaborators: json('collaborators').notNull().default('[]'),
|
||||
runCount: integer('run_count').notNull().default(0),
|
||||
lastRunAt: timestamp('last_run_at'),
|
||||
variables: json('variables').default('{}'),
|
||||
isPublished: boolean('is_published').notNull().default(false),
|
||||
marketplaceData: json('marketplace_data'),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('workflow_user_id_idx').on(table.userId),
|
||||
@@ -727,6 +722,10 @@ export const workspace = pgTable('workspace', {
|
||||
ownerId: text('owner_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
billedAccountUserId: text('billed_account_user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'no action' }),
|
||||
allowPersonalApiKeys: boolean('allow_personal_api_keys').notNull().default(true),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
@@ -158,7 +158,6 @@ class WorkflowExecutionResult:
|
||||
class WorkflowStatus:
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
```
|
||||
|
||||
|
||||
@@ -77,7 +77,6 @@ def status_example():
|
||||
status = client.get_workflow_status("your-workflow-id")
|
||||
print(f"Status: {{\n"
|
||||
f" deployed: {status.is_deployed},\n"
|
||||
f" published: {status.is_published},\n"
|
||||
f" needs_redeployment: {status.needs_redeployment},\n"
|
||||
f" deployed_at: {status.deployed_at}\n"
|
||||
f"}}")
|
||||
|
||||
@@ -42,7 +42,6 @@ class WorkflowStatus:
|
||||
"""Status of a workflow."""
|
||||
is_deployed: bool
|
||||
deployed_at: Optional[str] = None
|
||||
is_published: bool = False
|
||||
needs_redeployment: bool = False
|
||||
|
||||
|
||||
@@ -296,7 +295,6 @@ class SimStudioClient:
|
||||
return WorkflowStatus(
|
||||
is_deployed=status_data.get('isDeployed', False),
|
||||
deployed_at=status_data.get('deployedAt'),
|
||||
is_published=status_data.get('isPublished', False),
|
||||
needs_redeployment=status_data.get('needsRedeployment', False)
|
||||
)
|
||||
|
||||
|
||||
@@ -79,12 +79,10 @@ def test_workflow_status():
|
||||
status = WorkflowStatus(
|
||||
is_deployed=True,
|
||||
deployed_at="2023-01-01T00:00:00Z",
|
||||
is_published=False,
|
||||
needs_redeployment=False
|
||||
)
|
||||
assert status.is_deployed is True
|
||||
assert status.deployed_at == "2023-01-01T00:00:00Z"
|
||||
assert status.is_published is False
|
||||
assert status.needs_redeployment is False
|
||||
|
||||
|
||||
|
||||
@@ -157,7 +157,6 @@ interface WorkflowExecutionResult {
|
||||
interface WorkflowStatus {
|
||||
isDeployed: boolean;
|
||||
deployedAt?: string;
|
||||
isPublished: boolean;
|
||||
needsRedeployment: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -82,7 +82,6 @@ async function statusExample() {
|
||||
const status = await client.getWorkflowStatus('your-workflow-id')
|
||||
console.log('Status:', {
|
||||
deployed: status.isDeployed,
|
||||
published: status.isPublished,
|
||||
needsRedeployment: status.needsRedeployment,
|
||||
deployedAt: status.deployedAt,
|
||||
})
|
||||
|
||||
@@ -72,7 +72,6 @@ describe('SimStudioClient', () => {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
isDeployed: true,
|
||||
deployedAt: '2023-01-01T00:00:00Z',
|
||||
isPublished: false,
|
||||
needsRedeployment: false,
|
||||
}),
|
||||
}
|
||||
@@ -89,7 +88,6 @@ describe('SimStudioClient', () => {
|
||||
json: vi.fn().mockResolvedValue({
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
isPublished: false,
|
||||
needsRedeployment: true,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export interface WorkflowExecutionResult {
|
||||
export interface WorkflowStatus {
|
||||
isDeployed: boolean
|
||||
deployedAt?: string
|
||||
isPublished: boolean
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user