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