mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-13 07:55:09 -05:00
improve collaborative UX
This commit is contained in:
@@ -110,10 +110,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const callerUserId = new URL(request.url).searchParams.get('userId') || undefined
|
||||
|
||||
const authz = await authorizeCredentialUse(request, {
|
||||
credentialId,
|
||||
workflowId: workflowId ?? undefined,
|
||||
requireWorkflowIdForInternal: false,
|
||||
callerUserId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
|
||||
@@ -184,7 +184,10 @@ export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(credentialMember).where(eq(credentialMember.id, target.id))
|
||||
await db
|
||||
.update(credentialMember)
|
||||
.set({ status: 'revoked', updatedAt: new Date() })
|
||||
.where(eq(credentialMember.id, target.id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,13 +16,20 @@ const logger = createLogger('CredentialByIdAPI')
|
||||
const updateCredentialSchema = z
|
||||
.object({
|
||||
displayName: z.string().trim().min(1).max(255).optional(),
|
||||
description: z.string().trim().max(500).nullish(),
|
||||
accountId: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Boolean(data.displayName || data.accountId), {
|
||||
message: 'At least one field must be provided',
|
||||
path: ['displayName'],
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.displayName !== undefined ||
|
||||
data.description !== undefined ||
|
||||
data.accountId !== undefined,
|
||||
{
|
||||
message: 'At least one field must be provided',
|
||||
path: ['displayName'],
|
||||
}
|
||||
)
|
||||
|
||||
async function getCredentialResponse(credentialId: string, userId: string) {
|
||||
const [row] = await db
|
||||
@@ -31,6 +38,7 @@ async function getCredentialResponse(credentialId: string, userId: string) {
|
||||
workspaceId: credential.workspaceId,
|
||||
type: credential.type,
|
||||
displayName: credential.displayName,
|
||||
description: credential.description,
|
||||
providerId: credential.providerId,
|
||||
accountId: credential.accountId,
|
||||
envKey: credential.envKey,
|
||||
@@ -99,23 +107,35 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (access.credential.type === 'oauth') {
|
||||
const updates: Record<string, unknown> = {}
|
||||
|
||||
if (parseResult.data.description !== undefined) {
|
||||
updates.description = parseResult.data.description ?? null
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
if (access.credential.type === 'oauth') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'OAuth credential editing is limited to description only.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'OAuth credential editing is disabled. Connect an account and create or use its linked credential.',
|
||||
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Environment credentials cannot be updated via this endpoint. Use the environment value editor in credentials settings.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
updates.updatedAt = new Date()
|
||||
await db.update(credential).set(updates).where(eq(credential.id, id))
|
||||
|
||||
const row = await getCredentialResponse(id, session.user.id)
|
||||
return NextResponse.json({ credential: row }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to update credential', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { db } from '@sim/db'
|
||||
import { environment, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
syncPersonalEnvCredentialsForUser,
|
||||
syncWorkspaceEnvCredentials,
|
||||
} from '@/lib/credentials/environment'
|
||||
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('CredentialsBootstrapAPI')
|
||||
|
||||
const bootstrapSchema = z.object({
|
||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Ensures the current user's connected accounts and env vars are reflected as workspace credentials.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const parseResult = bootstrapSchema.safeParse(await request.json())
|
||||
if (!parseResult.success) {
|
||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { workspaceId } = parseResult.data
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
if (!workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [personalRow, workspaceRow] = await Promise.all([
|
||||
db
|
||||
.select({ variables: environment.variables })
|
||||
.from(environment)
|
||||
.where(eq(environment.userId, session.user.id))
|
||||
.limit(1),
|
||||
db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1),
|
||||
])
|
||||
|
||||
const personalKeys = Object.keys((personalRow[0]?.variables as Record<string, string>) || {})
|
||||
const workspaceKeys = Object.keys((workspaceRow[0]?.variables as Record<string, string>) || {})
|
||||
|
||||
const [oauthSyncResult] = await Promise.all([
|
||||
syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }),
|
||||
syncPersonalEnvCredentialsForUser({ userId: session.user.id, envKeys: personalKeys }),
|
||||
syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: workspaceKeys,
|
||||
actingUserId: session.user.id,
|
||||
}),
|
||||
])
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
synced: {
|
||||
oauthCreated: oauthSyncResult.createdCredentials,
|
||||
oauthMembershipsUpdated: oauthSyncResult.updatedMemberships,
|
||||
personalEnvKeys: personalKeys.length,
|
||||
workspaceEnvKeys: workspaceKeys.length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to bootstrap workspace credentials', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const createDraftSchema = z.object({
|
||||
workspaceId: z.string().min(1),
|
||||
providerId: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -29,7 +30,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { workspaceId, providerId, displayName } = parsed.data
|
||||
const { workspaceId, providerId, displayName, description } = parsed.data
|
||||
const userId = session.user.id
|
||||
const now = new Date()
|
||||
|
||||
@@ -47,6 +48,7 @@ export async function POST(request: Request) {
|
||||
workspaceId,
|
||||
providerId,
|
||||
displayName,
|
||||
description: description || null,
|
||||
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
|
||||
createdAt: now,
|
||||
})
|
||||
@@ -58,6 +60,7 @@ export async function POST(request: Request) {
|
||||
],
|
||||
set: {
|
||||
displayName,
|
||||
description: description || null,
|
||||
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
|
||||
createdAt: now,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ const listCredentialsSchema = z.object({
|
||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
|
||||
type: credentialTypeSchema.optional(),
|
||||
providerId: z.string().optional(),
|
||||
credentialId: z.string().optional(),
|
||||
})
|
||||
|
||||
const createCredentialSchema = z
|
||||
@@ -33,6 +34,7 @@ const createCredentialSchema = z
|
||||
workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
|
||||
type: credentialTypeSchema,
|
||||
displayName: z.string().trim().min(1).max(255).optional(),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
providerId: z.string().trim().min(1).optional(),
|
||||
accountId: z.string().trim().min(1).optional(),
|
||||
envKey: z.string().trim().min(1).optional(),
|
||||
@@ -156,10 +158,12 @@ export async function GET(request: NextRequest) {
|
||||
const rawWorkspaceId = searchParams.get('workspaceId')
|
||||
const rawType = searchParams.get('type')
|
||||
const rawProviderId = searchParams.get('providerId')
|
||||
const rawCredentialId = searchParams.get('credentialId')
|
||||
const parseResult = listCredentialsSchema.safeParse({
|
||||
workspaceId: rawWorkspaceId?.trim(),
|
||||
type: rawType?.trim() || undefined,
|
||||
providerId: rawProviderId?.trim() || undefined,
|
||||
credentialId: rawCredentialId?.trim() || undefined,
|
||||
})
|
||||
|
||||
if (!parseResult.success) {
|
||||
@@ -172,13 +176,28 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { workspaceId, type, providerId } = parseResult.data
|
||||
const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
|
||||
if (!workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (lookupCredentialId) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
displayName: credential.displayName,
|
||||
type: credential.type,
|
||||
providerId: credential.providerId,
|
||||
})
|
||||
.from(credential)
|
||||
.where(and(eq(credential.id, lookupCredentialId), eq(credential.workspaceId, workspaceId)))
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ credential: row ?? null })
|
||||
}
|
||||
|
||||
if (!type || type === 'oauth') {
|
||||
await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id })
|
||||
}
|
||||
@@ -202,6 +221,7 @@ export async function GET(request: NextRequest) {
|
||||
workspaceId: credential.workspaceId,
|
||||
type: credential.type,
|
||||
displayName: credential.displayName,
|
||||
description: credential.description,
|
||||
providerId: credential.providerId,
|
||||
accountId: credential.accountId,
|
||||
envKey: credential.envKey,
|
||||
@@ -245,8 +265,16 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
||||
}
|
||||
|
||||
const { workspaceId, type, displayName, providerId, accountId, envKey, envOwnerUserId } =
|
||||
parseResult.data
|
||||
const {
|
||||
workspaceId,
|
||||
type,
|
||||
displayName,
|
||||
description,
|
||||
providerId,
|
||||
accountId,
|
||||
envKey,
|
||||
envOwnerUserId,
|
||||
} = parseResult.data
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
|
||||
if (!workspaceAccess.canWrite) {
|
||||
@@ -254,6 +282,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
let resolvedDisplayName = displayName?.trim() ?? ''
|
||||
const resolvedDescription = description?.trim() || null
|
||||
let resolvedProviderId: string | null = providerId ?? null
|
||||
let resolvedAccountId: string | null = accountId ?? null
|
||||
const resolvedEnvKey: string | null = envKey ? normalizeEnvKeyInput(envKey) : null
|
||||
@@ -345,16 +374,21 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
const canUpdateExistingCredential = membership.role === 'admin'
|
||||
const shouldUpdateDisplayName =
|
||||
type === 'oauth' &&
|
||||
membership.role === 'admin' &&
|
||||
resolvedDisplayName &&
|
||||
resolvedDisplayName !== existingCredential.displayName
|
||||
) {
|
||||
const shouldUpdateDescription =
|
||||
typeof description !== 'undefined' &&
|
||||
(existingCredential.description ?? null) !== resolvedDescription
|
||||
|
||||
if (canUpdateExistingCredential && (shouldUpdateDisplayName || shouldUpdateDescription)) {
|
||||
await db
|
||||
.update(credential)
|
||||
.set({
|
||||
displayName: resolvedDisplayName,
|
||||
...(shouldUpdateDisplayName ? { displayName: resolvedDisplayName } : {}),
|
||||
...(shouldUpdateDescription ? { description: resolvedDescription } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(credential.id, existingCredential.id))
|
||||
@@ -388,6 +422,7 @@ export async function POST(request: NextRequest) {
|
||||
workspaceId,
|
||||
type,
|
||||
displayName: resolvedDisplayName,
|
||||
description: resolvedDescription,
|
||||
providerId: resolvedProviderId,
|
||||
accountId: resolvedAccountId,
|
||||
envKey: resolvedEnvKey,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
user,
|
||||
userStats,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspaceEnvironment,
|
||||
workspaceInvitation,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
@@ -23,6 +24,7 @@ import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
|
||||
const logger = createLogger('OrganizationInvitation')
|
||||
@@ -495,6 +497,34 @@ export async function PUT(
|
||||
}
|
||||
})
|
||||
|
||||
if (status === 'accepted') {
|
||||
const acceptedWsInvitations = await db
|
||||
.select({ workspaceId: workspaceInvitation.workspaceId })
|
||||
.from(workspaceInvitation)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInvitation.orgInvitationId, invitationId),
|
||||
eq(workspaceInvitation.status, 'accepted' as WorkspaceInvitationStatus)
|
||||
)
|
||||
)
|
||||
|
||||
for (const wsInv of acceptedWsInvitations) {
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, wsInv.workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId: wsInv.workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: session.user.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Pro subscription cancellation after transaction commits
|
||||
if (personalProToCancel) {
|
||||
try {
|
||||
|
||||
@@ -32,9 +32,10 @@
|
||||
|
||||
import crypto from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, user, workspace } from '@sim/db/schema'
|
||||
import { permissions, user, workspace, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, count, eq } from 'drizzle-orm'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
badRequestResponse,
|
||||
@@ -232,6 +233,20 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
|
||||
permissionId,
|
||||
})
|
||||
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: body.userId,
|
||||
})
|
||||
}
|
||||
|
||||
return singleResponse({
|
||||
id: permissionId,
|
||||
workspaceId,
|
||||
|
||||
@@ -536,6 +536,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
useDraftState: shouldUseDraftState,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession,
|
||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||
}
|
||||
|
||||
@@ -875,6 +876,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
useDraftState: shouldUseDraftState,
|
||||
startTime: new Date().toISOString(),
|
||||
isClientSession,
|
||||
enforceCredentialAccess: useAuthenticatedUserAsActor,
|
||||
workflowStateOverride: effectiveWorkflowStateOverride,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import crypto from 'crypto'
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workspace } from '@sim/db/schema'
|
||||
import { permissions, workspace, workspaceEnvironment } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import {
|
||||
getUsersWithPermissions,
|
||||
hasWorkspaceAdminAccess,
|
||||
@@ -154,6 +155,20 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
}
|
||||
})
|
||||
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
user,
|
||||
type WorkspaceInvitationStatus,
|
||||
workspace,
|
||||
workspaceEnvironment,
|
||||
workspaceInvitation,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
@@ -14,6 +15,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { WorkspaceInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
|
||||
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -162,6 +164,20 @@ export async function GET(
|
||||
.where(eq(workspaceInvitation.id, invitation.id))
|
||||
})
|
||||
|
||||
const [wsEnvRow] = await db
|
||||
.select({ variables: workspaceEnvironment.variables })
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, invitation.workspaceId))
|
||||
.limit(1)
|
||||
const wsEnvKeys = Object.keys((wsEnvRow?.variables as Record<string, string>) || {})
|
||||
if (wsEnvKeys.length > 0) {
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId: invitation.workspaceId,
|
||||
envKeys: wsEnvKeys,
|
||||
actingUserId: session.user.id,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
|
||||
}
|
||||
|
||||
|
||||
@@ -116,26 +116,51 @@ export function CredentialSelector({
|
||||
[credentialSets, selectedCredentialSetId]
|
||||
)
|
||||
|
||||
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
|
||||
setInaccessibleCredentialName(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
|
||||
)
|
||||
if (!response.ok || cancelled) return
|
||||
const data = await response.json()
|
||||
if (!cancelled && data.credential?.displayName) {
|
||||
setInaccessibleCredentialName(data.credential.displayName)
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (selectedCredentialSet) return selectedCredentialSet.name
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
if (inaccessibleCredentialName) return inaccessibleCredentialName
|
||||
if (selectedId && !credentialsLoading) return 'Credential (no access)'
|
||||
return ''
|
||||
}, [selectedCredentialSet, selectedCredential])
|
||||
}, [
|
||||
selectedCredentialSet,
|
||||
selectedCredential,
|
||||
inaccessibleCredentialName,
|
||||
selectedId,
|
||||
credentialsLoading,
|
||||
])
|
||||
|
||||
const displayValue = isEditing ? editingValue : resolvedLabel
|
||||
|
||||
const invalidSelection =
|
||||
!isPreview && Boolean(selectedId) && !selectedCredential && !credentialsLoading
|
||||
|
||||
useEffect(() => {
|
||||
if (!invalidSelection) return
|
||||
logger.info('Clearing invalid credential selection - credential was disconnected', {
|
||||
selectedId,
|
||||
provider: effectiveProviderId,
|
||||
})
|
||||
setStoreValue('')
|
||||
}, [invalidSelection, selectedId, effectiveProviderId, setStoreValue])
|
||||
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
|
||||
@@ -85,19 +85,43 @@ export function ToolCredentialSelector({
|
||||
[credentials, selectedId]
|
||||
)
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
return ''
|
||||
}, [selectedCredential])
|
||||
|
||||
const inputValue = isEditing ? editingInputValue : resolvedLabel
|
||||
|
||||
const invalidSelection = Boolean(selectedId) && !selectedCredential && !credentialsLoading
|
||||
const [inaccessibleCredentialName, setInaccessibleCredentialName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!invalidSelection) return
|
||||
onChange('')
|
||||
}, [invalidSelection, onChange])
|
||||
if (!selectedId || selectedCredential || credentialsLoading || !workspaceId) {
|
||||
setInaccessibleCredentialName(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/credentials?workspaceId=${encodeURIComponent(workspaceId)}&credentialId=${encodeURIComponent(selectedId)}`
|
||||
)
|
||||
if (!response.ok || cancelled) return
|
||||
const data = await response.json()
|
||||
if (!cancelled && data.credential?.displayName) {
|
||||
setInaccessibleCredentialName(data.credential.displayName)
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedId, selectedCredential, credentialsLoading, workspaceId])
|
||||
|
||||
const resolvedLabel = useMemo(() => {
|
||||
if (selectedCredential) return selectedCredential.name
|
||||
if (inaccessibleCredentialName) return inaccessibleCredentialName
|
||||
if (selectedId && !credentialsLoading) return 'Credential (no access)'
|
||||
return ''
|
||||
}, [selectedCredential, inaccessibleCredentialName, selectedId, credentialsLoading])
|
||||
|
||||
const inputValue = isEditing ? editingInputValue : resolvedLabel
|
||||
|
||||
useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId)
|
||||
|
||||
|
||||
@@ -255,6 +255,54 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
|
||||
useEffect(() => {
|
||||
const OAUTH_CONNECT_PENDING_KEY = 'sim.oauth-connect-pending'
|
||||
const pending = window.sessionStorage.getItem(OAUTH_CONNECT_PENDING_KEY)
|
||||
if (!pending) return
|
||||
window.sessionStorage.removeItem(OAUTH_CONNECT_PENDING_KEY)
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const {
|
||||
displayName,
|
||||
providerId,
|
||||
preCount,
|
||||
workspaceId: wsId,
|
||||
} = JSON.parse(pending) as {
|
||||
displayName: string
|
||||
providerId: string
|
||||
preCount: number
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/credentials?workspaceId=${encodeURIComponent(wsId)}&type=oauth`
|
||||
)
|
||||
const data = response.ok ? await response.json() : { credentials: [] }
|
||||
const oauthCredentials = (data.credentials ?? []) as Array<{
|
||||
displayName: string
|
||||
providerId: string | null
|
||||
}>
|
||||
|
||||
if (oauthCredentials.length > preCount) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: `"${displayName}" credential connected successfully.`,
|
||||
})
|
||||
} else {
|
||||
const existing = oauthCredentials.find((c) => c.providerId === providerId)
|
||||
const existingName = existing?.displayName || displayName
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: `This account is already connected as "${existingName}".`,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed sessionStorage data
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const {
|
||||
workflows,
|
||||
activeWorkflowId,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { createElement, useEffect, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Search, Trash2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Combobox,
|
||||
Input,
|
||||
Label,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
@@ -35,12 +37,12 @@ import {
|
||||
useCreateWorkspaceCredential,
|
||||
useDeleteWorkspaceCredential,
|
||||
useRemoveWorkspaceCredentialMember,
|
||||
useUpdateWorkspaceCredential,
|
||||
useUpsertWorkspaceCredentialMember,
|
||||
useWorkspaceCredentialMembers,
|
||||
useWorkspaceCredentials,
|
||||
type WorkspaceCredential,
|
||||
type WorkspaceCredentialRole,
|
||||
workspaceCredentialKeys,
|
||||
} from '@/hooks/queries/credentials'
|
||||
import {
|
||||
usePersonalEnvironment,
|
||||
@@ -62,12 +64,20 @@ const roleOptions = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
] as const
|
||||
|
||||
const typeOptions = [
|
||||
type CreateCredentialType = 'oauth' | 'secret'
|
||||
type SecretScope = 'workspace' | 'personal'
|
||||
|
||||
const createTypeOptions = [
|
||||
{ value: 'oauth', label: 'OAuth Account' },
|
||||
{ value: 'env_workspace', label: 'Workspace Secret' },
|
||||
{ value: 'env_personal', label: 'Personal Secret' },
|
||||
{ value: 'secret', label: 'Secret' },
|
||||
] as const
|
||||
|
||||
function getSecretCredentialType(
|
||||
scope: SecretScope
|
||||
): Extract<WorkspaceCredential['type'], 'env_workspace' | 'env_personal'> {
|
||||
return scope === 'workspace' ? 'env_workspace' : 'env_personal'
|
||||
}
|
||||
|
||||
function typeBadgeVariant(type: WorkspaceCredential['type']): 'blue' | 'amber' | 'gray-secondary' {
|
||||
if (type === 'oauth') return 'blue'
|
||||
if (type === 'env_workspace') return 'amber'
|
||||
@@ -89,15 +99,16 @@ function normalizeEnvKeyInput(raw: string): string {
|
||||
export function CredentialsManager() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(null)
|
||||
const [memberRole, setMemberRole] = useState<WorkspaceCredentialRole>('member')
|
||||
const [memberUserId, setMemberUserId] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createType, setCreateType] = useState<WorkspaceCredential['type']>('oauth')
|
||||
const [createType, setCreateType] = useState<CreateCredentialType>('oauth')
|
||||
const [createSecretScope, setCreateSecretScope] = useState<SecretScope>('personal')
|
||||
const [createDisplayName, setCreateDisplayName] = useState('')
|
||||
const [createDescription, setCreateDescription] = useState('')
|
||||
const [createEnvKey, setCreateEnvKey] = useState('')
|
||||
const [createEnvValue, setCreateEnvValue] = useState('')
|
||||
const [createOAuthProviderId, setCreateOAuthProviderId] = useState('')
|
||||
@@ -105,6 +116,7 @@ export function CredentialsManager() {
|
||||
const [detailsError, setDetailsError] = useState<string | null>(null)
|
||||
const [selectedEnvValueDraft, setSelectedEnvValueDraft] = useState('')
|
||||
const [isEditingEnvValue, setIsEditingEnvValue] = useState(false)
|
||||
const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('')
|
||||
const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false)
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id || ''
|
||||
@@ -128,31 +140,6 @@ export function CredentialsManager() {
|
||||
select: (data) => data,
|
||||
})
|
||||
|
||||
const bootstrapCredentials = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!workspaceId) return null
|
||||
const response = await fetch('/api/credentials/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to bootstrap credentials')
|
||||
}
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.list(workspaceId) })
|
||||
},
|
||||
})
|
||||
const runBootstrapCredentials = bootstrapCredentials.mutate
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return
|
||||
runBootstrapCredentials()
|
||||
}, [workspaceId, runBootstrapCredentials])
|
||||
|
||||
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null)
|
||||
const selectedCredential = useMemo(
|
||||
() => credentials.find((credential) => credential.id === selectedCredentialId) || null,
|
||||
@@ -164,6 +151,7 @@ export function CredentialsManager() {
|
||||
)
|
||||
|
||||
const createCredential = useCreateWorkspaceCredential()
|
||||
const updateCredential = useUpdateWorkspaceCredential()
|
||||
const deleteCredential = useDeleteWorkspaceCredential()
|
||||
const upsertMember = useUpsertWorkspaceCredentialMember()
|
||||
const removeMember = useRemoveWorkspaceCredentialMember()
|
||||
@@ -182,6 +170,7 @@ export function CredentialsManager() {
|
||||
return credentials.filter((credential) => {
|
||||
return (
|
||||
credential.displayName.toLowerCase().includes(normalized) ||
|
||||
(credential.description || '').toLowerCase().includes(normalized) ||
|
||||
(credential.providerId || '').toLowerCase().includes(normalized) ||
|
||||
resolveProviderLabel(credential.providerId).toLowerCase().includes(normalized) ||
|
||||
typeLabel(credential.type).toLowerCase().includes(normalized)
|
||||
@@ -236,18 +225,21 @@ export function CredentialsManager() {
|
||||
}
|
||||
return getCanonicalScopesForProvider(createOAuthProviderId)
|
||||
}, [selectedOAuthService, createOAuthProviderId])
|
||||
const createSecretType = useMemo(
|
||||
() => getSecretCredentialType(createSecretScope),
|
||||
[createSecretScope]
|
||||
)
|
||||
const selectedExistingEnvCredential = useMemo(() => {
|
||||
if (createType !== 'secret') return null
|
||||
const envKey = normalizeEnvKeyInput(createEnvKey)
|
||||
if (!envKey) return null
|
||||
return (
|
||||
credentials.find(
|
||||
(row) =>
|
||||
row.type === createType &&
|
||||
row.type !== 'oauth' &&
|
||||
(row.envKey || '').toLowerCase() === envKey.toLowerCase()
|
||||
row.type === createSecretType && (row.envKey || '').toLowerCase() === envKey.toLowerCase()
|
||||
) ?? null
|
||||
)
|
||||
}, [credentials, createEnvKey, createType])
|
||||
}, [credentials, createEnvKey, createSecretType, createType])
|
||||
const selectedEnvCurrentValue = useMemo(() => {
|
||||
if (!selectedCredential || selectedCredential.type === 'oauth') return ''
|
||||
const envKey = selectedCredential.envKey || ''
|
||||
@@ -267,6 +259,26 @@ export function CredentialsManager() {
|
||||
if (!selectedCredential || selectedCredential.type === 'oauth') return false
|
||||
return selectedEnvValueDraft !== selectedEnvCurrentValue
|
||||
}, [selectedCredential, selectedEnvValueDraft, selectedEnvCurrentValue])
|
||||
useEffect(() => {
|
||||
if (!selectedCredential || !isSelectedAdmin) return
|
||||
if (selectedDescriptionDraft === (selectedCredential.description || '')) return
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await updateCredential.mutateAsync({
|
||||
credentialId: selectedCredential.id,
|
||||
description: selectedDescriptionDraft.trim() || null,
|
||||
})
|
||||
await refetchCredentials()
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update description'
|
||||
setDetailsError(message)
|
||||
logger.error('Failed to autosave credential description', error)
|
||||
}
|
||||
}, 600)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [selectedDescriptionDraft])
|
||||
|
||||
useEffect(() => {
|
||||
if (createType !== 'oauth') return
|
||||
@@ -295,6 +307,7 @@ export function CredentialsManager() {
|
||||
setShowCreateModal(true)
|
||||
setShowCreateOAuthRequiredModal(false)
|
||||
setCreateError(null)
|
||||
setCreateDescription('')
|
||||
setCreateEnvValue('')
|
||||
|
||||
if (request.type === 'oauth') {
|
||||
@@ -303,7 +316,8 @@ export function CredentialsManager() {
|
||||
setCreateDisplayName(request.displayName)
|
||||
setCreateEnvKey('')
|
||||
} else {
|
||||
setCreateType(request.type)
|
||||
setCreateType('secret')
|
||||
setCreateSecretScope(request.type === 'env_workspace' ? 'workspace' : 'personal')
|
||||
setCreateOAuthProviderId('')
|
||||
setCreateDisplayName('')
|
||||
setCreateEnvKey(request.envKey || '')
|
||||
@@ -316,10 +330,13 @@ export function CredentialsManager() {
|
||||
if (!selectedCredential) {
|
||||
setSelectedEnvValueDraft('')
|
||||
setIsEditingEnvValue(false)
|
||||
setSelectedDescriptionDraft('')
|
||||
return
|
||||
}
|
||||
|
||||
setDetailsError(null)
|
||||
setSelectedDescriptionDraft(selectedCredential.description || '')
|
||||
|
||||
if (selectedCredential.type === 'oauth') {
|
||||
setSelectedEnvValueDraft('')
|
||||
setIsEditingEnvValue(false)
|
||||
@@ -351,7 +368,9 @@ export function CredentialsManager() {
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setCreateType('oauth')
|
||||
setCreateSecretScope('personal')
|
||||
setCreateDisplayName('')
|
||||
setCreateDescription('')
|
||||
setCreateEnvKey('')
|
||||
setCreateEnvValue('')
|
||||
setCreateOAuthProviderId('')
|
||||
@@ -412,7 +431,6 @@ export function CredentialsManager() {
|
||||
})
|
||||
}
|
||||
|
||||
await bootstrapCredentials.mutateAsync()
|
||||
await refetchCredentials()
|
||||
setIsEditingEnvValue(false)
|
||||
} catch (error: unknown) {
|
||||
@@ -425,6 +443,7 @@ export function CredentialsManager() {
|
||||
const handleCreateCredential = async () => {
|
||||
if (!workspaceId) return
|
||||
setCreateError(null)
|
||||
const normalizedDescription = createDescription.trim()
|
||||
|
||||
try {
|
||||
if (createType === 'oauth') {
|
||||
@@ -451,7 +470,7 @@ export function CredentialsManager() {
|
||||
return
|
||||
}
|
||||
|
||||
if (createType === 'env_personal') {
|
||||
if (createSecretType === 'env_personal') {
|
||||
const personalVariables = Object.entries(personalEnvironment).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
@@ -476,21 +495,18 @@ export function CredentialsManager() {
|
||||
},
|
||||
})
|
||||
}
|
||||
if (selectedExistingEnvCredential) {
|
||||
setSelectedCredentialId(selectedExistingEnvCredential.id)
|
||||
} else {
|
||||
const response = await createCredential.mutateAsync({
|
||||
workspaceId,
|
||||
type: createType,
|
||||
envKey: normalizedEnvKey,
|
||||
})
|
||||
const credentialId = response?.credential?.id
|
||||
if (credentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
|
||||
const response = await createCredential.mutateAsync({
|
||||
workspaceId,
|
||||
type: createSecretType,
|
||||
envKey: normalizedEnvKey,
|
||||
description: normalizedDescription || undefined,
|
||||
})
|
||||
const credentialId = response?.credential?.id
|
||||
if (credentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
|
||||
await bootstrapCredentials.mutateAsync()
|
||||
await refetchCredentials()
|
||||
|
||||
setShowCreateModal(false)
|
||||
@@ -523,9 +539,20 @@ export function CredentialsManager() {
|
||||
workspaceId,
|
||||
providerId: selectedOAuthService.providerId,
|
||||
displayName,
|
||||
description: createDescription.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
window.sessionStorage.setItem(
|
||||
'sim.oauth-connect-pending',
|
||||
JSON.stringify({
|
||||
displayName,
|
||||
providerId: selectedOAuthService.providerId,
|
||||
preCount: credentials.filter((c) => c.type === 'oauth').length,
|
||||
workspaceId,
|
||||
})
|
||||
)
|
||||
|
||||
await connectOAuthService.mutateAsync({
|
||||
providerId: selectedOAuthService.providerId,
|
||||
callbackURL: window.location.href,
|
||||
@@ -565,7 +592,6 @@ export function CredentialsManager() {
|
||||
})
|
||||
|
||||
setSelectedCredentialId(null)
|
||||
await bootstrapCredentials.mutateAsync()
|
||||
await refetchCredentials()
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('oauth-credentials-updated', {
|
||||
@@ -646,9 +672,7 @@ export function CredentialsManager() {
|
||||
</div>
|
||||
) : sortedCredentials.length === 0 ? (
|
||||
<div className='rounded-[8px] border border-[var(--border-1)] px-[12px] py-[10px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
{bootstrapCredentials.isPending
|
||||
? 'Syncing credentials from connected accounts and secrets...'
|
||||
: 'No credentials available for this workspace.'}
|
||||
No credentials available for this workspace.
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
@@ -741,6 +765,19 @@ export function CredentialsManager() {
|
||||
className='mt-[6px]'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor='credential-description'>Description</Label>
|
||||
<Textarea
|
||||
id='credential-description'
|
||||
value={selectedDescriptionDraft}
|
||||
onChange={(event) => setSelectedDescriptionDraft(event.target.value)}
|
||||
placeholder='Add a description...'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
disabled={!isSelectedAdmin}
|
||||
className='mt-[6px] min-h-[60px] resize-none'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Connected service</Label>
|
||||
<div className='mt-[6px] flex items-center gap-[10px] rounded-[8px] border border-[var(--border-1)] px-[10px] py-[8px]'>
|
||||
@@ -818,6 +855,19 @@ export function CredentialsManager() {
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor='credential-description'>Description</Label>
|
||||
<Textarea
|
||||
id='credential-description'
|
||||
value={selectedDescriptionDraft}
|
||||
onChange={(event) => setSelectedDescriptionDraft(event.target.value)}
|
||||
placeholder='Add a description...'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
disabled={!isSelectedAdmin}
|
||||
className='mt-[6px] min-h-[60px] resize-none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detailsError && (
|
||||
@@ -950,14 +1000,16 @@ export function CredentialsManager() {
|
||||
<Label>Type</Label>
|
||||
<div className='mt-[6px]'>
|
||||
<Combobox
|
||||
options={typeOptions.map((option) => ({
|
||||
options={createTypeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
value={typeOptions.find((option) => option.value === createType)?.label || ''}
|
||||
value={
|
||||
createTypeOptions.find((option) => option.value === createType)?.label || ''
|
||||
}
|
||||
selectedValue={createType}
|
||||
onChange={(value) => {
|
||||
setCreateType(value as WorkspaceCredential['type'])
|
||||
setCreateType(value as CreateCredentialType)
|
||||
setCreateError(null)
|
||||
}}
|
||||
placeholder='Select credential type'
|
||||
@@ -977,6 +1029,17 @@ export function CredentialsManager() {
|
||||
className='mt-[6px]'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(event) => setCreateDescription(event.target.value)}
|
||||
placeholder='Optional description'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
className='mt-[6px] min-h-[80px] resize-none'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>OAuth service</Label>
|
||||
<div className='mt-[6px]'>
|
||||
@@ -993,16 +1056,31 @@ export function CredentialsManager() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Connecting creates a credential for this workspace. Disconnecting from that
|
||||
credential removes it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
<div>
|
||||
<Label className='block'>Scope</Label>
|
||||
<div className='mt-[6px]'>
|
||||
<ButtonGroup
|
||||
value={createSecretScope}
|
||||
onValueChange={(value) => setCreateSecretScope(value as SecretScope)}
|
||||
>
|
||||
<ButtonGroupItem
|
||||
value='personal'
|
||||
className='h-[28px] min-w-[72px] px-[10px] py-0 text-[12px]'
|
||||
>
|
||||
Personal
|
||||
</ButtonGroupItem>
|
||||
<ButtonGroupItem
|
||||
value='workspace'
|
||||
className='h-[28px] min-w-[80px] px-[10px] py-0 text-[12px]'
|
||||
>
|
||||
Workspace
|
||||
</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Secret key</Label>
|
||||
<Input
|
||||
@@ -1039,6 +1117,17 @@ export function CredentialsManager() {
|
||||
className='mt-[6px]'
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
value={createDescription}
|
||||
onChange={(event) => setCreateDescription(event.target.value)}
|
||||
placeholder='Optional description'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
className='mt-[6px] min-h-[80px] resize-none'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedExistingEnvCredential && (
|
||||
<div className='rounded-[8px] border border-[var(--brand-9)]/40 bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||
@@ -1091,7 +1180,6 @@ export function CredentialsManager() {
|
||||
createCredential.isPending ||
|
||||
savePersonalEnvironment.isPending ||
|
||||
upsertWorkspaceEnvironment.isPending ||
|
||||
bootstrapCredentials.isPending ||
|
||||
disconnectOAuthService.isPending
|
||||
}
|
||||
>
|
||||
|
||||
@@ -264,6 +264,7 @@ export class DAGExecutor {
|
||||
executionId: this.contextExtensions.executionId,
|
||||
userId: this.contextExtensions.userId,
|
||||
isDeployedContext: this.contextExtensions.isDeployedContext,
|
||||
enforceCredentialAccess: this.contextExtensions.enforceCredentialAccess,
|
||||
blockStates: state.getBlockStates(),
|
||||
blockLogs: overrides?.runFromBlockContext ? [] : (snapshotState?.blockLogs ?? []),
|
||||
metadata: {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ExecutionMetadata {
|
||||
useDraftState: boolean
|
||||
startTime: string
|
||||
isClientSession?: boolean
|
||||
enforceCredentialAccess?: boolean
|
||||
pendingBlocks?: string[]
|
||||
resumeFromSnapshot?: boolean
|
||||
credentialAccountUserId?: string
|
||||
@@ -80,6 +81,7 @@ export interface ContextExtensions {
|
||||
selectedOutputs?: string[]
|
||||
edges?: Array<{ source: string; target: string }>
|
||||
isDeployedContext?: boolean
|
||||
enforceCredentialAccess?: boolean
|
||||
isChildExecution?: boolean
|
||||
resumeFromSnapshot?: boolean
|
||||
resumePendingQueue?: string[]
|
||||
|
||||
@@ -328,6 +328,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -74,6 +74,7 @@ export class ApiBlockHandler implements BlockHandler {
|
||||
executionId: ctx.executionId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function evaluateConditionExpression(
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -41,6 +41,7 @@ export class FunctionBlockHandler implements BlockHandler {
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -68,6 +68,7 @@ export class GenericBlockHandler implements BlockHandler {
|
||||
executionId: ctx.executionId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -607,6 +607,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler {
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
isDeployedContext: ctx.isDeployedContext,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
},
|
||||
blockData: blockDataWithPause,
|
||||
blockNameMapping: blockNameMappingWithPause,
|
||||
|
||||
@@ -123,6 +123,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
contextExtensions: {
|
||||
isChildExecution: true,
|
||||
isDeployedContext: ctx.isDeployedContext === true,
|
||||
enforceCredentialAccess: ctx.enforceCredentialAccess,
|
||||
workspaceId: ctx.workspaceId,
|
||||
userId: ctx.userId,
|
||||
executionId: ctx.executionId,
|
||||
|
||||
@@ -168,6 +168,7 @@ export interface ExecutionContext {
|
||||
executionId?: string
|
||||
userId?: string
|
||||
isDeployedContext?: boolean
|
||||
enforceCredentialAccess?: boolean
|
||||
|
||||
permissionConfig?: PermissionGroupConfig | null
|
||||
permissionConfigLoaded?: boolean
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface WorkspaceCredential {
|
||||
workspaceId: string
|
||||
type: WorkspaceCredentialType
|
||||
displayName: string
|
||||
description: string | null
|
||||
providerId: string | null
|
||||
accountId: string | null
|
||||
envKey: string | null
|
||||
@@ -107,6 +108,7 @@ export function useCreateWorkspaceCredential() {
|
||||
workspaceId: string
|
||||
type: WorkspaceCredentialType
|
||||
displayName?: string
|
||||
description?: string
|
||||
providerId?: string
|
||||
accountId?: string
|
||||
envKey?: string
|
||||
@@ -143,6 +145,7 @@ export function useUpdateWorkspaceCredential() {
|
||||
mutationFn: async (payload: {
|
||||
credentialId: string
|
||||
displayName?: string
|
||||
description?: string | null
|
||||
accountId?: string
|
||||
}) => {
|
||||
const response = await fetch(`/api/credentials/${payload.credentialId}`, {
|
||||
@@ -150,6 +153,7 @@ export function useUpdateWorkspaceCredential() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
displayName: payload.displayName,
|
||||
description: payload.description,
|
||||
accountId: payload.accountId,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -270,6 +270,7 @@ export const auth = betterAuth({
|
||||
workspaceId: draft.workspaceId,
|
||||
type: 'oauth',
|
||||
displayName: draft.displayName,
|
||||
description: draft.description ?? null,
|
||||
providerId: account.providerId,
|
||||
accountId: account.id,
|
||||
createdBy: account.userId,
|
||||
|
||||
@@ -23,9 +23,14 @@ export interface CredentialAccessResult {
|
||||
*/
|
||||
export async function authorizeCredentialUse(
|
||||
request: NextRequest,
|
||||
params: { credentialId: string; workflowId?: string; requireWorkflowIdForInternal?: boolean }
|
||||
params: {
|
||||
credentialId: string
|
||||
workflowId?: string
|
||||
requireWorkflowIdForInternal?: boolean
|
||||
callerUserId?: string
|
||||
}
|
||||
): Promise<CredentialAccessResult> {
|
||||
const { credentialId, workflowId, requireWorkflowIdForInternal = true } = params
|
||||
const { credentialId, workflowId, requireWorkflowIdForInternal = true, callerUserId } = params
|
||||
|
||||
const auth = await checkSessionOrInternalAuth(request, {
|
||||
requireWorkflowId: requireWorkflowIdForInternal,
|
||||
@@ -76,26 +81,39 @@ export async function authorizeCredentialUse(
|
||||
return { ok: false, error: 'Credential account not found' }
|
||||
}
|
||||
|
||||
const requesterPerm =
|
||||
auth.authType === 'internal_jwt'
|
||||
? null
|
||||
: await getUserEntityPermissions(auth.userId, 'workspace', platformCredential.workspaceId)
|
||||
const effectiveCallerId =
|
||||
callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
|
||||
|
||||
if (effectiveCallerId) {
|
||||
const requesterPerm = await getUserEntityPermissions(
|
||||
effectiveCallerId,
|
||||
'workspace',
|
||||
platformCredential.workspaceId
|
||||
)
|
||||
|
||||
if (auth.authType !== 'internal_jwt') {
|
||||
const [membership] = await db
|
||||
.select({ id: credentialMember.id })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, platformCredential.id),
|
||||
eq(credentialMember.userId, auth.userId),
|
||||
eq(credentialMember.userId, effectiveCallerId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!membership || requesterPerm === null) {
|
||||
return { ok: false, error: 'Unauthorized' }
|
||||
if (!membership) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `You do not have access to this credential. Ask the credential admin to add you as a member.`,
|
||||
}
|
||||
}
|
||||
if (requesterPerm === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'You do not have access to this workspace.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,21 +167,27 @@ export async function authorizeCredentialUse(
|
||||
return { ok: false, error: 'Credential account not found' }
|
||||
}
|
||||
|
||||
if (auth.authType !== 'internal_jwt') {
|
||||
const legacyCallerId = callerUserId || (auth.authType !== 'internal_jwt' ? auth.userId : null)
|
||||
|
||||
if (legacyCallerId) {
|
||||
const [membership] = await db
|
||||
.select({ id: credentialMember.id })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, workspaceCredential.id),
|
||||
eq(credentialMember.userId, auth.userId),
|
||||
eq(credentialMember.userId, legacyCallerId),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return { ok: false, error: 'Unauthorized' }
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'You do not have access to this credential. Ask the credential admin to add you as a member.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { syncPersonalEnvCredentialsForUser } from '@/lib/credentials/environment'
|
||||
|
||||
interface SetEnvironmentVariablesParams {
|
||||
variables: Record<string, any> | Array<{ name: string; value: string }>
|
||||
@@ -108,6 +109,11 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<SetEnvironmentVar
|
||||
set: { variables: finalEncrypted, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncPersonalEnvCredentialsForUser({
|
||||
userId: authenticatedUserId,
|
||||
envKeys: Object.keys(finalEncrypted),
|
||||
})
|
||||
|
||||
logger.info('Saved personal environment variables', {
|
||||
userId: authenticatedUserId,
|
||||
addedCount: added.length,
|
||||
|
||||
@@ -106,6 +106,7 @@ async function ensureWorkspaceCredentialMemberships(
|
||||
.select({
|
||||
id: credentialMember.id,
|
||||
userId: credentialMember.userId,
|
||||
status: credentialMember.status,
|
||||
joinedAt: credentialMember.joinedAt,
|
||||
})
|
||||
.from(credentialMember)
|
||||
@@ -123,6 +124,9 @@ async function ensureWorkspaceCredentialMemberships(
|
||||
const targetRole = memberUserId === ownerUserId ? 'admin' : 'member'
|
||||
const existing = byUserId.get(memberUserId)
|
||||
if (existing) {
|
||||
if (existing.status === 'revoked') {
|
||||
continue
|
||||
}
|
||||
await db
|
||||
.update(credentialMember)
|
||||
.set({
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, credential, credentialMember } from '@sim/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, notInArray } from 'drizzle-orm'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
|
||||
/** Provider IDs that are not real OAuth integrations (e.g. Better Auth's password provider) */
|
||||
const NON_OAUTH_PROVIDER_IDS = ['credential'] as const
|
||||
|
||||
interface SyncWorkspaceOAuthCredentialsForUserParams {
|
||||
workspaceId: string
|
||||
userId: string
|
||||
@@ -34,7 +37,9 @@ export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
accountId: account.accountId,
|
||||
})
|
||||
.from(account)
|
||||
.where(eq(account.userId, userId))
|
||||
.where(
|
||||
and(eq(account.userId, userId), notInArray(account.providerId, [...NON_OAUTH_PROVIDER_IDS]))
|
||||
)
|
||||
|
||||
if (userAccounts.length === 0) {
|
||||
return { createdCredentials: 0, updatedMemberships: 0 }
|
||||
|
||||
@@ -302,6 +302,7 @@ export async function executeWorkflowCore(
|
||||
workspaceId: providedWorkspaceId,
|
||||
userId,
|
||||
isDeployedContext: !metadata.isClientSession,
|
||||
enforceCredentialAccess: metadata.enforceCredentialAccess ?? false,
|
||||
onBlockStart,
|
||||
onBlockComplete: wrappedOnBlockComplete,
|
||||
onStream,
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function executeTool(
|
||||
if (workflowId) {
|
||||
tokenUrlObj.searchParams.set('workflowId', workflowId)
|
||||
}
|
||||
if (userId) {
|
||||
if (userId && contextParams._context?.enforceCredentialAccess) {
|
||||
tokenUrlObj.searchParams.set('userId', userId)
|
||||
}
|
||||
|
||||
@@ -330,7 +330,15 @@ export async function executeTool(
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
})
|
||||
throw new Error(`Failed to fetch access token: ${response.status} ${errorText}`)
|
||||
let parsedError = errorText
|
||||
try {
|
||||
const parsed = JSON.parse(errorText)
|
||||
if (parsed.error) parsedError = parsed.error
|
||||
} catch {
|
||||
// Use raw text
|
||||
}
|
||||
const toolLabel = tool?.name || toolId
|
||||
throw new Error(`Failed to obtain credential for ${toolLabel}: ${parsedError}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -359,10 +367,7 @@ export async function executeTool(
|
||||
logger.error(`[${requestId}] Error fetching access token for ${toolId}:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
// Re-throw the error to fail the tool execution if token fetching fails
|
||||
throw new Error(
|
||||
`Failed to obtain credential for tool ${toolId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user