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:
Waleed
2025-09-12 11:46:47 -07:00
committed by GitHub
parent 3798c56e8c
commit 065fc5b87b
38 changed files with 8693 additions and 499 deletions

View File

@@ -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

View File

@@ -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,
})
})
}
}
}
}

View File

@@ -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 })

View File

@@ -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 })

View File

@@ -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,

View File

@@ -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))

View File

@@ -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)

View File

@@ -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 }

View 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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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&apos;t store the complete key. You won&apos;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>
)

View File

@@ -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>
)

View File

@@ -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`
}

View File

@@ -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'
/>

View File

@@ -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'>

View File

@@ -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>

View 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";

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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(),

View File

@@ -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'

View 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
}

View 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-')
}

View File

@@ -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',

View File

@@ -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!)
}
}
}

View File

@@ -1,3 +1,4 @@
import { describe, expect, it } from 'vitest'
import { quickValidateEmail, validateEmail } from './validation'
describe('Email Validation', () => {

View File

@@ -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

View File

@@ -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,

View File

@@ -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)
})

View File

@@ -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')

View File

@@ -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)

View File

@@ -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))

View File

@@ -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,

View File

@@ -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'

View File

@@ -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'

View 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)
}

View File

@@ -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=="],

View File

@@ -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"