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:
Vikhyath Mondreti
2025-09-01 15:56:58 -07:00
committed by GitHub
parent 5bbb349d8a
commit 9ea7ea79e9
36 changed files with 12893 additions and 361 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]) => {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
ALTER TABLE "workspace_invitation" ADD COLUMN "org_invitation_id" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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