mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(workspace-vars): add workspace scoped environment + fix cancellation of assoc. workspace invites if org invite cancelled (#1208)
* feat(env-vars): workspace scoped environment variables * fix cascade delete or workspace invite if org invite with attached workspace invites are created * remove redundant refetch * feat(env-vars): workspace scoped environment variables * fix redirect for invitation error, remove check for validated emails on workspace invitation accept * styling improvements * remove random migration code * stronger typing, added helpers, parallelized envvar encryption --------- Co-authored-by: waleedlatif1 <walif6@gmail.com>
This commit is contained in:
committed by
GitHub
parent
5bbb349d8a
commit
9ea7ea79e9
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { isDev } from '@/lib/environment'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -12,7 +13,7 @@ import { getEmailDomain } from '@/lib/urls/utils'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { db } from '@/db'
|
||||
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
|
||||
import { chat, userStats, workflow } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { Serializer } from '@/serializer'
|
||||
@@ -453,18 +454,21 @@ export async function executeWorkflowForChat(
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Get user environment variables for this workflow
|
||||
// Get user environment variables with workspace precedence
|
||||
let envVars: Record<string, string> = {}
|
||||
try {
|
||||
const envResult = await db
|
||||
.select()
|
||||
.from(envTable)
|
||||
.where(eq(envTable.userId, deployment.userId))
|
||||
const wfWorkspaceRow = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (envResult.length > 0 && envResult[0].variables) {
|
||||
envVars = envResult[0].variables as Record<string, string>
|
||||
}
|
||||
const workspaceId = wfWorkspaceRow[0]?.workspaceId || undefined
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
deployment.userId,
|
||||
workspaceId
|
||||
)
|
||||
envVars = { ...personalEncrypted, ...workspaceEncrypted }
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Could not fetch environment variables:`, error)
|
||||
}
|
||||
|
||||
@@ -31,14 +31,12 @@ export async function POST(req: NextRequest) {
|
||||
const { variables } = EnvVarSchema.parse(body)
|
||||
|
||||
// Encrypt all variables
|
||||
const encryptedVariables = await Object.entries(variables).reduce(
|
||||
async (accPromise, [key, value]) => {
|
||||
const acc = await accPromise
|
||||
const encryptedVariables = await Promise.all(
|
||||
Object.entries(variables).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return { ...acc, [key]: encrypted }
|
||||
},
|
||||
Promise.resolve({})
|
||||
)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
// Replace all environment variables for user
|
||||
await db
|
||||
|
||||
@@ -120,14 +120,12 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Only encrypt the variables that are new or changed
|
||||
const newlyEncryptedVariables = await Object.entries(variablesToEncrypt).reduce(
|
||||
async (accPromise, [key, value]) => {
|
||||
const acc = await accPromise
|
||||
const newlyEncryptedVariables = await Promise.all(
|
||||
Object.entries(variablesToEncrypt).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return { ...acc, [key]: encrypted }
|
||||
},
|
||||
Promise.resolve({})
|
||||
)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
// Merge existing encrypted variables with newly encrypted ones
|
||||
const finalEncryptedVariables = { ...existingEncryptedVariables, ...newlyEncryptedVariables }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
getEmailSubject,
|
||||
@@ -284,6 +284,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const workspaceInvitationIds: string[] = []
|
||||
if (isBatch && validWorkspaceInvitations.length > 0) {
|
||||
for (const email of emailsToInvite) {
|
||||
const orgInviteForEmail = invitationsToCreate.find((inv) => inv.email === email)
|
||||
for (const wsInvitation of validWorkspaceInvitations) {
|
||||
const wsInvitationId = randomUUID()
|
||||
const token = randomUUID()
|
||||
@@ -297,6 +298,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
status: 'pending',
|
||||
token,
|
||||
permissions: wsInvitation.permission,
|
||||
orgInvitationId: orgInviteForEmail?.id,
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -467,9 +469,7 @@ export async function DELETE(
|
||||
// Cancel the invitation
|
||||
const result = await db
|
||||
.update(invitation)
|
||||
.set({
|
||||
status: 'cancelled',
|
||||
})
|
||||
.set({ status: 'cancelled' })
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.id, invitationId),
|
||||
@@ -486,6 +486,26 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Also cancel any linked workspace invitations created as part of the batch
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
|
||||
|
||||
// Legacy fallback: cancel any pending workspace invitations for the same email
|
||||
// that do not have an orgInvitationId and were created by the same inviter
|
||||
await db
|
||||
.update(workspaceInvitation)
|
||||
.set({ status: 'cancelled' })
|
||||
.where(
|
||||
and(
|
||||
isNull(workspaceInvitation.orgInvitationId),
|
||||
eq(workspaceInvitation.email, result[0].email),
|
||||
eq(workspaceInvitation.status, 'pending'),
|
||||
eq(workspaceInvitation.inviterId, session.user.id)
|
||||
)
|
||||
)
|
||||
|
||||
logger.info('Organization invitation cancelled', {
|
||||
organizationId,
|
||||
invitationId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/env'
|
||||
@@ -82,16 +82,6 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user's email is verified
|
||||
if (!userData[0].emailVerified) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData[0].email}) before accepting invitations.`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the email matches the current user
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.redirect(
|
||||
@@ -137,14 +127,21 @@ export async function GET(req: NextRequest) {
|
||||
// Mark organization invitation as accepted
|
||||
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
|
||||
|
||||
// Find and accept any pending workspace invitations for the same email
|
||||
// Find and accept any pending workspace invitations linked to this org invite.
|
||||
// For backward compatibility, also include legacy pending invites by email with no org link.
|
||||
const workspaceInvitations = await tx
|
||||
.select()
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.email, orgInvitation.email),
|
||||
eq(workspaceInvitation.status, 'pending')
|
||||
eq(workspaceInvitation.status, 'pending'),
|
||||
or(
|
||||
eq(workspaceInvitation.orgInvitationId, invitationId),
|
||||
and(
|
||||
isNull(workspaceInvitation.orgInvitationId),
|
||||
eq(workspaceInvitation.email, orgInvitation.email)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -264,17 +261,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user's email is verified
|
||||
if (!userData[0].emailVerified) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Email not verified',
|
||||
message: `You must verify your email address (${userData[0].email}) before accepting invitations.`,
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
if (orgInvitation.email !== session.user.email) {
|
||||
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -17,13 +18,7 @@ import { decryptSecret } from '@/lib/utils'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import {
|
||||
environment as environmentTable,
|
||||
subscription,
|
||||
userStats,
|
||||
workflow,
|
||||
workflowSchedule,
|
||||
} from '@/db/schema'
|
||||
import { subscription, userStats, workflow, workflowSchedule } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { RateLimiter } from '@/services/queue'
|
||||
@@ -236,20 +231,15 @@ export async function GET() {
|
||||
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// Retrieve environment variables for this user (if any).
|
||||
const [userEnv] = await db
|
||||
.select()
|
||||
.from(environmentTable)
|
||||
.where(eq(environmentTable.userId, workflowRecord.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userEnv) {
|
||||
logger.debug(
|
||||
`[${requestId}] No environment record found for user ${workflowRecord.userId}. Proceeding with empty variables.`
|
||||
)
|
||||
}
|
||||
|
||||
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
|
||||
// Retrieve environment variables with workspace precedence
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
workflowRecord.userId,
|
||||
workflowRecord.workspaceId || undefined
|
||||
)
|
||||
const variables = EnvVarsSchema.parse({
|
||||
...personalEncrypted,
|
||||
...workspaceEncrypted,
|
||||
})
|
||||
|
||||
const currentBlockStates = await Object.entries(mergedStates).reduce(
|
||||
async (accPromise, [id, block]) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment as environmentTable, subscription, userStats } from '@/db/schema'
|
||||
import { subscription, userStats } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import {
|
||||
@@ -64,7 +65,12 @@ class UsageLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWorkflow(workflow: any, requestId: string, input?: any): Promise<any> {
|
||||
async function executeWorkflow(
|
||||
workflow: any,
|
||||
requestId: string,
|
||||
input?: any,
|
||||
executingUserId?: string
|
||||
): Promise<any> {
|
||||
const workflowId = workflow.id
|
||||
const executionId = uuidv4()
|
||||
|
||||
@@ -127,23 +133,15 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any): P
|
||||
// Use the same execution flow as in scheduled executions
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
|
||||
// Fetch the user's environment variables (if any)
|
||||
const [userEnv] = await db
|
||||
.select()
|
||||
.from(environmentTable)
|
||||
.where(eq(environmentTable.userId, workflow.userId))
|
||||
.limit(1)
|
||||
|
||||
if (!userEnv) {
|
||||
logger.debug(
|
||||
`[${requestId}] No environment record found for user ${workflow.userId}. Proceeding with empty variables.`
|
||||
)
|
||||
}
|
||||
|
||||
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
|
||||
// Load personal (for the executing user) and workspace env (workspace overrides personal)
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
executingUserId || workflow.userId,
|
||||
workflow.workspaceId || undefined
|
||||
)
|
||||
const variables = EnvVarsSchema.parse({ ...personalEncrypted, ...workspaceEncrypted })
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId: workflow.userId,
|
||||
userId: executingUserId || workflow.userId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
variables,
|
||||
})
|
||||
@@ -400,7 +398,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeWorkflow(validation.workflow, requestId, undefined)
|
||||
const result = await executeWorkflow(
|
||||
validation.workflow,
|
||||
requestId,
|
||||
undefined,
|
||||
// Executing user (manual run): if session present, use that user for fallback
|
||||
(await getSession())?.user?.id || undefined
|
||||
)
|
||||
|
||||
// Check if the workflow execution contains a response block output
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
@@ -589,7 +593,12 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
const result = await executeWorkflow(validation.workflow, requestId, input)
|
||||
const result = await executeWorkflow(
|
||||
validation.workflow,
|
||||
requestId,
|
||||
input,
|
||||
authenticatedUserId
|
||||
)
|
||||
|
||||
const hasResponseBlock = workflowHasResponseBlock(result)
|
||||
if (hasResponseBlock) {
|
||||
|
||||
232
apps/sim/app/api/workspaces/[id]/environment/route.ts
Normal file
232
apps/sim/app/api/workspaces/[id]/environment/route.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { eq } 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 { decryptSecret, encryptSecret } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment, workspace, workspaceEnvironment } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('WorkspaceEnvironmentAPI')
|
||||
|
||||
const UpsertSchema = z.object({
|
||||
variables: z.record(z.string()),
|
||||
})
|
||||
|
||||
const DeleteSchema = z.object({
|
||||
keys: z.array(z.string()).min(1),
|
||||
})
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Validate workspace exists
|
||||
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 })
|
||||
}
|
||||
|
||||
// Require any permission to read
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Workspace env (encrypted)
|
||||
const wsEnvRow = await db
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const wsEncrypted: Record<string, string> = (wsEnvRow[0]?.variables as any) || {}
|
||||
|
||||
// Personal env (encrypted)
|
||||
const personalRow = await db
|
||||
.select()
|
||||
.from(environment)
|
||||
.where(eq(environment.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const personalEncrypted: Record<string, string> = (personalRow[0]?.variables as any) || {}
|
||||
|
||||
// Decrypt both for UI
|
||||
const decryptAll = async (src: Record<string, string>) => {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(v)
|
||||
out[k] = decrypted
|
||||
} catch {
|
||||
out[k] = ''
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const [workspaceDecrypted, personalDecrypted] = await Promise.all([
|
||||
decryptAll(wsEncrypted),
|
||||
decryptAll(personalEncrypted),
|
||||
])
|
||||
|
||||
const conflicts = Object.keys(personalDecrypted).filter((k) => k in workspaceDecrypted)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
workspace: workspaceDecrypted,
|
||||
personal: personalDecrypted,
|
||||
conflicts,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workspace env GET error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to load environment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env 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 { variables } = UpsertSchema.parse(body)
|
||||
|
||||
// Read existing encrypted ws vars
|
||||
const existingRows = await db
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const existingEncrypted: Record<string, string> = (existingRows[0]?.variables as any) || {}
|
||||
|
||||
// Encrypt incoming
|
||||
const encryptedIncoming = await Promise.all(
|
||||
Object.entries(variables).map(async ([key, value]) => {
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
return [key, encrypted] as const
|
||||
})
|
||||
).then((entries) => Object.fromEntries(entries))
|
||||
|
||||
const merged = { ...existingEncrypted, ...encryptedIncoming }
|
||||
|
||||
// Upsert by unique workspace_id
|
||||
await db
|
||||
.insert(workspaceEnvironment)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId,
|
||||
variables: merged,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceEnvironment.workspaceId],
|
||||
set: { variables: merged, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to update environment' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace env delete 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 } = DeleteSchema.parse(body)
|
||||
|
||||
const wsRows = await db
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const current: Record<string, string> = (wsRows[0]?.variables as any) || {}
|
||||
let changed = false
|
||||
for (const k of keys) {
|
||||
if (k in current) {
|
||||
delete current[k]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(workspaceEnvironment)
|
||||
.values({
|
||||
id: wsRows[0]?.id || crypto.randomUUID(),
|
||||
workspaceId,
|
||||
variables: current,
|
||||
createdAt: wsRows[0]?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceEnvironment.workspaceId],
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Workspace env DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: error.message || 'Failed to remove environment keys' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -83,16 +83,6 @@ export async function GET(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user's email is verified
|
||||
if (!userData.emailVerified) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData.email}) before accepting invitations.`)}`,
|
||||
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check if the logged-in user's email matches the invitation
|
||||
const isValidMatch = userEmail === invitationEmail
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import { Button } from '@/components/ui/button'
|
||||
import { LoadingAgent } from '@/components/ui/loading-agent'
|
||||
import { client, useSession } from '@/lib/auth-client'
|
||||
import { useBrandConfig } from '@/lib/branding/branding'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('InviteByIDAPI')
|
||||
|
||||
export default function Invite() {
|
||||
const router = useRouter()
|
||||
@@ -102,7 +105,7 @@ export default function Invite() {
|
||||
throw new Error('Invitation not found or has expired')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching invitation:', err)
|
||||
logger.error('Error fetching invitation:', err)
|
||||
setError(err.message || 'Failed to load invitation details')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -117,25 +120,11 @@ export default function Invite() {
|
||||
if (!session?.user) return
|
||||
|
||||
setIsAccepting(true)
|
||||
try {
|
||||
if (invitationType === 'workspace') {
|
||||
// For workspace invites, call the API route with token
|
||||
const response = await fetch(
|
||||
`/api/workspaces/invitations/accept?token=${encodeURIComponent(token || '')}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to accept invitation')
|
||||
}
|
||||
|
||||
setAccepted(true)
|
||||
|
||||
// Redirect to workspace after a brief delay
|
||||
setTimeout(() => {
|
||||
router.push('/workspace')
|
||||
}, 2000)
|
||||
} else {
|
||||
if (invitationType === 'workspace') {
|
||||
window.location.href = `/api/workspaces/invitations/accept?token=${encodeURIComponent(token || '')}`
|
||||
} else {
|
||||
try {
|
||||
// For organization invites, use the client API
|
||||
const response = await client.organization.acceptInvitation({
|
||||
invitationId: inviteId,
|
||||
@@ -157,12 +146,12 @@ export default function Invite() {
|
||||
setTimeout(() => {
|
||||
router.push('/workspace')
|
||||
}, 2000)
|
||||
} catch (err: any) {
|
||||
logger.error('Error accepting invitation:', err)
|
||||
setError(err.message || 'Failed to accept invitation')
|
||||
} finally {
|
||||
setIsAccepting(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error accepting invitation:', err)
|
||||
setError(err.message || 'Failed to accept invitation')
|
||||
} finally {
|
||||
setIsAccepting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Mail, RotateCcw, ShieldX } from 'lucide-react'
|
||||
import { RotateCcw, ShieldX } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@@ -26,10 +26,6 @@ function getErrorMessage(reason: string, details?: string): string {
|
||||
return 'The workspace associated with this invitation could not be found.'
|
||||
case 'user-not-found':
|
||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||
case 'email-not-verified':
|
||||
return details
|
||||
? details
|
||||
: 'You must verify your email address before accepting invitations. Please check your email for a verification link.'
|
||||
case 'already-member':
|
||||
return 'You are already a member of this organization or workspace.'
|
||||
case 'invalid-invitation':
|
||||
@@ -58,8 +54,6 @@ export default function InviteError() {
|
||||
// Provide a fallback message for SSR
|
||||
const displayMessage = errorMessage || 'Loading error details...'
|
||||
|
||||
const isEmailVerificationError = reason === 'email-not-verified'
|
||||
|
||||
const isExpiredError = reason === 'expired'
|
||||
|
||||
return (
|
||||
@@ -88,20 +82,6 @@ export default function InviteError() {
|
||||
</p>
|
||||
|
||||
<div className='flex w-full flex-col gap-3'>
|
||||
{isEmailVerificationError && (
|
||||
<Button
|
||||
variant='default'
|
||||
className='w-full'
|
||||
style={{ backgroundColor: 'var(--brand-primary-hex)', color: 'white' }}
|
||||
asChild
|
||||
>
|
||||
<Link href='/verify'>
|
||||
<Mail className='mr-2 h-4 w-4' />
|
||||
Verify Email
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isExpiredError && (
|
||||
<Button
|
||||
variant='outline'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
@@ -73,7 +74,9 @@ export function Code({
|
||||
onValidationChange,
|
||||
wandConfig,
|
||||
}: CodeProps) {
|
||||
// Determine the AI prompt placeholder based on language
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const aiPromptPlaceholder = useMemo(() => {
|
||||
switch (generationType) {
|
||||
case 'json-schema':
|
||||
@@ -503,6 +506,7 @@ export function Code({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={code}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
@@ -45,6 +46,8 @@ export function ComboBox({
|
||||
config,
|
||||
isWide = false,
|
||||
}: ComboBoxProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlockId)
|
||||
const [storeInitialized, setStoreInitialized] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -508,6 +511,7 @@ export function ComboBox({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={displayValue}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Plus, Trash } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
@@ -52,6 +53,8 @@ export function ConditionInput({
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: ConditionInputProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
@@ -796,6 +799,7 @@ export function ConditionInput({
|
||||
searchTerm={block.searchTerm}
|
||||
inputValue={block.value}
|
||||
cursorPosition={block.cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setConditionalBlocks((blocks) =>
|
||||
blocks.map((b) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { ChevronsUpDown, Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
@@ -49,6 +50,8 @@ export function LongInput({
|
||||
onChange,
|
||||
disabled,
|
||||
}: LongInputProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
// Local state for immediate UI updates during streaming
|
||||
const [localContent, setLocalContent] = useState<string>('')
|
||||
|
||||
@@ -457,6 +460,7 @@ export function LongInput({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
@@ -85,6 +86,9 @@ export function ShortInput({
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlockId)
|
||||
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Get ReactFlow instance for zoom control
|
||||
const reactFlowInstance = useReactFlow()
|
||||
|
||||
@@ -199,7 +203,7 @@ export function ShortInput({
|
||||
}, [value])
|
||||
|
||||
// Handle paste events to ensure long values are handled correctly
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const handlePaste = (_e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
// Let the paste happen normally
|
||||
// Then ensure scroll positions are synced after the content is updated
|
||||
setTimeout(() => {
|
||||
@@ -447,6 +451,7 @@ export function ShortInput({
|
||||
searchTerm={searchTerm}
|
||||
inputValue={value?.toString() ?? ''}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { checkEnvVarTrigger, EnvVarDropdown } from '@/components/ui/env-var-dropdown'
|
||||
import { formatDisplayText } from '@/components/ui/formatted-text'
|
||||
@@ -30,6 +31,8 @@ export function Table({
|
||||
previewValue,
|
||||
disabled = false,
|
||||
}: TableProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<TableRow[]>(blockId, subBlockId)
|
||||
|
||||
// Use preview value when in preview mode, otherwise use store value
|
||||
@@ -287,6 +290,7 @@ export function Table({
|
||||
searchTerm={activeCell.searchTerm}
|
||||
inputValue={rows[activeCell.rowIndex].cells[activeCell.column] || ''}
|
||||
cursorPosition={activeCell.cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setActiveCell((prev) => (prev ? { ...prev, showEnvVars: false } : null))
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Code, FileJson, Trash2, Wand2, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -66,6 +67,8 @@ export function CustomToolModal({
|
||||
blockId,
|
||||
initialValues,
|
||||
}: CustomToolModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [activeSection, setActiveSection] = useState<ToolSection>('schema')
|
||||
const [jsonSchema, setJsonSchema] = useState('')
|
||||
const [functionCode, setFunctionCode] = useState('')
|
||||
@@ -1070,6 +1073,7 @@ try {
|
||||
searchTerm={searchTerm}
|
||||
inputValue={functionCode}
|
||||
cursorPosition={cursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
onClose={() => {
|
||||
setShowEnvVars(false)
|
||||
setSearchTerm('')
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useWorkflowExecution() {
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
const { activeWorkflowId, workflows } = useWorkflowRegistry()
|
||||
const { toggleConsole } = useConsoleStore()
|
||||
const { getAllVariables } = useEnvironmentStore()
|
||||
const { getAllVariables, loadWorkspaceEnvironment } = useEnvironmentStore()
|
||||
const { isDebugModeEnabled } = useGeneralStore()
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||
const {
|
||||
@@ -500,6 +500,7 @@ export function useWorkflowExecution() {
|
||||
currentWorkflow,
|
||||
toggleConsole,
|
||||
getAllVariables,
|
||||
loadWorkspaceEnvironment,
|
||||
getVariablesByWorkflowId,
|
||||
isDebugModeEnabled,
|
||||
setIsExecuting,
|
||||
@@ -598,9 +599,12 @@ export function useWorkflowExecution() {
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Get environment variables
|
||||
const envVars = getAllVariables()
|
||||
const envVarValues = Object.entries(envVars).reduce(
|
||||
// Get workspaceId from workflow metadata
|
||||
const workspaceId = activeWorkflowId ? workflows[activeWorkflowId]?.workspaceId : undefined
|
||||
|
||||
// Get environment variables with workspace precedence
|
||||
const personalEnvVars = getAllVariables()
|
||||
const personalEnvValues = Object.entries(personalEnvVars).reduce(
|
||||
(acc, [key, variable]) => {
|
||||
acc[key] = variable.value
|
||||
return acc
|
||||
@@ -608,6 +612,20 @@ export function useWorkflowExecution() {
|
||||
{} as Record<string, string>
|
||||
)
|
||||
|
||||
// Load workspace environment variables if workspaceId exists
|
||||
let workspaceEnvValues: Record<string, string> = {}
|
||||
if (workspaceId) {
|
||||
try {
|
||||
const workspaceData = await loadWorkspaceEnvironment(workspaceId)
|
||||
workspaceEnvValues = workspaceData.workspace || {}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load workspace environment variables:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with workspace taking precedence over personal
|
||||
const envVarValues = { ...personalEnvValues, ...workspaceEnvValues }
|
||||
|
||||
// Get workflow variables
|
||||
const workflowVars = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
|
||||
const workflowVariables = workflowVars.reduce(
|
||||
@@ -644,9 +662,6 @@ export function useWorkflowExecution() {
|
||||
selectedOutputIds = chatStore.getState().getSelectedWorkflowOutput(activeWorkflowId)
|
||||
}
|
||||
|
||||
// Get workspaceId from workflow metadata
|
||||
const workspaceId = activeWorkflowId ? workflows[activeWorkflowId]?.workspaceId : undefined
|
||||
|
||||
// Create executor options
|
||||
const executorOptions: ExecutorOptions = {
|
||||
workflow,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Plus, Search, Share2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment/store'
|
||||
import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types'
|
||||
@@ -22,7 +24,7 @@ import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/s
|
||||
const logger = createLogger('EnvironmentVariables')
|
||||
|
||||
// Constants
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),40px] gap-4'
|
||||
const GRID_COLS = 'grid grid-cols-[minmax(0,1fr),minmax(0,1fr),88px] gap-4'
|
||||
const INITIAL_ENV_VAR: UIEnvironmentVariable = { key: '', value: '' }
|
||||
|
||||
interface UIEnvironmentVariable extends StoreEnvironmentVariable {
|
||||
@@ -38,13 +40,27 @@ export function EnvironmentVariables({
|
||||
onOpenChange,
|
||||
registerCloseHandler,
|
||||
}: EnvironmentVariablesProps) {
|
||||
const { variables, isLoading } = useEnvironmentStore()
|
||||
const {
|
||||
variables,
|
||||
isLoading,
|
||||
loadWorkspaceEnvironment,
|
||||
upsertWorkspaceEnvironment,
|
||||
removeWorkspaceEnvironmentKeys,
|
||||
} = useEnvironmentStore()
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
|
||||
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
|
||||
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
|
||||
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
|
||||
const [workspaceVars, setWorkspaceVars] = useState<Record<string, string>>({})
|
||||
const [conflicts, setConflicts] = useState<string[]>([])
|
||||
const [renamingKey, setRenamingKey] = useState<string | null>(null)
|
||||
const [pendingKeyValue, setPendingKeyValue] = useState<string>('')
|
||||
const [isWorkspaceLoading, setIsWorkspaceLoading] = useState(true)
|
||||
const initialWorkspaceVarsRef = useRef<Record<string, string>>({})
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const pendingClose = useRef(false)
|
||||
@@ -80,8 +96,23 @@ export function EnvironmentVariables({
|
||||
if (!currentMap.has(key)) return true
|
||||
}
|
||||
|
||||
// Workspace diffs
|
||||
const before = initialWorkspaceVarsRef.current
|
||||
const after = workspaceVars
|
||||
const beforeKeys = Object.keys(before)
|
||||
const afterKeys = Object.keys(after)
|
||||
if (beforeKeys.length !== afterKeys.length) return true
|
||||
for (const key of new Set([...beforeKeys, ...afterKeys])) {
|
||||
if (before[key] !== after[key]) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [envVars])
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
// Check if there are any active conflicts
|
||||
const hasConflicts = useMemo(() => {
|
||||
return envVars.some((envVar) => !!envVar.key && Object.hasOwn(workspaceVars, envVar.key))
|
||||
}, [envVars, workspaceVars])
|
||||
|
||||
// Intercept close attempts to check for unsaved changes
|
||||
const handleModalClose = (open: boolean) => {
|
||||
@@ -102,6 +133,31 @@ export function EnvironmentVariables({
|
||||
pendingClose.current = false
|
||||
}, [variables])
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
if (!workspaceId) {
|
||||
setIsWorkspaceLoading(false)
|
||||
return
|
||||
}
|
||||
setIsWorkspaceLoading(true)
|
||||
try {
|
||||
const data = await loadWorkspaceEnvironment(workspaceId)
|
||||
if (!mounted) return
|
||||
setWorkspaceVars(data.workspace || {})
|
||||
initialWorkspaceVarsRef.current = data.workspace || {}
|
||||
setConflicts(data.conflicts || [])
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsWorkspaceLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [workspaceId, loadWorkspaceEnvironment])
|
||||
|
||||
// Register close handler with parent
|
||||
useEffect(() => {
|
||||
if (registerCloseHandler) {
|
||||
@@ -120,13 +176,32 @@ export function EnvironmentVariables({
|
||||
}
|
||||
}, [shouldScrollToBottom])
|
||||
|
||||
// Variable management functions
|
||||
const handleWorkspaceKeyRename = useCallback(
|
||||
(currentKey: string, currentValue: string) => {
|
||||
const newKey = pendingKeyValue.trim()
|
||||
if (!renamingKey || renamingKey !== currentKey) return
|
||||
setRenamingKey(null)
|
||||
if (!newKey || newKey === currentKey) return
|
||||
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[currentKey]
|
||||
next[newKey] = currentValue
|
||||
return next
|
||||
})
|
||||
|
||||
setConflicts((prev) => {
|
||||
const withoutOld = prev.filter((k) => k !== currentKey)
|
||||
const personalHasNew = !!useEnvironmentStore.getState().variables[newKey]
|
||||
return personalHasNew && !withoutOld.includes(newKey) ? [...withoutOld, newKey] : withoutOld
|
||||
})
|
||||
},
|
||||
[pendingKeyValue, renamingKey, setWorkspaceVars, setConflicts]
|
||||
)
|
||||
const addEnvVar = () => {
|
||||
const newVar = { key: '', value: '', id: Date.now() }
|
||||
setEnvVars([...envVars, newVar])
|
||||
// Clear search to ensure the new variable is visible
|
||||
setSearchTerm('')
|
||||
// Trigger scroll to bottom
|
||||
setShouldScrollToBottom(true)
|
||||
}
|
||||
|
||||
@@ -141,7 +216,6 @@ export function EnvironmentVariables({
|
||||
setEnvVars(newEnvVars.length ? newEnvVars : [INITIAL_ENV_VAR])
|
||||
}
|
||||
|
||||
// Input event handlers
|
||||
const handleValueFocus = (index: number, e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocusedValueIndex(index)
|
||||
e.target.scrollLeft = 0
|
||||
@@ -165,9 +239,7 @@ export function EnvironmentVariables({
|
||||
| 'key'
|
||||
| 'value'
|
||||
|
||||
// If we're in a specific input field, check if this looks like environment variable key-value pairs
|
||||
if (inputType) {
|
||||
// Check if this looks like valid environment variable key-value pairs
|
||||
const hasValidEnvVarPattern = lines.some((line) => {
|
||||
const equalIndex = line.indexOf('=')
|
||||
if (equalIndex === -1 || equalIndex === 0) return false
|
||||
@@ -177,14 +249,12 @@ export function EnvironmentVariables({
|
||||
return envVarPattern.test(potentialKey)
|
||||
})
|
||||
|
||||
// If it doesn't look like env vars, treat as single value paste
|
||||
if (!hasValidEnvVarPattern) {
|
||||
handleSingleValuePaste(text, index, inputType)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as key-value pairs
|
||||
handleKeyValuePaste(lines)
|
||||
}
|
||||
|
||||
@@ -197,21 +267,16 @@ export function EnvironmentVariables({
|
||||
const handleKeyValuePaste = (lines: string[]) => {
|
||||
const parsedVars = lines
|
||||
.map((line) => {
|
||||
// Only split on = if it looks like a proper environment variable (key=value format)
|
||||
const equalIndex = line.indexOf('=')
|
||||
|
||||
// If no = found or = is at the beginning, skip this line
|
||||
if (equalIndex === -1 || equalIndex === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const potentialKey = line.substring(0, equalIndex).trim()
|
||||
|
||||
// Check if the potential key looks like an environment variable name
|
||||
// Should be letters, numbers, underscores, and not contain spaces, URLs, etc.
|
||||
const envVarPattern = /^[A-Za-z_][A-Za-z0-9_]*$/
|
||||
|
||||
// If it doesn't look like an env var name, skip this line
|
||||
if (!envVarPattern.test(potentialKey)) {
|
||||
return null
|
||||
}
|
||||
@@ -231,13 +296,10 @@ export function EnvironmentVariables({
|
||||
if (parsedVars.length > 0) {
|
||||
const existingVars = envVars.filter((v) => v.key || v.value)
|
||||
setEnvVars([...existingVars, ...parsedVars])
|
||||
// Scroll to bottom when pasting multiple variables
|
||||
setShouldScrollToBottom(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog management
|
||||
|
||||
const handleCancel = () => {
|
||||
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
|
||||
setShowUnsavedChanges(false)
|
||||
@@ -246,13 +308,11 @@ export function EnvironmentVariables({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// Close modal immediately for optimistic updates
|
||||
setShowUnsavedChanges(false)
|
||||
onOpenChange(false)
|
||||
|
||||
// Convert valid env vars to Record<string, string>
|
||||
const validVariables = envVars
|
||||
.filter((v) => v.key && v.value)
|
||||
.reduce(
|
||||
@@ -263,56 +323,121 @@ export function EnvironmentVariables({
|
||||
{}
|
||||
)
|
||||
|
||||
// Single store update that triggers sync
|
||||
useEnvironmentStore.getState().setVariables(validVariables)
|
||||
|
||||
const before = initialWorkspaceVarsRef.current
|
||||
const after = workspaceVars
|
||||
const toUpsert: Record<string, string> = {}
|
||||
const toDelete: string[] = []
|
||||
|
||||
for (const [k, v] of Object.entries(after)) {
|
||||
if (!(k in before) || before[k] !== v) {
|
||||
toUpsert[k] = v
|
||||
}
|
||||
}
|
||||
for (const k of Object.keys(before)) {
|
||||
if (!(k in after)) toDelete.push(k)
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
if (Object.keys(toUpsert).length) {
|
||||
await upsertWorkspaceEnvironment(workspaceId, toUpsert)
|
||||
}
|
||||
if (toDelete.length) {
|
||||
await removeWorkspaceEnvironmentKeys(workspaceId, toDelete)
|
||||
}
|
||||
}
|
||||
|
||||
initialWorkspaceVarsRef.current = { ...workspaceVars }
|
||||
} catch (error) {
|
||||
logger.error('Failed to save environment variables:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// UI rendering
|
||||
const renderEnvVarRow = (envVar: UIEnvironmentVariable, originalIndex: number) => (
|
||||
<div key={envVar.id || originalIndex} className={`${GRID_COLS} items-center`}>
|
||||
<Input
|
||||
data-input-type='key'
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder='API_KEY'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
name={`env-var-key-${envVar.id || originalIndex}-${Math.random()}`}
|
||||
className='h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<Input
|
||||
data-input-type='value'
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
|
||||
type={focusedValueIndex === originalIndex ? 'text' : 'password'}
|
||||
onFocus={(e) => handleValueFocus(originalIndex, e)}
|
||||
onClick={handleValueClick}
|
||||
onBlur={() => setFocusedValueIndex(null)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder='Enter value'
|
||||
className='allow-scroll h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
name={`env-var-value-${envVar.id || originalIndex}-${Math.random()}`}
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeEnvVar(originalIndex)}
|
||||
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
const renderEnvVarRow = (envVar: UIEnvironmentVariable, originalIndex: number) => {
|
||||
const isConflict = !!envVar.key && Object.hasOwn(workspaceVars, envVar.key)
|
||||
return (
|
||||
<>
|
||||
<div className={`${GRID_COLS} items-center`}>
|
||||
<Input
|
||||
data-input-type='key'
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'key', e.target.value)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder='API_KEY'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
name={`env-var-key-${envVar.id || originalIndex}-${Math.random()}`}
|
||||
className={`h-9 rounded-[8px] border-none px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isConflict ? 'border border-red-500 bg-[#F6D2D2] outline-none ring-0 disabled:bg-[#F6D2D2] disabled:opacity-100 dark:bg-[#442929] disabled:dark:bg-[#442929]' : 'bg-muted'}`}
|
||||
/>
|
||||
<Input
|
||||
data-input-type='value'
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(originalIndex, 'value', e.target.value)}
|
||||
type={focusedValueIndex === originalIndex ? 'text' : 'password'}
|
||||
onFocus={(e) => handleValueFocus(originalIndex, e)}
|
||||
onClick={handleValueClick}
|
||||
onBlur={() => setFocusedValueIndex(null)}
|
||||
onPaste={(e) => handlePaste(e, originalIndex)}
|
||||
placeholder={isConflict ? 'Workspace override active' : 'Enter value'}
|
||||
disabled={isConflict}
|
||||
aria-disabled={isConflict}
|
||||
className={`allow-scroll h-9 rounded-[8px] border-none px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 ${isConflict ? 'cursor-not-allowed border border-red-500 bg-[#F6D2D2] outline-none ring-0 disabled:bg-[#F6D2D2] disabled:opacity-100 dark:bg-[#442929] disabled:dark:bg-[#442929]' : 'bg-muted'}`}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck='false'
|
||||
name={`env-var-value-${envVar.id || originalIndex}-${Math.random()}`}
|
||||
/>
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
disabled={!envVar.key || !envVar.value || isConflict || !workspaceId}
|
||||
onClick={() => {
|
||||
if (!envVar.key || !envVar.value || !workspaceId) return
|
||||
setWorkspaceVars((prev) => ({ ...prev, [envVar.key]: envVar.value }))
|
||||
setConflicts((prev) =>
|
||||
prev.includes(envVar.key) ? prev : [...prev, envVar.key]
|
||||
)
|
||||
removeEnvVar(originalIndex)
|
||||
}}
|
||||
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
|
||||
>
|
||||
<Share2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Make it workspace scoped</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => removeEnvVar(originalIndex)}
|
||||
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete environment variable</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{isConflict && (
|
||||
<div className='col-span-3 mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
Workspace variable with the same name overrides this. Rename your personal key to use
|
||||
it.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative flex h-full flex-col'>
|
||||
@@ -340,7 +465,7 @@ export function EnvironmentVariables({
|
||||
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 ? (
|
||||
{isLoading || isWorkspaceLoading ? (
|
||||
<>
|
||||
{/* Show 3 skeleton rows */}
|
||||
{[1, 2, 3].map((index) => (
|
||||
@@ -353,9 +478,60 @@ export function EnvironmentVariables({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{filteredEnvVars.map(({ envVar, originalIndex }) =>
|
||||
renderEnvVarRow(envVar, originalIndex)
|
||||
)}
|
||||
{/* Workspace section */}
|
||||
<div className='mb-6 space-y-2'>
|
||||
<div className='font-medium text-[13px] text-foreground'>Workspace</div>
|
||||
{Object.keys(workspaceVars).length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No workspace variables yet.</div>
|
||||
) : (
|
||||
Object.entries(workspaceVars).map(([key, value]) => (
|
||||
<div key={key} className={`${GRID_COLS} items-center`}>
|
||||
<Input
|
||||
value={renamingKey === key ? pendingKeyValue : key}
|
||||
onChange={(e) => {
|
||||
if (renamingKey !== key) setRenamingKey(key)
|
||||
setPendingKeyValue(e.target.value)
|
||||
}}
|
||||
onBlur={() => handleWorkspaceKeyRename(key, value)}
|
||||
className='h-9 rounded-[8px] border-none bg-muted px-3 text-sm'
|
||||
/>
|
||||
<Input
|
||||
value={value ? '•'.repeat(value.length) : ''}
|
||||
readOnly
|
||||
className='h-9 rounded-[8px] border-none bg-muted px-3 text-sm'
|
||||
/>
|
||||
<div className='flex justify-end'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => {
|
||||
setWorkspaceVars((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
setConflicts((prev) => prev.filter((k) => k !== key))
|
||||
}}
|
||||
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete environment variable</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Personal section */}
|
||||
<div className='mt-8 mb-2 font-medium text-[13px] text-foreground'> Personal </div>
|
||||
{filteredEnvVars.map(({ envVar, originalIndex }) => (
|
||||
<div key={envVar.id || originalIndex}>{renderEnvVarRow(envVar, originalIndex)}</div>
|
||||
))}
|
||||
{/* Show message when search has no results but there are variables */}
|
||||
{searchTerm.trim() && filteredEnvVars.length === 0 && envVars.length > 0 && (
|
||||
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
|
||||
@@ -386,9 +562,20 @@ export function EnvironmentVariables({
|
||||
Add Variable
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleSave} disabled={!hasChanges} className='h-9 rounded-[8px]'>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || hasConflicts}
|
||||
className={`h-9 rounded-[8px] ${hasConflicts ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{hasConflicts && (
|
||||
<TooltipContent>Resolve all conflicts before saving</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -399,19 +586,35 @@ export function EnvironmentVariables({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes. Do you want to save them before closing?
|
||||
{hasConflicts
|
||||
? 'You have unsaved changes, but conflicts must be resolved before saving. You can discard your changes to close the modal.'
|
||||
: 'You have unsaved changes. Do you want to save them before closing?'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel onClick={handleCancel} className='h-9 w-full rounded-[8px]'>
|
||||
Discard Changes
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleSave}
|
||||
className='h-9 w-full rounded-[8px] transition-all duration-200'
|
||||
>
|
||||
Save Changes
|
||||
</AlertDialogAction>
|
||||
{hasConflicts ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogAction
|
||||
disabled={true}
|
||||
className='h-9 w-full cursor-not-allowed rounded-[8px] opacity-50 transition-all duration-200'
|
||||
>
|
||||
Save Changes
|
||||
</AlertDialogAction>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Resolve all conflicts before saving</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<AlertDialogAction
|
||||
onClick={handleSave}
|
||||
className='h-9 w-full rounded-[8px] transition-all duration-200'
|
||||
>
|
||||
Save Changes
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle, Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
@@ -72,15 +72,6 @@ export function TeamManagement() {
|
||||
const userRole = getUserRole(session?.user?.email)
|
||||
const adminOrOwner = isAdminOrOwner(session?.user?.email)
|
||||
const usedSeats = getUsedSeats()
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!hasLoadedInitialData.current) {
|
||||
loadData()
|
||||
hasLoadedInitialData.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { isHosted } from '@/lib/environment'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Account,
|
||||
ApiKeys,
|
||||
@@ -47,33 +46,34 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const loadSettings = useGeneralStore((state) => state.loadSettings)
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
const hasLoadedGeneral = useRef(false)
|
||||
const environmentCloseHandler = useRef<((open: boolean) => void) | null>(null)
|
||||
const credentialsCloseHandler = useRef<((open: boolean) => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAllSettings() {
|
||||
async function loadGeneralIfNeeded() {
|
||||
if (!open) return
|
||||
|
||||
if (hasLoadedInitialData.current) return
|
||||
|
||||
if (activeSection !== 'general') return
|
||||
if (hasLoadedGeneral.current) return
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await loadSettings()
|
||||
hasLoadedGeneral.current = true
|
||||
hasLoadedInitialData.current = true
|
||||
} catch (error) {
|
||||
logger.error('Error loading settings data:', error)
|
||||
logger.error('Error loading general settings:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
loadAllSettings()
|
||||
void loadGeneralIfNeeded()
|
||||
} else {
|
||||
hasLoadedInitialData.current = false
|
||||
hasLoadedGeneral.current = false
|
||||
}
|
||||
}, [open, loadSettings])
|
||||
}, [open, activeSection, loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = (event: CustomEvent<{ tab: SettingsSection }>) => {
|
||||
@@ -127,49 +127,61 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
|
||||
{/* Content Area */}
|
||||
<div className='flex-1 overflow-y-auto'>
|
||||
<div className={cn('h-full', activeSection === 'general' ? 'block' : 'hidden')}>
|
||||
<General />
|
||||
</div>
|
||||
<div className={cn('h-full', activeSection === 'environment' ? 'block' : 'hidden')}>
|
||||
<EnvironmentVariables
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={(handler) => {
|
||||
environmentCloseHandler.current = handler
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('h-full', activeSection === 'account' ? 'block' : 'hidden')}>
|
||||
<Account onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
<div className={cn('h-full', activeSection === 'credentials' ? 'block' : 'hidden')}>
|
||||
<Credentials
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={(handler) => {
|
||||
credentialsCloseHandler.current = handler
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('h-full', activeSection === 'apikeys' ? 'block' : 'hidden')}>
|
||||
<ApiKeys onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
{isSubscriptionEnabled && (
|
||||
<div className={cn('h-full', activeSection === 'subscription' ? 'block' : 'hidden')}>
|
||||
{activeSection === 'general' && (
|
||||
<div className='h-full'>
|
||||
<General />
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'environment' && (
|
||||
<div className='h-full'>
|
||||
<EnvironmentVariables
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={(handler) => {
|
||||
environmentCloseHandler.current = handler
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'account' && (
|
||||
<div className='h-full'>
|
||||
<Account onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'credentials' && (
|
||||
<div className='h-full'>
|
||||
<Credentials
|
||||
onOpenChange={onOpenChange}
|
||||
registerCloseHandler={(handler) => {
|
||||
credentialsCloseHandler.current = handler
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'apikeys' && (
|
||||
<div className='h-full'>
|
||||
<ApiKeys onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
)}
|
||||
{isSubscriptionEnabled && activeSection === 'subscription' && (
|
||||
<div className='h-full'>
|
||||
<Subscription onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
)}
|
||||
{isBillingEnabled && (
|
||||
<div className={cn('h-full', activeSection === 'team' ? 'block' : 'hidden')}>
|
||||
{isBillingEnabled && activeSection === 'team' && (
|
||||
<div className='h-full'>
|
||||
<TeamManagement />
|
||||
</div>
|
||||
)}
|
||||
{isHosted && (
|
||||
<div className={cn('h-full', activeSection === 'copilot' ? 'block' : 'hidden')}>
|
||||
{isHosted && activeSection === 'copilot' && (
|
||||
<div className='h-full'>
|
||||
<Copilot />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('h-full', activeSection === 'privacy' ? 'block' : 'hidden')}>
|
||||
<Privacy />
|
||||
</div>
|
||||
{activeSection === 'privacy' && (
|
||||
<div className='h-full'>
|
||||
<Privacy />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { task } from '@trigger.dev/sdk'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -10,7 +11,7 @@ import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webho
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment as environmentTable, userStats, webhook } from '@/db/schema'
|
||||
import { userStats, webhook, workflow as workflowTable } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
@@ -69,35 +70,31 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
|
||||
|
||||
const { blocks, edges, loops, parallels } = workflowData
|
||||
|
||||
// Get environment variables (matching workflow-execution pattern)
|
||||
const [userEnv] = await db
|
||||
.select()
|
||||
.from(environmentTable)
|
||||
.where(eq(environmentTable.userId, payload.userId))
|
||||
// Get environment variables with workspace precedence
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, payload.workflowId))
|
||||
.limit(1)
|
||||
const workspaceId = wfRows[0]?.workspaceId || undefined
|
||||
|
||||
let decryptedEnvVars: Record<string, string> = {}
|
||||
if (userEnv) {
|
||||
const decryptionPromises = Object.entries((userEnv.variables as any) || {}).map(
|
||||
async ([key, encryptedValue]) => {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(encryptedValue as string)
|
||||
return [key, decrypted] as const
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}":`, error)
|
||||
throw new Error(`Failed to decrypt environment variable "${key}": ${error.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const decryptedPairs = await Promise.all(decryptionPromises)
|
||||
decryptedEnvVars = Object.fromEntries(decryptedPairs)
|
||||
}
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
payload.userId,
|
||||
workspaceId
|
||||
)
|
||||
const mergedEncrypted = { ...personalEncrypted, ...workspaceEncrypted }
|
||||
const decryptedPairs = await Promise.all(
|
||||
Object.entries(mergedEncrypted).map(async ([key, encrypted]) => {
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
return [key, decrypted] as const
|
||||
})
|
||||
)
|
||||
const decryptedEnvVars: Record<string, string> = Object.fromEntries(decryptedPairs)
|
||||
|
||||
// Start logging session
|
||||
await loggingSession.safeStart({
|
||||
userId: payload.userId,
|
||||
workspaceId: '', // TODO: Get from workflow if needed
|
||||
workspaceId: workspaceId || '',
|
||||
variables: decryptedEnvVars,
|
||||
})
|
||||
|
||||
@@ -179,7 +176,7 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
|
||||
workflowVariables,
|
||||
contextExtensions: {
|
||||
executionId,
|
||||
workspaceId: '',
|
||||
workspaceId: workspaceId || '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -291,7 +288,7 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) {
|
||||
workflowVariables,
|
||||
contextExtensions: {
|
||||
executionId,
|
||||
workspaceId: '', // TODO: Get from workflow if needed - see comment on line 103
|
||||
workspaceId: workspaceId || '',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { task } from '@trigger.dev/sdk'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { checkServerSideUsageLimits } from '@/lib/billing'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
@@ -9,7 +10,7 @@ import { decryptSecret } from '@/lib/utils'
|
||||
import { loadDeployedWorkflowState } from '@/lib/workflows/db-helpers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment as environmentTable, userStats } from '@/db/schema'
|
||||
import { userStats, workflow as workflowTable } from '@/db/schema'
|
||||
import { Executor } from '@/executor'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
@@ -79,35 +80,30 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
||||
{} as Record<string, Record<string, any>>
|
||||
)
|
||||
|
||||
// Get environment variables
|
||||
const [userEnv] = await db
|
||||
.select()
|
||||
.from(environmentTable)
|
||||
.where(eq(environmentTable.userId, payload.userId))
|
||||
// Get environment variables with workspace precedence
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
const workspaceId = wfRows[0]?.workspaceId || undefined
|
||||
|
||||
let decryptedEnvVars: Record<string, string> = {}
|
||||
if (userEnv) {
|
||||
const decryptionPromises = Object.entries((userEnv.variables as any) || {}).map(
|
||||
async ([key, encryptedValue]) => {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(encryptedValue as string)
|
||||
return [key, decrypted] as const
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Failed to decrypt environment variable "${key}":`, error)
|
||||
throw new Error(`Failed to decrypt environment variable "${key}": ${error.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const decryptedPairs = await Promise.all(decryptionPromises)
|
||||
decryptedEnvVars = Object.fromEntries(decryptedPairs)
|
||||
}
|
||||
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
|
||||
payload.userId,
|
||||
workspaceId
|
||||
)
|
||||
const mergedEncrypted = { ...personalEncrypted, ...workspaceEncrypted }
|
||||
const decryptionPromises = Object.entries(mergedEncrypted).map(async ([key, encrypted]) => {
|
||||
const { decrypted } = await decryptSecret(encrypted)
|
||||
return [key, decrypted] as const
|
||||
})
|
||||
const decryptedPairs = await Promise.all(decryptionPromises)
|
||||
const decryptedEnvVars: Record<string, string> = Object.fromEntries(decryptedPairs)
|
||||
|
||||
// Start logging session
|
||||
await loggingSession.safeStart({
|
||||
userId: payload.userId,
|
||||
workspaceId: '', // TODO: Get from workflow if needed
|
||||
workspaceId: workspaceId || '',
|
||||
variables: decryptedEnvVars,
|
||||
})
|
||||
|
||||
@@ -130,7 +126,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) {
|
||||
workflowVariables: {},
|
||||
contextExtensions: {
|
||||
executionId,
|
||||
workspaceId: '', // TODO: Get from workflow if needed - see comment on line 120
|
||||
workspaceId: workspaceId || '',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ interface EnvVarDropdownProps {
|
||||
cursorPosition: number
|
||||
onClose?: () => void
|
||||
style?: React.CSSProperties
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
interface EnvVarGroup {
|
||||
label: string
|
||||
variables: string[]
|
||||
}
|
||||
|
||||
export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
@@ -23,15 +29,65 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
cursorPosition,
|
||||
onClose,
|
||||
style,
|
||||
workspaceId,
|
||||
}) => {
|
||||
const envVars = useEnvironmentStore((state) => Object.keys(state.variables))
|
||||
const loadWorkspaceEnvironment = useEnvironmentStore((state) => state.loadWorkspaceEnvironment)
|
||||
const userEnvVars = useEnvironmentStore((state) => Object.keys(state.variables))
|
||||
const [workspaceEnvData, setWorkspaceEnvData] = useState<{
|
||||
workspace: Record<string, string>
|
||||
personal: Record<string, string>
|
||||
conflicts: string[]
|
||||
}>({ workspace: {}, personal: {}, conflicts: [] })
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// Load workspace environment variables when workspaceId changes
|
||||
useEffect(() => {
|
||||
if (workspaceId && visible) {
|
||||
loadWorkspaceEnvironment(workspaceId).then((data) => {
|
||||
setWorkspaceEnvData(data)
|
||||
})
|
||||
}
|
||||
}, [workspaceId, visible, loadWorkspaceEnvironment])
|
||||
|
||||
// Combine and organize environment variables
|
||||
const envVarGroups: EnvVarGroup[] = []
|
||||
|
||||
if (workspaceId) {
|
||||
// When workspaceId is provided, show both workspace and user env vars
|
||||
const workspaceVars = Object.keys(workspaceEnvData.workspace)
|
||||
const personalVars = Object.keys(workspaceEnvData.personal)
|
||||
|
||||
if (workspaceVars.length > 0) {
|
||||
envVarGroups.push({ label: 'Workspace', variables: workspaceVars })
|
||||
}
|
||||
if (personalVars.length > 0) {
|
||||
envVarGroups.push({ label: 'Personal', variables: personalVars })
|
||||
}
|
||||
} else {
|
||||
// Fallback to user env vars only
|
||||
if (userEnvVars.length > 0) {
|
||||
envVarGroups.push({ label: 'Personal', variables: userEnvVars })
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten all variables for filtering and selection
|
||||
const allEnvVars = envVarGroups.flatMap((group) => group.variables)
|
||||
|
||||
// Filter env vars based on search term
|
||||
const filteredEnvVars = envVars.filter((envVar) =>
|
||||
const filteredEnvVars = allEnvVars.filter((envVar) =>
|
||||
envVar.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
|
||||
// Create filtered groups for display
|
||||
const filteredGroups = envVarGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
variables: group.variables.filter((envVar) =>
|
||||
envVar.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.variables.length > 0)
|
||||
|
||||
// Reset selection when filtered results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
@@ -125,23 +181,35 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className='py-1'>
|
||||
{filteredEnvVars.map((envVar, index) => (
|
||||
<button
|
||||
key={envVar}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left text-sm',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
|
||||
index === selectedIndex && 'bg-accent text-accent-foreground'
|
||||
{filteredGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
{filteredGroups.length > 1 && (
|
||||
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent input blur
|
||||
handleEnvVarSelect(envVar)
|
||||
}}
|
||||
>
|
||||
{envVar}
|
||||
</button>
|
||||
{group.variables.map((envVar) => {
|
||||
const globalIndex = filteredEnvVars.indexOf(envVar)
|
||||
return (
|
||||
<button
|
||||
key={`${group.label}-${envVar}`}
|
||||
className={cn(
|
||||
'w-full px-3 py-1.5 text-left text-sm',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
|
||||
globalIndex === selectedIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent input blur
|
||||
handleEnvVarSelect(envVar)
|
||||
}}
|
||||
>
|
||||
{envVar}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
13
apps/sim/db/migrations/0080_left_riptide.sql
Normal file
13
apps/sim/db/migrations/0080_left_riptide.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "workspace_environment" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workspace_id" text NOT NULL,
|
||||
"variables" json DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ALTER COLUMN "current_usage_limit" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ADD COLUMN "billing_blocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_environment" ADD CONSTRAINT "workspace_environment_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "workspace_environment_workspace_unique" ON "workspace_environment" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_environment_workspace_id_idx" ON "workspace_environment" USING btree ("workspace_id");
|
||||
1
apps/sim/db/migrations/0081_yellow_shadow_king.sql
Normal file
1
apps/sim/db/migrations/0081_yellow_shadow_king.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workspace_invitation" ADD COLUMN "org_invitation_id" text;
|
||||
5912
apps/sim/db/migrations/meta/0080_snapshot.json
Normal file
5912
apps/sim/db/migrations/meta/0080_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5918
apps/sim/db/migrations/meta/0081_snapshot.json
Normal file
5918
apps/sim/db/migrations/meta/0081_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -554,6 +554,20 @@
|
||||
"when": 1756246702112,
|
||||
"tag": "0079_shocking_shriek",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 80,
|
||||
"version": "7",
|
||||
"when": 1756581886683,
|
||||
"tag": "0080_left_riptide",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 81,
|
||||
"version": "7",
|
||||
"when": 1756602783877,
|
||||
"tag": "0081_yellow_shadow_king",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -319,6 +319,24 @@ export const environment = pgTable('environment', {
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const workspaceEnvironment = pgTable(
|
||||
'workspace_environment',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workspaceId: text('workspace_id')
|
||||
.notNull()
|
||||
.references(() => workspace.id, { onDelete: 'cascade' }),
|
||||
variables: json('variables').notNull().default('{}'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Ensure one row per workspace
|
||||
workspaceUnique: uniqueIndex('workspace_environment_workspace_unique').on(table.workspaceId),
|
||||
workspaceIdIdx: index('workspace_environment_workspace_id_idx').on(table.workspaceId),
|
||||
})
|
||||
)
|
||||
|
||||
export const settings = pgTable('settings', {
|
||||
id: text('id').primaryKey(), // Use the user id as the key
|
||||
userId: text('user_id')
|
||||
@@ -606,6 +624,7 @@ export const workspaceInvitation = pgTable('workspace_invitation', {
|
||||
status: text('status').notNull().default('pending'),
|
||||
token: text('token').notNull().unique(),
|
||||
permissions: permissionTypeEnum('permissions').notNull().default('admin'),
|
||||
orgInvitationId: text('org_invitation_id'),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { decryptSecret } from '@/lib/utils'
|
||||
import { db } from '@/db'
|
||||
import { environment } from '@/db/schema'
|
||||
import { environment, workspaceEnvironment } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('EnvironmentUtils')
|
||||
|
||||
@@ -40,3 +41,67 @@ export async function getEnvironmentVariableKeys(userId: string): Promise<{
|
||||
throw new Error('Failed to get environment variables')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPersonalAndWorkspaceEnv(
|
||||
userId: string,
|
||||
workspaceId?: string
|
||||
): Promise<{
|
||||
personalEncrypted: Record<string, string>
|
||||
workspaceEncrypted: Record<string, string>
|
||||
personalDecrypted: Record<string, string>
|
||||
workspaceDecrypted: Record<string, string>
|
||||
conflicts: string[]
|
||||
}> {
|
||||
const [personalRows, workspaceRows] = await Promise.all([
|
||||
db.select().from(environment).where(eq(environment.userId, userId)).limit(1),
|
||||
workspaceId
|
||||
? db
|
||||
.select()
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
: Promise.resolve([] as any[]),
|
||||
])
|
||||
|
||||
const personalEncrypted: Record<string, string> = (personalRows[0]?.variables as any) || {}
|
||||
const workspaceEncrypted: Record<string, string> = (workspaceRows[0]?.variables as any) || {}
|
||||
|
||||
const decryptAll = async (src: Record<string, string>) => {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(src)) {
|
||||
try {
|
||||
const { decrypted } = await decryptSecret(v)
|
||||
out[k] = decrypted
|
||||
} catch {
|
||||
out[k] = ''
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const [personalDecrypted, workspaceDecrypted] = await Promise.all([
|
||||
decryptAll(personalEncrypted),
|
||||
decryptAll(workspaceEncrypted),
|
||||
])
|
||||
|
||||
const conflicts = Object.keys(personalEncrypted).filter((k) => k in workspaceEncrypted)
|
||||
|
||||
return {
|
||||
personalEncrypted,
|
||||
workspaceEncrypted,
|
||||
personalDecrypted,
|
||||
workspaceDecrypted,
|
||||
conflicts,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEffectiveDecryptedEnv(
|
||||
userId: string,
|
||||
workspaceId?: string
|
||||
): Promise<Record<string, string>> {
|
||||
const { personalDecrypted, workspaceDecrypted } = await getPersonalAndWorkspaceEnv(
|
||||
userId,
|
||||
workspaceId
|
||||
)
|
||||
return { ...personalDecrypted, ...workspaceDecrypted }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const API_ENDPOINTS = {
|
||||
SETTINGS: '/api/settings',
|
||||
WORKFLOWS: '/api/workflows',
|
||||
WORKSPACE_PERMISSIONS: (id: string) => `/api/workspaces/${id}/permissions`,
|
||||
WORKSPACE_ENVIRONMENT: (id: string) => `/api/workspaces/${id}/environment`,
|
||||
}
|
||||
|
||||
// Removed SYNC_INTERVALS - Socket.IO handles real-time sync
|
||||
|
||||
@@ -578,7 +578,16 @@ export const useOrganizationStore = create<OrganizationStore>()(
|
||||
set({ isLoading: true })
|
||||
|
||||
try {
|
||||
await client.organization.cancelInvitation({ invitationId })
|
||||
const response = await fetch(
|
||||
`/api/organizations/${activeOrganization.id}/invitations?invitationId=${encodeURIComponent(
|
||||
invitationId
|
||||
)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}) as any)
|
||||
throw new Error((data as any).error || 'Failed to cancel invitation')
|
||||
}
|
||||
await get().refreshOrganization()
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel invitation', { error })
|
||||
|
||||
@@ -94,6 +94,67 @@ export const useEnvironmentStore = create<EnvironmentStore>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Workspace environment actions
|
||||
loadWorkspaceEnvironment: async (workspaceId: string) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId))
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load workspace environment: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
// The UI component for environment modal will handle workspace section state locally.
|
||||
set({ isLoading: false })
|
||||
return data as {
|
||||
workspace: Record<string, string>
|
||||
personal: Record<string, string>
|
||||
conflicts: string[]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error loading workspace environment:', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false })
|
||||
return { workspace: {}, personal: {}, conflicts: [] }
|
||||
}
|
||||
},
|
||||
|
||||
upsertWorkspaceEnvironment: async (workspaceId: string, variables: Record<string, string>) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ variables }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update workspace environment: ${response.statusText}`)
|
||||
}
|
||||
set({ isLoading: false })
|
||||
} catch (error) {
|
||||
logger.error('Error updating workspace environment:', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
removeWorkspaceEnvironmentKeys: async (workspaceId: string, keys: string[]) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null })
|
||||
const response = await fetch(API_ENDPOINTS.WORKSPACE_ENVIRONMENT(workspaceId), {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keys }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to remove workspace environment keys: ${response.statusText}`)
|
||||
}
|
||||
set({ isLoading: false })
|
||||
} catch (error) {
|
||||
logger.error('Error removing workspace environment keys:', { error })
|
||||
set({ error: error instanceof Error ? error.message : 'Unknown error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy method updated to use the new saveEnvironmentVariables
|
||||
setVariables: (variables: Record<string, string>) => {
|
||||
get().saveEnvironmentVariables(variables)
|
||||
|
||||
@@ -17,6 +17,18 @@ export interface EnvironmentStore extends EnvironmentState {
|
||||
loadEnvironmentVariables: () => Promise<void>
|
||||
saveEnvironmentVariables: (variables: Record<string, string>) => Promise<void>
|
||||
|
||||
// Workspace environment
|
||||
loadWorkspaceEnvironment: (workspaceId: string) => Promise<{
|
||||
workspace: Record<string, string>
|
||||
personal: Record<string, string>
|
||||
conflicts: string[]
|
||||
}>
|
||||
upsertWorkspaceEnvironment: (
|
||||
workspaceId: string,
|
||||
variables: Record<string, string>
|
||||
) => Promise<void>
|
||||
removeWorkspaceEnvironmentKeys: (workspaceId: string, keys: string[]) => Promise<void>
|
||||
|
||||
// Utility methods
|
||||
getVariable: (key: string) => string | undefined
|
||||
getAllVariables: () => Record<string, EnvironmentVariable>
|
||||
|
||||
Reference in New Issue
Block a user