mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
feat(api-keys): add workspace level api keys to share with other workspace members, add encryption for api keys (#1323)
* update infra and remove railway
* feat(api-keys): add workspace-level api keys
* encrypt api keys
* Revert "update infra and remove railway"
This reverts commit b23258a5a1.
* reran migrations
* tested workspace keys
* consolidated code
* more consolidation
* cleanup
* consolidate, remove unused code
* add dummy key for ci
* continue with regular path for self-hosted folks that don't have key set
* fix tests
* fix test
* remove tests
* removed ci additions
This commit is contained in:
@@ -12,7 +12,8 @@ BETTER_AUTH_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Security (Required)
|
||||
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate
|
||||
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
|
||||
INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes
|
||||
|
||||
# Email Provider (Optional)
|
||||
# RESEND_API_KEY= # Uncomment and add your key from https://resend.com to send actual emails
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { runs } from '@trigger.dev/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { createErrorResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('TaskStatusAPI')
|
||||
|
||||
@@ -27,14 +25,17 @@ export async function GET(
|
||||
if (!authenticatedUserId) {
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const [apiKeyRecord] = await db
|
||||
.select({ userId: apiKeyTable.userId })
|
||||
.from(apiKeyTable)
|
||||
.where(eq(apiKeyTable.key, apiKeyHeader))
|
||||
.limit(1)
|
||||
|
||||
if (apiKeyRecord) {
|
||||
authenticatedUserId = apiKeyRecord.userId
|
||||
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (authResult.success && authResult.userId) {
|
||||
authenticatedUserId = authResult.userId
|
||||
if (authResult.keyId) {
|
||||
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
|
||||
keyId: authResult.keyId,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateApiKey } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey } from '@/db/schema'
|
||||
|
||||
@@ -19,7 +19,6 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch all API keys for this user
|
||||
const keys = await db
|
||||
.select({
|
||||
id: apiKey.id,
|
||||
@@ -30,13 +29,19 @@ export async function GET(request: NextRequest) {
|
||||
expiresAt: apiKey.expiresAt,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(eq(apiKey.userId, userId))
|
||||
.where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal')))
|
||||
.orderBy(apiKey.createdAt)
|
||||
|
||||
const maskedKeys = keys.map((key) => ({
|
||||
...key,
|
||||
key: key.key,
|
||||
}))
|
||||
const maskedKeys = await Promise.all(
|
||||
keys.map(async (key) => {
|
||||
const displayFormat = await getApiKeyDisplayFormat(key.key)
|
||||
return {
|
||||
...key,
|
||||
key: key.key,
|
||||
displayKey: displayFormat,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ keys: maskedKeys })
|
||||
} catch (error) {
|
||||
@@ -56,33 +61,61 @@ export async function POST(request: NextRequest) {
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the request
|
||||
const { name } = body
|
||||
if (!name || typeof name !== 'string') {
|
||||
const { name: rawName } = body
|
||||
if (!rawName || typeof rawName !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid request. Name is required.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const keyValue = generateApiKey()
|
||||
const name = rawName.trim()
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: 'Name cannot be empty.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(and(eq(apiKey.userId, userId), eq(apiKey.name, name), eq(apiKey.type, 'personal')))
|
||||
.limit(1)
|
||||
|
||||
if (existingKey.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `A personal API key named "${name}" already exists. Please choose a different name.`,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { key: plainKey, encryptedKey } = await createApiKey(true)
|
||||
|
||||
if (!encryptedKey) {
|
||||
throw new Error('Failed to encrypt API key for storage')
|
||||
}
|
||||
|
||||
// Insert the new API key
|
||||
const [newKey] = await db
|
||||
.insert(apiKey)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
workspaceId: null,
|
||||
name,
|
||||
key: keyValue,
|
||||
key: encryptedKey,
|
||||
type: 'personal',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: apiKey.key,
|
||||
createdAt: apiKey.createdAt,
|
||||
})
|
||||
|
||||
return NextResponse.json({ key: newKey })
|
||||
return NextResponse.json({
|
||||
key: {
|
||||
...newKey,
|
||||
key: plainKey,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to create API key', { error })
|
||||
return NextResponse.json({ error: 'Failed to create API key' }, { status: 500 })
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('V1Auth')
|
||||
|
||||
export interface AuthResult {
|
||||
authenticated: boolean
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function authenticateApiKey(request: NextRequest): Promise<AuthResult> {
|
||||
export async function authenticateV1Request(request: NextRequest): Promise<AuthResult> {
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -23,36 +23,23 @@ export async function authenticateApiKey(request: NextRequest): Promise<AuthResu
|
||||
}
|
||||
|
||||
try {
|
||||
const [keyRecord] = await db
|
||||
.select({
|
||||
userId: apiKeyTable.userId,
|
||||
expiresAt: apiKeyTable.expiresAt,
|
||||
})
|
||||
.from(apiKeyTable)
|
||||
.where(eq(apiKeyTable.key, apiKey))
|
||||
.limit(1)
|
||||
const result = await authenticateApiKeyFromHeader(apiKey)
|
||||
|
||||
if (!keyRecord) {
|
||||
if (!result.success) {
|
||||
logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) })
|
||||
return {
|
||||
authenticated: false,
|
||||
error: 'Invalid API key',
|
||||
error: result.error || 'Invalid API key',
|
||||
}
|
||||
}
|
||||
|
||||
if (keyRecord.expiresAt && keyRecord.expiresAt < new Date()) {
|
||||
logger.warn('Expired API key attempted', { userId: keyRecord.userId })
|
||||
return {
|
||||
authenticated: false,
|
||||
error: 'API key expired',
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.key, apiKey))
|
||||
await updateApiKeyLastUsed(result.keyId!)
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
userId: keyRecord.userId,
|
||||
userId: result.userId!,
|
||||
workspaceId: result.workspaceId,
|
||||
keyType: result.keyType,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error', { error })
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { RateLimiter } from '@/services/queue/RateLimiter'
|
||||
import { authenticateApiKey } from './auth'
|
||||
import { authenticateV1Request } from './auth'
|
||||
|
||||
const logger = createLogger('V1Middleware')
|
||||
const rateLimiter = new RateLimiter()
|
||||
@@ -21,7 +21,7 @@ export async function checkRateLimit(
|
||||
endpoint: 'logs' | 'logs-detail' = 'logs'
|
||||
): Promise<RateLimitResult> {
|
||||
try {
|
||||
const auth = await authenticateApiKey(request)
|
||||
const auth = await authenticateV1Request(request)
|
||||
if (!auth.authenticated) {
|
||||
return {
|
||||
allowed: false,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { generateApiKey } from '@/lib/api-key/service'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateApiKey, generateRequestId } from '@/lib/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
@@ -33,7 +34,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
deployedAt: workflow.deployedAt,
|
||||
userId: workflow.userId,
|
||||
deployedState: workflow.deployedState,
|
||||
pinnedApiKey: workflow.pinnedApiKey,
|
||||
pinnedApiKeyId: workflow.pinnedApiKeyId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, id))
|
||||
@@ -57,18 +58,28 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
}
|
||||
|
||||
let userKey: string | null = null
|
||||
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
|
||||
|
||||
if (workflowData.pinnedApiKey) {
|
||||
userKey = workflowData.pinnedApiKey
|
||||
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 {
|
||||
// Fetch the user's API key, preferring the most recently used
|
||||
const userApiKey = await db
|
||||
.select({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
type: apiKey.type,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(eq(apiKey.userId, workflowData.userId))
|
||||
.where(and(eq(apiKey.userId, workflowData.userId), eq(apiKey.type, 'personal')))
|
||||
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
|
||||
.limit(1)
|
||||
|
||||
@@ -76,22 +87,24 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
if (userApiKey.length === 0) {
|
||||
try {
|
||||
const newApiKeyVal = generateApiKey()
|
||||
const keyName = 'Default API Key'
|
||||
await db.insert(apiKey).values({
|
||||
id: uuidv4(),
|
||||
userId: workflowData.userId,
|
||||
name: 'Default API Key',
|
||||
workspaceId: null,
|
||||
name: keyName,
|
||||
key: newApiKeyVal,
|
||||
type: 'personal',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
userKey = newApiKeyVal
|
||||
keyInfo = { name: keyName, type: 'personal' }
|
||||
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
|
||||
} catch (keyError) {
|
||||
// If key generation fails, log the error but continue with the request
|
||||
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
|
||||
}
|
||||
} else {
|
||||
userKey = userApiKey[0].key
|
||||
keyInfo = { name: userApiKey[0].name, type: userApiKey[0].type as 'personal' | 'workspace' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,8 +133,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
|
||||
|
||||
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'No API key found'
|
||||
|
||||
return createSuccessResponse({
|
||||
apiKey: userKey,
|
||||
apiKey: responseApiKeyInfo,
|
||||
isDeployed: workflowData.isDeployed,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
needsRedeployment,
|
||||
@@ -149,7 +165,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const workflowData = await db
|
||||
.select({
|
||||
userId: workflow.userId,
|
||||
pinnedApiKey: workflow.pinnedApiKey,
|
||||
pinnedApiKeyId: workflow.pinnedApiKeyId,
|
||||
})
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, id))
|
||||
@@ -285,12 +301,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
key: apiKey.key,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(eq(apiKey.userId, userId))
|
||||
.where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal')))
|
||||
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
|
||||
.limit(1)
|
||||
|
||||
let userKey = null
|
||||
|
||||
// If no API key exists, create one
|
||||
if (userApiKey.length === 0) {
|
||||
try {
|
||||
@@ -298,30 +312,85 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
await db.insert(apiKey).values({
|
||||
id: uuidv4(),
|
||||
userId,
|
||||
workspaceId: null, // Personal keys must have NULL workspaceId
|
||||
name: 'Default API Key',
|
||||
key: newApiKey,
|
||||
type: 'personal', // Explicitly set type
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
userKey = newApiKey
|
||||
logger.info(`[${requestId}] Generated new API key for user: ${userId}`)
|
||||
} catch (keyError) {
|
||||
// If key generation fails, log the error but continue with the request
|
||||
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
|
||||
}
|
||||
} else {
|
||||
userKey = userApiKey[0].key
|
||||
}
|
||||
|
||||
// If client provided a specific API key and it belongs to the user, prefer it
|
||||
let keyInfo: { name: string; type: 'personal' | 'workspace' } | null = null
|
||||
let matchedKey: {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
type: 'personal' | 'workspace'
|
||||
} | null = null
|
||||
|
||||
if (providedApiKey) {
|
||||
const [owned] = await db
|
||||
.select({ key: apiKey.key })
|
||||
let isValidKey = false
|
||||
|
||||
const [personalKey] = await db
|
||||
.select({ id: apiKey.id, key: apiKey.key, name: apiKey.name, expiresAt: apiKey.expiresAt })
|
||||
.from(apiKey)
|
||||
.where(and(eq(apiKey.userId, userId), eq(apiKey.key, providedApiKey)))
|
||||
.where(
|
||||
and(eq(apiKey.id, providedApiKey), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))
|
||||
)
|
||||
.limit(1)
|
||||
if (owned) {
|
||||
userKey = providedApiKey
|
||||
|
||||
if (personalKey) {
|
||||
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
|
||||
matchedKey = { ...personalKey, type: 'personal' }
|
||||
isValidKey = true
|
||||
keyInfo = { name: personalKey.name, type: 'personal' }
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidKey) {
|
||||
const [workflowData] = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, id))
|
||||
.limit(1)
|
||||
|
||||
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, providedApiKey),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,26 +401,33 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
deployedState: currentState,
|
||||
}
|
||||
// Only pin when the client explicitly provided a key in this request
|
||||
if (providedApiKey) {
|
||||
updateData.pinnedApiKey = userKey
|
||||
if (providedApiKey && keyInfo && matchedKey) {
|
||||
updateData.pinnedApiKeyId = matchedKey.id
|
||||
}
|
||||
|
||||
await db.update(workflow).set(updateData).where(eq(workflow.id, id))
|
||||
|
||||
// Update lastUsed for the key we returned
|
||||
if (userKey) {
|
||||
if (matchedKey) {
|
||||
try {
|
||||
await db
|
||||
.update(apiKey)
|
||||
.set({ lastUsed: new Date(), updatedAt: new Date() })
|
||||
.where(eq(apiKey.key, userKey))
|
||||
.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}`)
|
||||
return createSuccessResponse({ apiKey: userKey, isDeployed: true, deployedAt })
|
||||
|
||||
const responseApiKeyInfo = keyInfo ? `${keyInfo.name} (${keyInfo.type})` : 'Default key'
|
||||
|
||||
return createSuccessResponse({
|
||||
apiKey: responseApiKeyInfo,
|
||||
isDeployed: true,
|
||||
deployedAt,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {
|
||||
error: error.message,
|
||||
@@ -387,6 +463,7 @@ export async function DELETE(
|
||||
isDeployed: false,
|
||||
deployedAt: null,
|
||||
deployedState: null,
|
||||
pinnedApiKeyId: null,
|
||||
})
|
||||
.where(eq(workflow.id, id))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyInternalToken } from '@/lib/auth/internal'
|
||||
import { env } from '@/lib/env'
|
||||
@@ -9,7 +10,7 @@ import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, templates, workflow } from '@/db/schema'
|
||||
import { templates, workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowByIdAPI')
|
||||
|
||||
@@ -31,7 +32,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
// Check for internal JWT token for server-side calls
|
||||
const authHeader = request.headers.get('authorization')
|
||||
let isInternalCall = false
|
||||
|
||||
@@ -43,26 +43,25 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
let userId: string | null = null
|
||||
|
||||
if (isInternalCall) {
|
||||
// For internal calls, we'll skip user-specific access checks
|
||||
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
|
||||
} else {
|
||||
// Try session auth first (for web UI)
|
||||
const session = await getSession()
|
||||
let authenticatedUserId: string | null = session?.user?.id || null
|
||||
|
||||
// If no session, check for API key auth
|
||||
if (!authenticatedUserId) {
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
// Verify API key
|
||||
const [apiKeyRecord] = await db
|
||||
.select({ userId: apiKeyTable.userId })
|
||||
.from(apiKeyTable)
|
||||
.where(eq(apiKeyTable.key, apiKeyHeader))
|
||||
.limit(1)
|
||||
|
||||
if (apiKeyRecord) {
|
||||
authenticatedUserId = apiKeyRecord.userId
|
||||
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (authResult.success && authResult.userId) {
|
||||
authenticatedUserId = authResult.userId
|
||||
if (authResult.keyId) {
|
||||
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
|
||||
keyId: authResult.keyId,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +116,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load from normalized tables first
|
||||
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
|
||||
|
||||
@@ -130,7 +128,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
loops: normalizedData.loops,
|
||||
})
|
||||
|
||||
// Construct response object with workflow data and state from normalized tables
|
||||
const finalWorkflowData = {
|
||||
...workflowData,
|
||||
state: {
|
||||
@@ -175,7 +172,6 @@ export async function DELETE(
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`)
|
||||
@@ -184,7 +180,6 @@ export async function DELETE(
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const workflowData = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKey } from '@/lib/api-key/auth'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkflowMiddleware')
|
||||
|
||||
@@ -57,8 +56,9 @@ export async function validateWorkflowAccess(
|
||||
}
|
||||
|
||||
// If a pinned key exists, only accept that specific key
|
||||
if (workflow.pinnedApiKey) {
|
||||
if (workflow.pinnedApiKey !== apiKeyHeader) {
|
||||
if (workflow.pinnedApiKey?.key) {
|
||||
const isValidPinnedKey = await authenticateApiKey(apiKeyHeader, workflow.pinnedApiKey.key)
|
||||
if (!isValidPinnedKey) {
|
||||
return {
|
||||
error: {
|
||||
message: 'Unauthorized: Invalid API key',
|
||||
@@ -67,14 +67,29 @@ export async function validateWorkflowAccess(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Otherwise, verify the key belongs to the workflow owner
|
||||
const [owned] = await db
|
||||
.select({ key: apiKey.key })
|
||||
.from(apiKey)
|
||||
.where(and(eq(apiKey.userId, workflow.userId), eq(apiKey.key, apiKeyHeader)))
|
||||
.limit(1)
|
||||
// Try personal keys first
|
||||
const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
userId: workflow.userId as string,
|
||||
keyTypes: ['personal'],
|
||||
})
|
||||
|
||||
if (!owned) {
|
||||
let validResult = null
|
||||
if (personalResult.success) {
|
||||
validResult = personalResult
|
||||
} else if (workflow.workspaceId) {
|
||||
// Try workspace keys
|
||||
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
workspaceId: workflow.workspaceId as string,
|
||||
keyTypes: ['workspace'],
|
||||
})
|
||||
|
||||
if (workspaceResult.success) {
|
||||
validResult = workspaceResult
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid key found, reject
|
||||
if (!validResult) {
|
||||
return {
|
||||
error: {
|
||||
message: 'Unauthorized: Invalid API key',
|
||||
@@ -82,6 +97,8 @@ export async function validateWorkflowAccess(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
await updateApiKeyLastUsed(validResult.keyId!)
|
||||
}
|
||||
}
|
||||
return { workflow }
|
||||
|
||||
141
apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts
Normal file
141
apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { and, eq, not } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeyAPI')
|
||||
|
||||
const UpdateKeySchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
})
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; keyId: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workspaceId, keyId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name } = UpdateKeySchema.parse(body)
|
||||
|
||||
const existingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingKey.length === 0) {
|
||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const conflictingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.name, name),
|
||||
eq(apiKey.type, 'workspace'),
|
||||
not(eq(apiKey.id, keyId))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (conflictingKey.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A workspace API key with this name already exists' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedKey] = await db
|
||||
.update(apiKey)
|
||||
.set({
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
||||
)
|
||||
.returning({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
createdAt: apiKey.createdAt,
|
||||
updatedAt: apiKey.updatedAt,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
|
||||
return NextResponse.json({ key: updatedKey })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key PUT error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update workspace API key' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; keyId: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workspaceId, keyId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const deletedRows = await db
|
||||
.delete(apiKey)
|
||||
.where(
|
||||
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
||||
)
|
||||
.returning({ id: apiKey.id })
|
||||
|
||||
if (deletedRows.length === 0) {
|
||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete workspace API key' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
212
apps/sim/app/api/workspaces/[id]/api-keys/route.ts
Normal file
212
apps/sim/app/api/workspaces/[id]/api-keys/route.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey, workspace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeysAPI')
|
||||
|
||||
const CreateKeySchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required'),
|
||||
})
|
||||
|
||||
const DeleteKeysSchema = z.object({
|
||||
keys: z.array(z.string()).min(1),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API keys access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const ws = await db.select().from(workspace).where(eq(workspace.id, workspaceId)).limit(1)
|
||||
if (!ws.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceKeys = await db
|
||||
.select({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: apiKey.key,
|
||||
createdAt: apiKey.createdAt,
|
||||
lastUsed: apiKey.lastUsed,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
createdBy: apiKey.createdBy,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace')))
|
||||
.orderBy(apiKey.createdAt)
|
||||
|
||||
const formattedWorkspaceKeys = await Promise.all(
|
||||
workspaceKeys.map(async (key) => {
|
||||
const displayFormat = await getApiKeyDisplayFormat(key.key)
|
||||
return {
|
||||
...key,
|
||||
key: key.key,
|
||||
displayKey: displayFormat,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
keys: formattedWorkspaceKeys,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API keys GET error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to load API keys' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key creation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name } = CreateKeySchema.parse(body)
|
||||
|
||||
const existingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.name, name),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingKey.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `A workspace API key named "${name}" already exists. Please choose a different name.`,
|
||||
},
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const { key: plainKey, encryptedKey } = await createApiKey(true)
|
||||
|
||||
if (!encryptedKey) {
|
||||
throw new Error('Failed to encrypt API key for storage')
|
||||
}
|
||||
|
||||
const [newKey] = await db
|
||||
.insert(apiKey)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
workspaceId,
|
||||
userId: userId,
|
||||
createdBy: userId,
|
||||
name,
|
||||
key: encryptedKey,
|
||||
type: 'workspace',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
createdAt: apiKey.createdAt,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
key: {
|
||||
...newKey,
|
||||
key: plainKey,
|
||||
},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key POST error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create workspace API key' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || (permission !== 'admin' && permission !== 'write')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { keys } = DeleteKeysSchema.parse(body)
|
||||
|
||||
const deletedCount = await db
|
||||
.delete(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.type, 'workspace'),
|
||||
inArray(apiKey.id, keys)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ success: true, deletedCount })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete workspace API keys' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Check, Copy, Loader2, Plus, X } from 'lucide-react'
|
||||
import { Check, Copy, Loader2, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -22,15 +25,17 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
|
||||
const logger = createLogger('DeployForm')
|
||||
|
||||
@@ -38,9 +43,17 @@ interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
displayKey?: string
|
||||
lastUsed?: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
createdBy?: string
|
||||
}
|
||||
|
||||
interface ApiKeysData {
|
||||
workspace: ApiKey[]
|
||||
personal: ApiKey[]
|
||||
conflicts: string[]
|
||||
}
|
||||
|
||||
// Form schema for API key selection or creation
|
||||
@@ -52,12 +65,9 @@ const deployFormSchema = z.object({
|
||||
type DeployFormValues = z.infer<typeof deployFormSchema>
|
||||
|
||||
interface DeployFormProps {
|
||||
apiKeys: ApiKey[]
|
||||
apiKeys: ApiKey[] // Legacy prop for backward compatibility
|
||||
keysLoaded: boolean
|
||||
endpointUrl: string
|
||||
workflowId: string
|
||||
onSubmit: (data: DeployFormValues) => void
|
||||
getInputFormatExample: () => string
|
||||
onApiKeyCreated?: () => void
|
||||
// Optional id to bind an external submit button via the `form` attribute
|
||||
formId?: string
|
||||
@@ -66,45 +76,137 @@ interface DeployFormProps {
|
||||
export function DeployForm({
|
||||
apiKeys,
|
||||
keysLoaded,
|
||||
endpointUrl,
|
||||
workflowId,
|
||||
onSubmit,
|
||||
getInputFormatExample,
|
||||
onApiKeyCreated,
|
||||
formId,
|
||||
}: DeployFormProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canCreateWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
|
||||
|
||||
// State
|
||||
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 [isCreating, setIsCreating] = useState(false)
|
||||
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
|
||||
const [keysLoaded2, setKeysLoaded2] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const [justCreatedKeyId, setJustCreatedKeyId] = useState<string | null>(null)
|
||||
|
||||
// Get all available API keys (workspace + personal)
|
||||
const allApiKeys = apiKeysData ? [...apiKeysData.workspace, ...apiKeysData.personal] : apiKeys
|
||||
|
||||
// Initialize form with react-hook-form
|
||||
const form = useForm<DeployFormValues>({
|
||||
resolver: zodResolver(deployFormSchema),
|
||||
defaultValues: {
|
||||
apiKey: apiKeys.length > 0 ? apiKeys[0].key : '',
|
||||
apiKey: allApiKeys.length > 0 ? allApiKeys[0].id : '',
|
||||
newKeyName: '',
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch workspace and personal API keys
|
||||
const fetchApiKeys = async () => {
|
||||
if (!workspaceId) return
|
||||
|
||||
try {
|
||||
setKeysLoaded2(false)
|
||||
const [workspaceResponse, personalResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}/api-keys`),
|
||||
fetch('/api/users/me/api-keys'),
|
||||
])
|
||||
|
||||
let workspaceKeys: ApiKey[] = []
|
||||
let personalKeys: ApiKey[] = []
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const workspaceData = await workspaceResponse.json()
|
||||
workspaceKeys = workspaceData.keys || []
|
||||
} else {
|
||||
logger.error('Error fetching workspace API keys:', { status: workspaceResponse.status })
|
||||
}
|
||||
|
||||
if (personalResponse.ok) {
|
||||
const personalData = await personalResponse.json()
|
||||
personalKeys = personalData.keys || []
|
||||
} else {
|
||||
logger.error('Error fetching personal API keys:', { status: personalResponse.status })
|
||||
}
|
||||
|
||||
// Client-side conflict detection
|
||||
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
|
||||
const conflicts = personalKeys
|
||||
.filter((key) => workspaceKeyNames.has(key.name))
|
||||
.map((key) => key.name)
|
||||
|
||||
setApiKeysData({
|
||||
workspace: workspaceKeys,
|
||||
personal: personalKeys,
|
||||
conflicts,
|
||||
})
|
||||
setKeysLoaded2(true)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching API keys:', { error })
|
||||
setKeysLoaded2(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Update on dependency changes beyond the initial load
|
||||
useEffect(() => {
|
||||
if (keysLoaded && apiKeys.length > 0) {
|
||||
// Ensure that form has a value after loading
|
||||
form.setValue('apiKey', form.getValues().apiKey || apiKeys[0].key)
|
||||
if (workspaceId) {
|
||||
fetchApiKeys()
|
||||
}
|
||||
}, [keysLoaded, apiKeys, form])
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if ((keysLoaded || keysLoaded2) && allApiKeys.length > 0) {
|
||||
const currentValue = form.getValues().apiKey
|
||||
|
||||
// If we just created a key, prioritize selecting it
|
||||
if (justCreatedKeyId && allApiKeys.find((key) => key.id === justCreatedKeyId)) {
|
||||
form.setValue('apiKey', justCreatedKeyId)
|
||||
setJustCreatedKeyId(null) // Clear after setting
|
||||
}
|
||||
// Otherwise, ensure form has a value if it doesn't already
|
||||
else if (!currentValue || !allApiKeys.find((key) => key.id === currentValue)) {
|
||||
form.setValue('apiKey', allApiKeys[0].id)
|
||||
}
|
||||
}
|
||||
}, [keysLoaded, keysLoaded2, allApiKeys, form, justCreatedKeyId])
|
||||
|
||||
// Generate a new API key
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return
|
||||
|
||||
setIsCreating(true)
|
||||
// Client-side duplicate check for immediate feedback
|
||||
const trimmedName = newKeyName.trim()
|
||||
const isDuplicate =
|
||||
keyType === 'workspace'
|
||||
? (apiKeysData?.workspace || []).some((k) => k.name === trimmedName)
|
||||
: (apiKeysData?.personal || apiKeys || []).some((k) => k.name === trimmedName)
|
||||
if (isDuplicate) {
|
||||
setCreateError(
|
||||
keyType === 'workspace'
|
||||
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
|
||||
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmittingCreate(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const response = await fetch('/api/users/me/api-keys', {
|
||||
const url =
|
||||
keyType === 'workspace'
|
||||
? `/api/workspaces/${workspaceId}/api-keys`
|
||||
: '/api/users/me/api-keys'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -114,30 +216,53 @@ export function DeployForm({
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create new API key')
|
||||
}
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Show the new key dialog with the API key (only shown once)
|
||||
setNewKey(data.key)
|
||||
setShowNewKeyDialog(true)
|
||||
// Reset form and close the create dialog ONLY on success
|
||||
setNewKeyName('')
|
||||
setKeyType('personal')
|
||||
setCreateError(null)
|
||||
setIsCreatingKey(false)
|
||||
|
||||
const data = await response.json()
|
||||
// Show the new key dialog with the API key (only shown once)
|
||||
setNewKey(data.key)
|
||||
setShowNewKeyDialog(true)
|
||||
// Reset form
|
||||
setNewKeyName('')
|
||||
// Close the create dialog
|
||||
setIsCreatingKey(false)
|
||||
// Store the newly created key ID for auto-selection
|
||||
setJustCreatedKeyId(data.key.id)
|
||||
|
||||
// Update the form with the new key
|
||||
form.setValue('apiKey', data.key.key)
|
||||
// Refresh the keys list - the useEffect will handle auto-selection
|
||||
await fetchApiKeys()
|
||||
|
||||
// Trigger a refresh of the keys list in the parent component
|
||||
if (onApiKeyCreated) {
|
||||
onApiKeyCreated()
|
||||
// Trigger a refresh of the keys list in the parent component
|
||||
if (onApiKeyCreated) {
|
||||
onApiKeyCreated()
|
||||
}
|
||||
} else {
|
||||
let errorData
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch (parseError) {
|
||||
errorData = { error: 'Server error' }
|
||||
}
|
||||
// Check for duplicate name error and prefer server message
|
||||
const serverMessage = typeof errorData?.error === 'string' ? errorData.error : null
|
||||
if (response.status === 409 || serverMessage?.toLowerCase().includes('already exists')) {
|
||||
setCreateError(
|
||||
serverMessage ||
|
||||
(keyType === 'workspace'
|
||||
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
|
||||
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`)
|
||||
)
|
||||
} else {
|
||||
setCreateError(errorData.error || 'Failed to create API key. Please try again.')
|
||||
}
|
||||
logger.error('Failed to create API key:', errorData)
|
||||
}
|
||||
} catch (error) {
|
||||
setCreateError('Failed to create API key. Please check your connection and try again.')
|
||||
logger.error('Error creating API key:', { error })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
setIsSubmittingCreate(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +296,10 @@ export function DeployForm({
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 gap-1 px-2 text-muted-foreground text-xs'
|
||||
onClick={() => setIsCreatingKey(true)}
|
||||
onClick={() => {
|
||||
setIsCreatingKey(true)
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
<span>Create new</span>
|
||||
@@ -191,22 +319,68 @@ export function DeployForm({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent align='start' className='w-[var(--radix-select-trigger-width)] py-1'>
|
||||
{apiKeys.map((apiKey) => (
|
||||
<SelectItem
|
||||
key={apiKey.id}
|
||||
value={apiKey.key}
|
||||
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.key.slice(-5)}
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
</SelectItem>
|
||||
))}
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -215,130 +389,145 @@ export function DeployForm({
|
||||
/>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<Dialog open={isCreatingKey} onOpenChange={setIsCreatingKey}>
|
||||
<DialogContent className='flex flex-col gap-0 p-0 sm:max-w-md' hideCloseButton>
|
||||
<DialogHeader className='border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>Create new API key</DialogTitle>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-8 w-8 p-0'
|
||||
onClick={() => setIsCreatingKey(false)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<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='flex-1 px-6 pt-4 pb-6'>
|
||||
<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'
|
||||
>
|
||||
Personal
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant={keyType === 'workspace' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setKeyType('workspace')
|
||||
if (createError) setCreateError(null)
|
||||
}}
|
||||
className='h-8'
|
||||
>
|
||||
Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='keyName'>API Key Name</Label>
|
||||
<p className='font-[360] text-sm'>
|
||||
Enter a name for your API key to help you identify it later.
|
||||
</p>
|
||||
<Input
|
||||
id='keyName'
|
||||
placeholder='e.g., Development, Production, etc.'
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className='focus-visible:ring-primary'
|
||||
onChange={(e) => {
|
||||
setNewKeyName(e.target.value)
|
||||
if (createError) setCreateError(null) // Clear error when user types
|
||||
}}
|
||||
placeholder='e.g., Development, Production'
|
||||
className='h-9 rounded-[8px]'
|
||||
autoFocus
|
||||
/>
|
||||
{createError && <div className='text-red-600 text-sm'>{createError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 border-t px-6 py-4'>
|
||||
<Button variant='outline' onClick={() => setIsCreatingKey(false)}>
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel
|
||||
className='h-9 w-full rounded-[8px]'
|
||||
onClick={() => {
|
||||
setNewKeyName('')
|
||||
setKeyType('personal')
|
||||
setCreateError(null)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateKey} disabled={!newKeyName.trim() || isCreating}>
|
||||
{isCreating ? (
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleCreateKey}
|
||||
className='h-9 w-full rounded-[8px] bg-primary text-white hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
disabled={
|
||||
!newKeyName.trim() ||
|
||||
isSubmittingCreate ||
|
||||
(keyType === 'workspace' && !canCreateWorkspaceKeys)
|
||||
}
|
||||
>
|
||||
{isSubmittingCreate ? (
|
||||
<>
|
||||
<Loader2 className='mr-1.5 h-3.5 w-3.5 animate-spin' />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create'
|
||||
`Create ${keyType === 'workspace' ? 'Workspace' : 'Personal'} Key`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* New API Key Dialog */}
|
||||
<Dialog
|
||||
<AlertDialog
|
||||
open={showNewKeyDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowNewKeyDialog(open)
|
||||
if (!open) setNewKey(null)
|
||||
if (!open) {
|
||||
setNewKey(null)
|
||||
setCopySuccess(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className='flex flex-col gap-0 p-0 sm:max-w-md' hideCloseButton>
|
||||
<DialogHeader className='border-b px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<DialogTitle className='font-medium text-lg'>
|
||||
Your API key has been created
|
||||
</DialogTitle>
|
||||
<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='h-8 w-8 p-0'
|
||||
onClick={() => {
|
||||
setShowNewKeyDialog(false)
|
||||
setNewKey(null)
|
||||
}}
|
||||
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={() => copyToClipboard(newKey.key)}
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
<span className='sr-only'>Close</span>
|
||||
{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>
|
||||
<DialogDescription className='pt-2'>
|
||||
This is the only time you will see your API key. Copy it now and store it securely.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{newKey && (
|
||||
<div className='flex-1 px-6 pt-4 pb-6'>
|
||||
<div className='space-y-2'>
|
||||
<Label>API Key</Label>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
readOnly
|
||||
value={newKey.key}
|
||||
className='border-slate-300 bg-muted/50 pr-10 font-mono text-sm'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7'
|
||||
onClick={() => copyToClipboard(newKey.key)}
|
||||
>
|
||||
{copySuccess ? (
|
||||
<Check className='h-4 w-4 text-green-500' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
<span className='sr-only'>Copy to clipboard</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p className='mt-1 text-muted-foreground text-xs'>
|
||||
For security, we don't store the complete key. You won't be able to
|
||||
view it again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end border-t px-6 py-4'>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowNewKeyDialog(false)
|
||||
setNewKey(null)
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface ApiKeyProps {
|
||||
@@ -10,15 +8,22 @@ interface ApiKeyProps {
|
||||
}
|
||||
|
||||
export function ApiKey({ apiKey, showLabel = true }: ApiKeyProps) {
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
// Extract key name and type from the API response format "Name (type)"
|
||||
const getKeyInfo = (keyInfo: string) => {
|
||||
if (!keyInfo || keyInfo.includes('No API key found')) {
|
||||
return { name: keyInfo, type: null }
|
||||
}
|
||||
|
||||
// Function to mask API key with asterisks but keep first and last 4 chars visible
|
||||
const maskApiKey = (key: string) => {
|
||||
if (!key || key.includes('No API key found')) return key
|
||||
if (key.length <= 8) return key
|
||||
return `${key.substring(0, 4)}${'*'.repeat(key.length - 8)}${key.substring(key.length - 4)}`
|
||||
const match = keyInfo.match(/^(.*?)\s+\(([^)]+)\)$/)
|
||||
if (match) {
|
||||
return { name: match[1].trim(), type: match[2] }
|
||||
}
|
||||
|
||||
return { name: keyInfo, type: null }
|
||||
}
|
||||
|
||||
const { name, type } = getKeyInfo(apiKey)
|
||||
|
||||
return (
|
||||
<div className='space-y-1.5'>
|
||||
{showLabel && (
|
||||
@@ -26,15 +31,17 @@ export function ApiKey({ apiKey, showLabel = true }: ApiKeyProps) {
|
||||
<Label className='font-medium text-sm'>API Key</Label>
|
||||
</div>
|
||||
)}
|
||||
<div className='group relative rounded-md border bg-background transition-colors hover:bg-muted/50'>
|
||||
<pre
|
||||
className='cursor-pointer overflow-x-auto whitespace-pre-wrap p-3 font-mono text-xs'
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
title={showKey ? 'Click to hide API Key' : 'Click to reveal API Key'}
|
||||
>
|
||||
{showKey ? apiKey : maskApiKey(apiKey)}
|
||||
</pre>
|
||||
<CopyButton text={apiKey} />
|
||||
<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'>{name}</pre>
|
||||
{type && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ExampleCommand({
|
||||
if (!command.includes('curl')) return command
|
||||
|
||||
// Replace the actual API key with a placeholder in the command
|
||||
const sanitizedCommand = command.replace(apiKey, 'SIM_API_KEY')
|
||||
const sanitizedCommand = command.replace(apiKey, '$SIM_API_KEY')
|
||||
|
||||
// Format the command with line breaks for better readability
|
||||
return sanitizedCommand
|
||||
@@ -49,50 +49,19 @@ export function ExampleCommand({
|
||||
.replace(' http', '\n http')
|
||||
}
|
||||
|
||||
// Get the actual command with real API key for copying
|
||||
// Get the command with placeholder for copying (single line, no line breaks)
|
||||
const getActualCommand = () => {
|
||||
const baseEndpoint = endpoint
|
||||
const inputExample = getInputFormatExample
|
||||
? getInputFormatExample()
|
||||
: ' -d \'{"input": "your data here"}\''
|
||||
|
||||
switch (mode) {
|
||||
case 'sync':
|
||||
// Use the original command but ensure it has the real API key
|
||||
return command
|
||||
|
||||
case 'async':
|
||||
switch (exampleType) {
|
||||
case 'execute':
|
||||
return `curl -X POST \\
|
||||
-H "X-API-Key: ${apiKey}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Execution-Mode: async"${inputExample} \\
|
||||
${baseEndpoint}`
|
||||
|
||||
case 'status': {
|
||||
const baseUrl = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: ${apiKey}" \\
|
||||
${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION`
|
||||
}
|
||||
|
||||
case 'rate-limits': {
|
||||
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: ${apiKey}" \\
|
||||
${baseUrlForRateLimit}/api/users/me/usage-limits`
|
||||
}
|
||||
|
||||
default:
|
||||
return command
|
||||
}
|
||||
|
||||
default:
|
||||
return command
|
||||
}
|
||||
const displayCommand = getDisplayCommand()
|
||||
// Remove line breaks and extra whitespace for copying
|
||||
return displayCommand
|
||||
.replace(/\\\n\s*/g, ' ') // Remove backslash + newline + whitespace
|
||||
.replace(/\n\s*/g, ' ') // Remove any remaining newlines + whitespace
|
||||
.replace(/\s+/g, ' ') // Normalize multiple spaces to single space
|
||||
.trim()
|
||||
}
|
||||
|
||||
const getDisplayCommand = () => {
|
||||
const baseEndpoint = endpoint.replace(apiKey, 'SIM_API_KEY')
|
||||
const baseEndpoint = endpoint.replace(apiKey, '$SIM_API_KEY')
|
||||
const inputExample = getInputFormatExample
|
||||
? getInputFormatExample()
|
||||
: ' -d \'{"input": "your data here"}\''
|
||||
@@ -105,20 +74,20 @@ export function ExampleCommand({
|
||||
switch (exampleType) {
|
||||
case 'execute':
|
||||
return `curl -X POST \\
|
||||
-H "X-API-Key: SIM_API_KEY" \\
|
||||
-H "X-API-Key: $SIM_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "X-Execution-Mode: async"${inputExample} \\
|
||||
${baseEndpoint}`
|
||||
|
||||
case 'status': {
|
||||
const baseUrl = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: SIM_API_KEY" \\
|
||||
return `curl -H "X-API-Key: $SIM_API_KEY" \\
|
||||
${baseUrl}/api/jobs/JOB_ID_FROM_EXECUTION`
|
||||
}
|
||||
|
||||
case 'rate-limits': {
|
||||
const baseUrlForRateLimit = baseEndpoint.split('/api/workflows/')[0]
|
||||
return `curl -H "X-API-Key: SIM_API_KEY" \\
|
||||
return `curl -H "X-API-Key: $SIM_API_KEY" \\
|
||||
${baseUrlForRateLimit}/api/users/me/usage-limits`
|
||||
}
|
||||
|
||||
|
||||
@@ -274,7 +274,7 @@ export function DeployModal({
|
||||
setDeploymentInfo(newDeploymentInfo)
|
||||
|
||||
await refetchDeployedState()
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error deploying workflow:', { error })
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -297,7 +297,7 @@ export function DeployModal({
|
||||
setDeploymentStatus(workflowId, false)
|
||||
setChatExists(false)
|
||||
onOpenChange(false)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error undeploying workflow:', { error })
|
||||
} finally {
|
||||
setIsUndeploying(false)
|
||||
@@ -341,7 +341,7 @@ export function DeployModal({
|
||||
|
||||
// Ensure modal status updates immediately
|
||||
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error redeploying workflow:', { error })
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@@ -471,10 +471,7 @@ export function DeployModal({
|
||||
<DeployForm
|
||||
apiKeys={apiKeys}
|
||||
keysLoaded={keysLoaded}
|
||||
endpointUrl={`${getEnv('NEXT_PUBLIC_APP_URL')}/api/workflows/${workflowId}/execute`}
|
||||
workflowId={workflowId || ''}
|
||||
onSubmit={onDeploy}
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
onApiKeyCreated={fetchApiKeys}
|
||||
formId='deploy-api-form'
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, Copy, Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -18,29 +19,54 @@ import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
|
||||
const logger = createLogger('ApiKeys')
|
||||
|
||||
interface ApiKeysProps {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
registerCloseHandler?: (handler: (open: boolean) => void) => void
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
name: string
|
||||
key: string
|
||||
displayKey?: string
|
||||
lastUsed?: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
createdBy?: string
|
||||
}
|
||||
|
||||
export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
interface ApiKeyDisplayProps {
|
||||
apiKey: ApiKey
|
||||
}
|
||||
|
||||
function ApiKeyDisplay({ apiKey }: ApiKeyDisplayProps) {
|
||||
const displayValue = apiKey.displayKey || apiKey.key
|
||||
return (
|
||||
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
|
||||
<code className='font-mono text-foreground text-xs'>{displayValue}</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
const { data: session } = useSession()
|
||||
const userId = session?.user?.id
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canEdit || userPermissions.canAdmin
|
||||
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
|
||||
// State for both workspace and personal keys
|
||||
const [workspaceKeys, setWorkspaceKeys] = useState<ApiKey[]>([])
|
||||
const [personalKeys, setPersonalKeys] = useState<ApiKey[]>([])
|
||||
const [conflicts, setConflicts] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isSubmittingCreate, setIsSubmittingCreate] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [newKey, setNewKey] = useState<ApiKey | null>(null)
|
||||
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
|
||||
@@ -49,23 +75,71 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
|
||||
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
|
||||
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// Filter API keys based on search term
|
||||
const filteredApiKeys = apiKeys.filter((key) =>
|
||||
key.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredWorkspaceKeys = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return workspaceKeys.map((key, index) => ({ key, originalIndex: index }))
|
||||
}
|
||||
return workspaceKeys
|
||||
.map((key, index) => ({ key, originalIndex: index }))
|
||||
.filter(({ key }) => key.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
}, [workspaceKeys, searchTerm])
|
||||
|
||||
const filteredPersonalKeys = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return personalKeys.map((key, index) => ({ key, originalIndex: index }))
|
||||
}
|
||||
return personalKeys
|
||||
.map((key, index) => ({ key, originalIndex: index }))
|
||||
.filter(({ key }) => key.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
}, [personalKeys, searchTerm])
|
||||
|
||||
const personalHeaderMarginClass = useMemo(() => {
|
||||
if (!searchTerm.trim()) return 'mt-8'
|
||||
return filteredWorkspaceKeys.length > 0 ? 'mt-8' : 'mt-0'
|
||||
}, [searchTerm, filteredWorkspaceKeys])
|
||||
|
||||
// Fetch API keys
|
||||
const fetchApiKeys = async () => {
|
||||
if (!userId) return
|
||||
if (!userId || !workspaceId) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/users/me/api-keys')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setApiKeys(data.keys || [])
|
||||
const [workspaceResponse, personalResponse] = await Promise.all([
|
||||
fetch(`/api/workspaces/${workspaceId}/api-keys`),
|
||||
fetch('/api/users/me/api-keys'),
|
||||
])
|
||||
|
||||
let workspaceKeys: ApiKey[] = []
|
||||
let personalKeys: ApiKey[] = []
|
||||
|
||||
if (workspaceResponse.ok) {
|
||||
const workspaceData = await workspaceResponse.json()
|
||||
workspaceKeys = workspaceData.keys || []
|
||||
} else {
|
||||
logger.error('Failed to fetch workspace API keys:', { status: workspaceResponse.status })
|
||||
}
|
||||
|
||||
if (personalResponse.ok) {
|
||||
const personalData = await personalResponse.json()
|
||||
personalKeys = personalData.keys || []
|
||||
} else {
|
||||
logger.error('Failed to fetch personal API keys:', { status: personalResponse.status })
|
||||
}
|
||||
|
||||
// Client-side conflict detection
|
||||
const workspaceKeyNames = new Set(workspaceKeys.map((k) => k.name))
|
||||
const conflicts = personalKeys
|
||||
.filter((key) => workspaceKeyNames.has(key.name))
|
||||
.map((key) => key.name)
|
||||
|
||||
setWorkspaceKeys(workspaceKeys)
|
||||
setPersonalKeys(personalKeys)
|
||||
setConflicts(conflicts)
|
||||
} catch (error) {
|
||||
logger.error('Error fetching API keys:', { error })
|
||||
} finally {
|
||||
@@ -73,13 +147,32 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a new API key
|
||||
const handleCreateKey = async () => {
|
||||
if (!userId || !newKeyName.trim()) return
|
||||
|
||||
setIsCreating(true)
|
||||
const trimmedName = newKeyName.trim()
|
||||
const isDuplicate =
|
||||
keyType === 'workspace'
|
||||
? workspaceKeys.some((k) => k.name === trimmedName)
|
||||
: personalKeys.some((k) => k.name === trimmedName)
|
||||
if (isDuplicate) {
|
||||
setCreateError(
|
||||
keyType === 'workspace'
|
||||
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
|
||||
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmittingCreate(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const response = await fetch('/api/users/me/api-keys', {
|
||||
const url =
|
||||
keyType === 'workspace'
|
||||
? `/api/workspaces/${workspaceId}/api-keys`
|
||||
: '/api/users/me/api-keys'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -91,57 +184,105 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Show the new key dialog with the API key (only shown once)
|
||||
setNewKey(data.key)
|
||||
setShowNewKeyDialog(true)
|
||||
// Refresh the keys list
|
||||
fetchApiKeys()
|
||||
// Close the create dialog
|
||||
setIsCreating(false)
|
||||
setNewKeyName('')
|
||||
setKeyType('personal')
|
||||
setCreateError(null)
|
||||
setIsSubmittingCreate(false)
|
||||
setIsCreateDialogOpen(false)
|
||||
} else {
|
||||
let errorData
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch (parseError) {
|
||||
logger.error('Error parsing API response:', parseError)
|
||||
errorData = { error: 'Server error' }
|
||||
}
|
||||
|
||||
logger.error('API key creation failed:', { status: response.status, errorData })
|
||||
|
||||
const serverMessage = typeof errorData?.error === 'string' ? errorData.error : null
|
||||
if (response.status === 409 || serverMessage?.toLowerCase().includes('already exists')) {
|
||||
const errorMessage =
|
||||
serverMessage ||
|
||||
(keyType === 'workspace'
|
||||
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
|
||||
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`)
|
||||
logger.error('Setting error message:', errorMessage)
|
||||
setCreateError(errorMessage)
|
||||
} else {
|
||||
setCreateError(errorData.error || 'Failed to create API key. Please try again.')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setCreateError('Failed to create API key. Please check your connection and try again.')
|
||||
logger.error('Error creating API key:', { error })
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
setIsSubmittingCreate(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete an API key
|
||||
const handleDeleteKey = async () => {
|
||||
if (!userId || !deleteKey) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/me/api-keys/${deleteKey.id}`, {
|
||||
const isWorkspaceKey = workspaceKeys.some((k) => k.id === deleteKey.id)
|
||||
const url = isWorkspaceKey
|
||||
? `/api/workspaces/${workspaceId}/api-keys/${deleteKey.id}`
|
||||
: `/api/users/me/api-keys/${deleteKey.id}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh the keys list
|
||||
fetchApiKeys()
|
||||
// Close the dialog
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteKey(null)
|
||||
setDeleteConfirmationName('')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
logger.error('Failed to delete API key:', errorData)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting API key:', { error })
|
||||
}
|
||||
}
|
||||
|
||||
// Copy API key to clipboard
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key)
|
||||
setCopySuccess(true)
|
||||
setTimeout(() => setCopySuccess(false), 2000)
|
||||
}
|
||||
|
||||
// Load API keys on mount
|
||||
const handleModalClose = (open: boolean) => {
|
||||
onOpenChange?.(open)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
if (userId && workspaceId) {
|
||||
fetchApiKeys()
|
||||
}
|
||||
}, [userId])
|
||||
}, [userId, workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (registerCloseHandler) {
|
||||
registerCloseHandler(handleModalClose)
|
||||
}
|
||||
}, [registerCloseHandler])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldScrollToBottom && scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
setShouldScrollToBottom(false)
|
||||
}
|
||||
}, [shouldScrollToBottom])
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Never'
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
@@ -172,7 +313,10 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
|
||||
>
|
||||
<div className='h-full space-y-2 py-2'>
|
||||
{isLoading ? (
|
||||
<div className='space-y-2'>
|
||||
@@ -180,50 +324,136 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
<ApiKeySkeleton />
|
||||
<ApiKeySkeleton />
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
) : personalKeys.length === 0 && workspaceKeys.length === 0 ? (
|
||||
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
|
||||
Click "Create Key" below to get started
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{filteredApiKeys.map((key) => (
|
||||
<div key={key.id} className='flex flex-col gap-2'>
|
||||
<Label className='font-normal text-muted-foreground text-xs uppercase'>
|
||||
{key.name}
|
||||
</Label>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
|
||||
<code className='font-mono text-foreground text-xs'>
|
||||
•••••{key.key.slice(-6)}
|
||||
</code>
|
||||
<>
|
||||
{/* Workspace section */}
|
||||
{!searchTerm.trim() ? (
|
||||
<div className='mb-6 space-y-2'>
|
||||
<div className='font-medium text-[13px] text-foreground'>Workspace</div>
|
||||
{workspaceKeys.length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No workspace API keys yet.</div>
|
||||
) : (
|
||||
workspaceKeys.map((key) => (
|
||||
<div key={key.id} className='flex flex-col gap-2'>
|
||||
<Label className='font-normal text-muted-foreground text-xs uppercase'>
|
||||
{key.name}
|
||||
</Label>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ApiKeyDisplay apiKey={key} />
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Last used: {formatDate(key.lastUsed)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
disabled={!canManageWorkspaceKeys}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : filteredWorkspaceKeys.length > 0 ? (
|
||||
<div className='mb-6 space-y-2'>
|
||||
<div className='font-medium text-[13px] text-foreground'>Workspace</div>
|
||||
{filteredWorkspaceKeys.map(({ key }) => (
|
||||
<div key={key.id} className='flex flex-col gap-2'>
|
||||
<Label className='font-normal text-muted-foreground text-xs uppercase'>
|
||||
{key.name}
|
||||
</Label>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ApiKeyDisplay apiKey={key} />
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Last used: {formatDate(key.lastUsed)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
disabled={!canManageWorkspaceKeys}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Last used: {formatDate(key.lastUsed)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{/* Personal section */}
|
||||
<div
|
||||
className={`${personalHeaderMarginClass} mb-2 font-medium text-[13px] text-foreground`}
|
||||
>
|
||||
Personal
|
||||
</div>
|
||||
{filteredPersonalKeys.map(({ key }) => {
|
||||
const isConflict = conflicts.includes(key.name)
|
||||
return (
|
||||
<div key={key.id} className='flex flex-col gap-2'>
|
||||
<Label className='font-normal text-muted-foreground text-xs uppercase'>
|
||||
{key.name}
|
||||
</Label>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<ApiKeyDisplay apiKey={key} />
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Last used: {formatDate(key.lastUsed)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setDeleteKey(key)
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
className='h-8 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isConflict && (
|
||||
<div className='col-span-3 mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
Workspace API key with the same name overrides this. Rename your personal
|
||||
key to use it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Show message when search has no results but there are keys */}
|
||||
{searchTerm.trim() && filteredApiKeys.length === 0 && apiKeys.length > 0 && (
|
||||
<div className='py-8 text-center text-muted-foreground text-sm'>
|
||||
No API keys found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Show message when search has no results across both sections */}
|
||||
{searchTerm.trim() &&
|
||||
filteredPersonalKeys.length === 0 &&
|
||||
filteredWorkspaceKeys.length === 0 &&
|
||||
(personalKeys.length > 0 || workspaceKeys.length > 0) && (
|
||||
<div className='py-8 text-center text-muted-foreground text-sm'>
|
||||
No API keys found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -239,60 +469,106 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
onClick={() => {
|
||||
setIsCreateDialogOpen(true)
|
||||
setKeyType('personal')
|
||||
setCreateError(null)
|
||||
}}
|
||||
variant='ghost'
|
||||
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
>
|
||||
<Plus className='h-4 w-4 stroke-[2px]' />
|
||||
Create Key
|
||||
</Button>
|
||||
<div className='text-muted-foreground text-xs'>Keep your API keys secure</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create API Key Dialog */}
|
||||
<AlertDialog open={isCreating} onOpenChange={setIsCreating}>
|
||||
<AlertDialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Create new API key</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This key will have access to your account and workflows. Make sure to copy it after
|
||||
creation as you won't be able to see it again.
|
||||
{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='py-2'>
|
||||
<p className='mb-2 font-[360] text-sm'>
|
||||
Enter a name for your API key to help you identify it later.
|
||||
</p>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder='e.g., Development, Production'
|
||||
className='h-9 rounded-[8px]'
|
||||
autoFocus
|
||||
/>
|
||||
<div className='space-y-4 py-2'>
|
||||
{canManageWorkspaceKeys && (
|
||||
<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'
|
||||
>
|
||||
Personal
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant={keyType === 'workspace' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setKeyType('workspace')
|
||||
if (createError) setCreateError(null)
|
||||
}}
|
||||
className='h-8'
|
||||
>
|
||||
Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-2'>
|
||||
<p className='font-[360] text-sm'>
|
||||
Enter a name for your API key to help you identify it later.
|
||||
</p>
|
||||
<Input
|
||||
value={newKeyName}
|
||||
onChange={(e) => {
|
||||
setNewKeyName(e.target.value)
|
||||
if (createError) setCreateError(null) // Clear error when user types
|
||||
}}
|
||||
placeholder='e.g., Development, Production'
|
||||
className='h-9 rounded-[8px]'
|
||||
autoFocus
|
||||
/>
|
||||
{createError && <div className='text-red-600 text-sm'>{createError}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel
|
||||
className='h-9 w-full rounded-[8px]'
|
||||
onClick={() => setNewKeyName('')}
|
||||
onClick={() => {
|
||||
setNewKeyName('')
|
||||
setKeyType('personal')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
handleCreateKey()
|
||||
setNewKeyName('')
|
||||
}}
|
||||
className='h-9 w-full rounded-[8px] bg-primary text-muted-foreground transition-all duration-200 hover:bg-primary/90'
|
||||
disabled={!newKeyName.trim()}
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleCreateKey}
|
||||
className='h-9 w-full rounded-[8px] bg-primary text-white hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
disabled={
|
||||
!newKeyName.trim() ||
|
||||
isSubmittingCreate ||
|
||||
(keyType === 'workspace' && !canManageWorkspaceKeys)
|
||||
}
|
||||
>
|
||||
Create Key
|
||||
</AlertDialogAction>
|
||||
Create {keyType === 'workspace' ? 'Workspace' : 'Personal'} Key
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -392,7 +668,6 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Loading skeleton for API keys
|
||||
function ApiKeySkeleton() {
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
|
||||
@@ -267,7 +267,6 @@ export function Copilot() {
|
||||
<Plus className='h-4 w-4 stroke-[2px]' />
|
||||
Generate Key
|
||||
</Button>
|
||||
<div className='text-muted-foreground text-xs'>Keep your API keys secure</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
25
apps/sim/db/migrations/0089_amused_pete_wisdom.sql
Normal file
25
apps/sim/db/migrations/0089_amused_pete_wisdom.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Step 1: Add new columns to api_key table
|
||||
ALTER TABLE "api_key" ADD COLUMN "workspace_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "api_key" ADD COLUMN "created_by" text;--> statement-breakpoint
|
||||
ALTER TABLE "api_key" ADD COLUMN "type" text DEFAULT 'personal' NOT NULL;--> statement-breakpoint
|
||||
|
||||
-- Step 2: Add pinned_api_key_id column to workflow table
|
||||
ALTER TABLE "workflow" ADD COLUMN "pinned_api_key_id" text;--> statement-breakpoint
|
||||
|
||||
-- Step 3: Migrate pinned API key references from text key to foreign key ID
|
||||
UPDATE "workflow"
|
||||
SET "pinned_api_key_id" = ak."id"
|
||||
FROM "api_key" ak
|
||||
WHERE "workflow"."pinned_api_key" IS NOT NULL
|
||||
AND ak."key" = "workflow"."pinned_api_key";--> statement-breakpoint
|
||||
|
||||
-- Step 4: Add foreign key constraints
|
||||
ALTER TABLE "api_key" ADD CONSTRAINT "api_key_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "api_key" ADD CONSTRAINT "api_key_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workflow" ADD CONSTRAINT "workflow_pinned_api_key_id_api_key_id_fk" FOREIGN KEY ("pinned_api_key_id") REFERENCES "public"."api_key"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
|
||||
-- Step 5: Add check constraint to ensure data integrity
|
||||
ALTER TABLE "api_key" ADD CONSTRAINT "workspace_type_check" CHECK ((type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL));--> statement-breakpoint
|
||||
|
||||
-- Step 6: Drop old columns
|
||||
ALTER TABLE "workflow" DROP COLUMN "pinned_api_key";
|
||||
6754
apps/sim/db/migrations/meta/0089_snapshot.json
Normal file
6754
apps/sim/db/migrations/meta/0089_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -617,6 +617,13 @@
|
||||
"when": 1757619657920,
|
||||
"tag": "0088_serious_firestar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 89,
|
||||
"version": "7",
|
||||
"when": 1757628623657,
|
||||
"tag": "0089_amused_pete_wisdom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const workflow = pgTable(
|
||||
isDeployed: boolean('is_deployed').notNull().default(false),
|
||||
deployedState: json('deployed_state'),
|
||||
deployedAt: timestamp('deployed_at'),
|
||||
pinnedApiKey: text('pinned_api_key'),
|
||||
pinnedApiKeyId: text('pinned_api_key_id').references(() => apiKey.id, { onDelete: 'set null' }),
|
||||
collaborators: json('collaborators').notNull().default('[]'),
|
||||
runCount: integer('run_count').notNull().default(0),
|
||||
lastRunAt: timestamp('last_run_at'),
|
||||
@@ -503,18 +503,31 @@ export const workflowLogWebhookDelivery = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const apiKey = pgTable('api_key', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
key: text('key').notNull().unique(),
|
||||
lastUsed: timestamp('last_used'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
})
|
||||
export const apiKey = pgTable(
|
||||
'api_key',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), // Only set for workspace keys
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), // Who created the workspace key
|
||||
name: text('name').notNull(),
|
||||
key: text('key').notNull().unique(),
|
||||
type: text('type').notNull().default('personal'), // 'personal' or 'workspace'
|
||||
lastUsed: timestamp('last_used'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at'),
|
||||
},
|
||||
(table) => ({
|
||||
// Ensure workspace keys have a workspace_id and personal keys don't
|
||||
workspaceTypeCheck: check(
|
||||
'workspace_type_check',
|
||||
sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)`
|
||||
),
|
||||
})
|
||||
)
|
||||
|
||||
export const marketplace = pgTable('marketplace', {
|
||||
id: text('id').primaryKey(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockType } from '@/executor/consts'
|
||||
import { LoopBlockHandler } from '@/executor/handlers/loop/loop-handler'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
|
||||
215
apps/sim/lib/api-key/auth.ts
Normal file
215
apps/sim/lib/api-key/auth.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
decryptApiKey,
|
||||
encryptApiKey,
|
||||
generateApiKey,
|
||||
generateEncryptedApiKey,
|
||||
isEncryptedApiKeyFormat,
|
||||
isLegacyApiKeyFormat,
|
||||
} from '@/lib/api-key/service'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('ApiKeyAuth')
|
||||
|
||||
/**
|
||||
* API key authentication utilities supporting both legacy plain text keys
|
||||
* and modern encrypted keys for gradual migration without breaking existing keys
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if a stored key is in the new encrypted format
|
||||
* @param storedKey - The key stored in the database
|
||||
* @returns true if the key is encrypted, false if it's plain text
|
||||
*/
|
||||
export function isEncryptedKey(storedKey: string): boolean {
|
||||
// Check if it follows the encrypted format: iv:encrypted:authTag
|
||||
return storedKey.includes(':') && storedKey.split(':').length === 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates an API key against a stored key, supporting both legacy and new encrypted formats
|
||||
* @param inputKey - The API key provided by the client
|
||||
* @param storedKey - The key stored in the database (may be plain text or encrypted)
|
||||
* @returns Promise<boolean> - true if the key is valid
|
||||
*/
|
||||
export async function authenticateApiKey(inputKey: string, storedKey: string): Promise<boolean> {
|
||||
try {
|
||||
// If input key has new encrypted prefix (sk-sim-), only check against encrypted storage
|
||||
if (isEncryptedApiKeyFormat(inputKey)) {
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
return false
|
||||
}
|
||||
}
|
||||
// New format keys should never match against plain text storage
|
||||
return false
|
||||
}
|
||||
|
||||
// If input key has legacy prefix (sim_), check both encrypted and plain text
|
||||
if (isLegacyApiKeyFormat(inputKey)) {
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
// Fall through to plain text comparison if decryption fails
|
||||
}
|
||||
}
|
||||
// Legacy format can match against plain text storage
|
||||
return inputKey === storedKey
|
||||
}
|
||||
|
||||
// If no recognized prefix, fall back to original behavior
|
||||
if (isEncryptedKey(storedKey)) {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(storedKey)
|
||||
return inputKey === decrypted
|
||||
} catch (decryptError) {
|
||||
logger.error('Failed to decrypt stored API key:', { error: decryptError })
|
||||
}
|
||||
}
|
||||
|
||||
return inputKey === storedKey
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error:', { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an API key for secure storage
|
||||
* @param apiKey - The plain text API key to encrypt
|
||||
* @returns Promise<string> - The encrypted key
|
||||
*/
|
||||
export async function encryptApiKeyForStorage(apiKey: string): Promise<string> {
|
||||
try {
|
||||
const { encrypted } = await encryptApiKey(apiKey)
|
||||
return encrypted
|
||||
} catch (error) {
|
||||
logger.error('API key encryption error:', { error })
|
||||
throw new Error('Failed to encrypt API key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new API key
|
||||
* @param useStorage - Whether to encrypt the key before storage (default: true)
|
||||
* @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version
|
||||
*/
|
||||
export async function createApiKey(useStorage = true): Promise<{
|
||||
key: string
|
||||
encryptedKey?: string
|
||||
}> {
|
||||
try {
|
||||
const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined
|
||||
|
||||
const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey()
|
||||
|
||||
if (useStorage) {
|
||||
const encryptedKey = await encryptApiKeyForStorage(plainKey)
|
||||
return { key: plainKey, encryptedKey }
|
||||
}
|
||||
|
||||
return { key: plainKey }
|
||||
} catch (error) {
|
||||
logger.error('API key creation error:', { error })
|
||||
throw new Error('Failed to create API key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an API key from storage for display purposes
|
||||
* @param encryptedKey - The encrypted API key from the database
|
||||
* @returns Promise<string> - The decrypted API key
|
||||
*/
|
||||
export async function decryptApiKeyFromStorage(encryptedKey: string): Promise<string> {
|
||||
try {
|
||||
const { decrypted } = await decryptApiKey(encryptedKey)
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
logger.error('API key decryption error:', { error })
|
||||
throw new Error('Failed to decrypt API key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last 4 characters of an API key for display purposes
|
||||
* @param apiKey - The API key (plain text)
|
||||
* @returns string - The last 4 characters
|
||||
*/
|
||||
export function getApiKeyLast4(apiKey: string): string {
|
||||
return apiKey.slice(-4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the display format for an API key showing prefix and last 4 characters
|
||||
* @param encryptedKey - The encrypted API key from the database
|
||||
* @returns Promise<string> - The display format like "sk-sim-...r6AA"
|
||||
*/
|
||||
export async function getApiKeyDisplayFormat(encryptedKey: string): Promise<string> {
|
||||
try {
|
||||
if (isEncryptedKey(encryptedKey)) {
|
||||
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
|
||||
return formatApiKeyForDisplay(decryptedKey)
|
||||
}
|
||||
// For plain text keys (legacy), format directly
|
||||
return formatApiKeyForDisplay(encryptedKey)
|
||||
} catch (error) {
|
||||
logger.error('Failed to format API key for display:', { error })
|
||||
return '****'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an API key for display showing prefix and last 4 characters
|
||||
* @param apiKey - The API key (plain text)
|
||||
* @returns string - The display format like "sk-sim-...r6AA" or "sim_...r6AA"
|
||||
*/
|
||||
export function formatApiKeyForDisplay(apiKey: string): string {
|
||||
if (isEncryptedApiKeyFormat(apiKey)) {
|
||||
// For sk-sim- format: "sk-sim-...r6AA"
|
||||
const last4 = getApiKeyLast4(apiKey)
|
||||
return `sk-sim-...${last4}`
|
||||
}
|
||||
if (isLegacyApiKeyFormat(apiKey)) {
|
||||
// For sim_ format: "sim_...r6AA"
|
||||
const last4 = getApiKeyLast4(apiKey)
|
||||
return `sim_...${last4}`
|
||||
}
|
||||
// Unknown format, just show last 4
|
||||
const last4 = getApiKeyLast4(apiKey)
|
||||
return `...${last4}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last 4 characters of an encrypted API key by decrypting it first
|
||||
* @param encryptedKey - The encrypted API key from the database
|
||||
* @returns Promise<string> - The last 4 characters
|
||||
*/
|
||||
export async function getEncryptedApiKeyLast4(encryptedKey: string): Promise<string> {
|
||||
try {
|
||||
if (isEncryptedKey(encryptedKey)) {
|
||||
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
|
||||
return getApiKeyLast4(decryptedKey)
|
||||
}
|
||||
// For plain text keys (legacy), return last 4 directly
|
||||
return getApiKeyLast4(encryptedKey)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get last 4 characters of API key:', { error })
|
||||
return '****'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates API key format (basic validation)
|
||||
* @param apiKey - The API key to validate
|
||||
* @returns boolean - true if the format appears valid
|
||||
*/
|
||||
export function isValidApiKeyFormat(apiKey: string): boolean {
|
||||
return typeof apiKey === 'string' && apiKey.length > 10 && apiKey.length < 200
|
||||
}
|
||||
252
apps/sim/lib/api-key/service.ts
Normal file
252
apps/sim/lib/api-key/service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { authenticateApiKey } from '@/lib/api-key/auth'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, workspace } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('ApiKeyService')
|
||||
|
||||
export interface ApiKeyAuthOptions {
|
||||
userId?: string
|
||||
workspaceId?: string
|
||||
keyTypes?: ('personal' | 'workspace')[]
|
||||
}
|
||||
|
||||
export interface ApiKeyAuthResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
keyId?: string
|
||||
keyType?: 'personal' | 'workspace'
|
||||
workspaceId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate an API key from header with flexible filtering options
|
||||
*/
|
||||
export async function authenticateApiKeyFromHeader(
|
||||
apiKeyHeader: string,
|
||||
options: ApiKeyAuthOptions = {}
|
||||
): Promise<ApiKeyAuthResult> {
|
||||
if (!apiKeyHeader) {
|
||||
return { success: false, error: 'API key required' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Build query based on options
|
||||
let query = db
|
||||
.select({
|
||||
id: apiKeyTable.id,
|
||||
userId: apiKeyTable.userId,
|
||||
workspaceId: apiKeyTable.workspaceId,
|
||||
type: apiKeyTable.type,
|
||||
key: apiKeyTable.key,
|
||||
expiresAt: apiKeyTable.expiresAt,
|
||||
})
|
||||
.from(apiKeyTable)
|
||||
|
||||
// Add workspace join if needed for workspace keys
|
||||
if (options.workspaceId || options.keyTypes?.includes('workspace')) {
|
||||
query = query.leftJoin(workspace, eq(apiKeyTable.workspaceId, workspace.id)) as any
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
const conditions = []
|
||||
|
||||
if (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 === 1) {
|
||||
conditions.push(eq(apiKeyTable.type, options.keyTypes[0]))
|
||||
} else {
|
||||
// For multiple types, we'll filter in memory since drizzle's inArray is complex here
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions)) as any
|
||||
}
|
||||
|
||||
const keyRecords = await query
|
||||
|
||||
// Filter by keyTypes in memory if multiple types specified
|
||||
const filteredRecords =
|
||||
options.keyTypes?.length && options.keyTypes.length > 1
|
||||
? keyRecords.filter((record) => options.keyTypes!.includes(record.type as any))
|
||||
: keyRecords
|
||||
|
||||
// Authenticate each key
|
||||
for (const storedKey of filteredRecords) {
|
||||
// Skip expired keys
|
||||
if (storedKey.expiresAt && storedKey.expiresAt < new Date()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await authenticateApiKey(apiKeyHeader, storedKey.key)
|
||||
if (isValid) {
|
||||
return {
|
||||
success: true,
|
||||
userId: storedKey.userId,
|
||||
keyId: storedKey.id,
|
||||
keyType: storedKey.type as 'personal' | 'workspace',
|
||||
workspaceId: storedKey.workspaceId || undefined,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error authenticating API key:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'Invalid API key' }
|
||||
} catch (error) {
|
||||
logger.error('API key authentication error:', error)
|
||||
return { success: false, error: 'Authentication failed' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the last used timestamp for an API key
|
||||
*/
|
||||
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
|
||||
try {
|
||||
await db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.id, keyId))
|
||||
} catch (error) {
|
||||
logger.error('Error updating API key last used:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API encryption key from the environment
|
||||
* @returns The API encryption key
|
||||
*/
|
||||
function getApiEncryptionKey(): Buffer | null {
|
||||
const key = env.API_ENCRYPTION_KEY
|
||||
if (!key) {
|
||||
logger.warn(
|
||||
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (key.length !== 64) {
|
||||
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
|
||||
}
|
||||
return Buffer.from(key, 'hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts an API key using the dedicated API encryption key
|
||||
* @param apiKey - The API key to encrypt
|
||||
* @returns A promise that resolves to an object containing the encrypted API key and IV
|
||||
*/
|
||||
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||
const key = getApiEncryptionKey()
|
||||
|
||||
// If no API encryption key is set, return the key as-is for backward compatibility
|
||||
if (!key) {
|
||||
return { encrypted: apiKey, iv: '' }
|
||||
}
|
||||
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
let encrypted = cipher.update(apiKey, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
const authTag = cipher.getAuthTag()
|
||||
|
||||
// Format: iv:encrypted:authTag
|
||||
return {
|
||||
encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`,
|
||||
iv: iv.toString('hex'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts an API key using the dedicated API encryption key
|
||||
* @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text
|
||||
* @returns A promise that resolves to an object containing the decrypted API key
|
||||
*/
|
||||
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
|
||||
// Check if this is actually encrypted (contains colons)
|
||||
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
|
||||
// This is a plain text key, return as-is
|
||||
return { decrypted: encryptedValue }
|
||||
}
|
||||
|
||||
const key = getApiEncryptionKey()
|
||||
|
||||
// If no API encryption key is set, assume it's plain text
|
||||
if (!key) {
|
||||
return { decrypted: encryptedValue }
|
||||
}
|
||||
|
||||
const parts = encryptedValue.split(':')
|
||||
const ivHex = parts[0]
|
||||
const authTagHex = parts[parts.length - 1]
|
||||
const encrypted = parts.slice(1, -1).join(':')
|
||||
|
||||
if (!ivHex || !encrypted || !authTagHex) {
|
||||
throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"')
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const authTag = Buffer.from(authTagHex, 'hex')
|
||||
|
||||
try {
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(authTag)
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
return { decrypted }
|
||||
} catch (error: unknown) {
|
||||
logger.error('API key decryption error:', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a standardized API key with the 'sim_' prefix (legacy format)
|
||||
* @returns A new API key string
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
return `sim_${nanoid(32)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new encrypted API key with the 'sk-sim-' prefix
|
||||
* @returns A new encrypted API key string
|
||||
*/
|
||||
export function generateEncryptedApiKey(): string {
|
||||
return `sk-sim-${nanoid(32)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an API key uses the new encrypted format based on prefix
|
||||
* @param apiKey - The API key to check
|
||||
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
|
||||
*/
|
||||
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
|
||||
return apiKey.startsWith('sk-sim-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an API key uses the legacy format based on prefix
|
||||
* @param apiKey - The API key to check
|
||||
* @returns true if the key uses the legacy format (sim_ prefix)
|
||||
*/
|
||||
export function isLegacyApiKeyFormat(apiKey: string): boolean {
|
||||
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyInternalToken } from '@/lib/auth/internal'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable, workflow } from '@/db/schema'
|
||||
import { workflow } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('HybridAuth')
|
||||
|
||||
@@ -105,19 +106,16 @@ export async function checkHybridAuth(
|
||||
// 3. Try API key auth
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const [apiKeyRecord] = await db
|
||||
.select({ userId: apiKeyTable.userId })
|
||||
.from(apiKeyTable)
|
||||
.where(eq(apiKeyTable.key, apiKeyHeader))
|
||||
.limit(1)
|
||||
|
||||
if (apiKeyRecord) {
|
||||
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (result.success) {
|
||||
await updateApiKeyLastUsed(result.keyId!)
|
||||
return {
|
||||
success: true,
|
||||
userId: apiKeyRecord.userId,
|
||||
userId: result.userId!,
|
||||
authType: 'api_key',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid API key',
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { apiKey as apiKeyTable } from '@/db/schema'
|
||||
|
||||
export type { NotificationStatus } from '@/lib/copilot/types'
|
||||
|
||||
@@ -62,14 +60,10 @@ export async function authenticateCopilotRequest(req: NextRequest): Promise<Copi
|
||||
if (!userId) {
|
||||
const apiKeyHeader = req.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const [apiKeyRecord] = await db
|
||||
.select({ userId: apiKeyTable.userId })
|
||||
.from(apiKeyTable)
|
||||
.where(eq(apiKeyTable.key, apiKeyHeader))
|
||||
.limit(1)
|
||||
|
||||
if (apiKeyRecord) {
|
||||
userId = apiKeyRecord.userId
|
||||
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (result.success) {
|
||||
userId = result.userId!
|
||||
await updateApiKeyLastUsed(result.keyId!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { quickValidateEmail, validateEmail } from './validation'
|
||||
|
||||
describe('Email Validation', () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const env = createEnv({
|
||||
ALLOWED_LOGIN_EMAILS: z.string().optional(), // Comma-separated list of allowed email addresses for login
|
||||
ALLOWED_LOGIN_DOMAINS: z.string().optional(), // Comma-separated list of allowed email domains for login
|
||||
ENCRYPTION_KEY: z.string().min(32), // Key for encrypting sensitive data
|
||||
API_ENCRYPTION_KEY: z.string().min(32).optional(), // Dedicated key for encrypting API keys (optional for OSS)
|
||||
INTERNAL_API_SECRET: z.string().min(32), // Secret for internal API authentication
|
||||
|
||||
// Copilot
|
||||
@@ -87,7 +88,6 @@ export const env = createEnv({
|
||||
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
|
||||
|
||||
// External Services
|
||||
JWT_SECRET: z.string().min(1).optional(), // JWT signing secret for custom tokens
|
||||
BROWSERBASE_API_KEY: z.string().min(1).optional(), // Browserbase API key for browser automation
|
||||
BROWSERBASE_PROJECT_ID: z.string().min(1).optional(), // Browserbase project ID
|
||||
GITHUB_TOKEN: z.string().optional(), // GitHub personal access token for API access
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Tests for schedule utility functions
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
type BlockState,
|
||||
calculateNextRunTime,
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('Azure Blob Storage Client', () => {
|
||||
{ input: '', expected: 'file' },
|
||||
]
|
||||
|
||||
test.each(testCases)('should sanitize "$input" to "$expected"', async ({ input, expected }) => {
|
||||
it.each(testCases)('should sanitize "$input" to "$expected"', async ({ input, expected }) => {
|
||||
const { sanitizeFilenameForMetadata } = await import('@/lib/uploads/blob/blob-client')
|
||||
expect(sanitizeFilenameForMetadata(input)).toBe(expected)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
formatDateTime,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
generateApiKey,
|
||||
getInvalidCharacters,
|
||||
getTimezoneAbbreviation,
|
||||
isValidName,
|
||||
@@ -44,25 +43,6 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateApiKey', () => {
|
||||
it.concurrent('should generate API key with sim_ prefix', () => {
|
||||
const key = generateApiKey()
|
||||
expect(key).toMatch(/^sim_/)
|
||||
})
|
||||
|
||||
it.concurrent('should generate unique API keys for each call', () => {
|
||||
const key1 = generateApiKey()
|
||||
const key2 = generateApiKey()
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it.concurrent('should generate API keys of correct length', () => {
|
||||
const key = generateApiKey()
|
||||
// Expected format: 'sim_' + 32 random characters
|
||||
expect(key.length).toBe(36)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cn (class name utility)', () => {
|
||||
it.concurrent('should merge class names correctly', () => {
|
||||
const result = cn('class1', 'class2')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -265,14 +264,6 @@ export function formatDuration(durationMs: number): string {
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a standardized API key with the 'sim_' prefix
|
||||
* @returns A new API key string
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
return `sim_${nanoid(32)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a secure random password
|
||||
* @param length - The length of the password (default: 24)
|
||||
|
||||
@@ -3,14 +3,48 @@ import { NextResponse } from 'next/server'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { db } from '@/db'
|
||||
import { userStats, workflow as workflowTable } from '@/db/schema'
|
||||
import { apiKey, userStats, workflow as workflowTable } from '@/db/schema'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowUtils')
|
||||
|
||||
export async function getWorkflowById(id: string) {
|
||||
const workflows = await db.select().from(workflowTable).where(eq(workflowTable.id, id)).limit(1)
|
||||
const workflows = await db
|
||||
.select({
|
||||
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,
|
||||
pinnedApiKey: {
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: apiKey.key,
|
||||
type: apiKey.type,
|
||||
workspaceId: apiKey.workspaceId,
|
||||
},
|
||||
})
|
||||
.from(workflowTable)
|
||||
.leftJoin(apiKey, eq(workflowTable.pinnedApiKeyId, apiKey.id))
|
||||
.where(eq(workflowTable.id, id))
|
||||
.limit(1)
|
||||
|
||||
return workflows[0]
|
||||
}
|
||||
|
||||
@@ -45,7 +79,7 @@ export async function updateWorkflowRunCounts(workflowId: string, runs = 1) {
|
||||
await db
|
||||
.update(workflowTable)
|
||||
.set({
|
||||
runCount: workflow.runCount + runs,
|
||||
runCount: (workflow.runCount as number) + runs,
|
||||
lastRunAt: new Date(),
|
||||
})
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* converting between workflow state (blocks, edges, loops) and serialized format
|
||||
* used by the executor.
|
||||
*/
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getProviderFromModel } from '@/providers/utils'
|
||||
import {
|
||||
createAgentWithToolsWorkflowState,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 1. Early validation (serialization) - user-only required fields
|
||||
* 2. Late validation (tool execution) - user-or-llm required fields
|
||||
*/
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Serializer } from '@/serializer/index'
|
||||
import { validateRequiredParametersAfterMerge } from '@/tools/utils'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { convertLoopBlockToLoop } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
|
||||
22
apps/sim/test-self-hosting.ts
Normal file
22
apps/sim/test-self-hosting.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createApiKey } from './lib/api-key/auth'
|
||||
|
||||
console.log('=== Testing self-hosting scenario (no API_ENCRYPTION_KEY) ===')
|
||||
|
||||
// Check environment
|
||||
console.log('ENCRYPTION_KEY:', `${process.env.ENCRYPTION_KEY?.slice(0, 10)}...`)
|
||||
console.log('API_ENCRYPTION_KEY:', process.env.API_ENCRYPTION_KEY)
|
||||
|
||||
// Ensure API_ENCRYPTION_KEY is not set
|
||||
process.env.API_ENCRYPTION_KEY = undefined
|
||||
console.log('API_ENCRYPTION_KEY after delete:', process.env.API_ENCRYPTION_KEY)
|
||||
|
||||
try {
|
||||
const result = await createApiKey(true)
|
||||
console.log('Key generated:', !!result.key)
|
||||
console.log('Encrypted key generated:', !!result.encryptedKey)
|
||||
console.log('Encrypted key value:', result.encryptedKey)
|
||||
console.log('Are they the same?', result.key === result.encryptedKey)
|
||||
console.log('Would validation pass?', !!result.encryptedKey)
|
||||
} catch (error) {
|
||||
console.error('Error in createApiKey:', error)
|
||||
}
|
||||
6
bun.lock
6
bun.lock
@@ -7,6 +7,7 @@
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"bcryptjs": "3.0.2",
|
||||
"geist": "^1.4.2",
|
||||
"mongodb": "6.19.0",
|
||||
"react-colorful": "5.6.1",
|
||||
@@ -16,6 +17,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0-beta.5",
|
||||
"@next/env": "^15.4.1",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.5.6",
|
||||
@@ -1333,6 +1335,8 @@
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
@@ -1627,6 +1631,8 @@
|
||||
|
||||
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
|
||||
|
||||
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
|
||||
|
||||
"better-auth": ["better-auth@1.3.7", "", { "dependencies": { "@better-auth/utils": "0.2.6", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.8.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "^1.0.13", "defu": "^6.1.4", "jose": "^5.10.0", "kysely": "^0.28.5", "nanostores": "^0.11.4" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-/1fEyx2SGgJQM5ujozDCh9eJksnVkNU/J7Fk/tG5Y390l8nKbrPvqiFlCjlMM+scR+UABJbQzA6An7HT50LHyQ=="],
|
||||
|
||||
"better-call": ["better-call@1.0.16", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-42dgJ1rOtc0anOoxjXPOWuel/Z/4aeO7EJ2SiXNwvlkySSgjXhNjAjTMWa8DL1nt6EXS3jl3VKC3mPsU/lUgVA=="],
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@linear/sdk": "40.0.0",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"bcryptjs": "3.0.2",
|
||||
"geist": "^1.4.2",
|
||||
"mongodb": "6.19.0",
|
||||
"react-colorful": "5.6.1",
|
||||
@@ -44,6 +45,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.0-beta.5",
|
||||
"@next/env": "^15.4.1",
|
||||
"@types/bcryptjs": "3.0.0",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.5.6"
|
||||
|
||||
Reference in New Issue
Block a user