mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-12 07:24:55 -05:00
checkpoint
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, desc, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(account)
|
||||
.where(and(...whereConditions))
|
||||
.orderBy(desc(account.updatedAt))
|
||||
|
||||
const accountsWithDisplayName = accounts.map((acc) => ({
|
||||
id: acc.id,
|
||||
|
||||
@@ -48,16 +48,21 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const shopData = await shopResponse.json()
|
||||
const shopInfo = shopData.shop
|
||||
const stableAccountId = shopInfo.id?.toString() || shopDomain
|
||||
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'shopify')),
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'shopify'),
|
||||
eq(account.accountId, stableAccountId)
|
||||
),
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const accountData = {
|
||||
accessToken: accessToken,
|
||||
accountId: shopInfo.id?.toString() || shopDomain,
|
||||
accountId: stableAccountId,
|
||||
scope: scope || '',
|
||||
updatedAt: now,
|
||||
idToken: shopDomain,
|
||||
|
||||
@@ -52,7 +52,11 @@ export async function POST(request: NextRequest) {
|
||||
const trelloUser = await userResponse.json()
|
||||
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'trello')),
|
||||
where: and(
|
||||
eq(account.userId, session.user.id),
|
||||
eq(account.providerId, 'trello'),
|
||||
eq(account.accountId, trelloUser.id)
|
||||
),
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
import { db } from '@sim/db'
|
||||
import { credentialMember, user } from '@sim/db/schema'
|
||||
import { credential, credentialMember, user } 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 { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('CredentialMembersAPI')
|
||||
|
||||
const upsertMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
role: z.enum(['admin', 'member']),
|
||||
})
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const deleteMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
})
|
||||
async function requireAdminMembership(credentialId: string, userId: string) {
|
||||
const [membership] = await db
|
||||
.select({ role: credentialMember.role, status: credentialMember.status })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
|
||||
return null
|
||||
}
|
||||
return membership
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const access = await getCredentialActorContext(id, session.user.id)
|
||||
if (!access.credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
if (!access.hasWorkspaceAccess || !access.isAdmin) {
|
||||
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
|
||||
|
||||
const { id: credentialId } = await context.params
|
||||
|
||||
const [cred] = await db
|
||||
.select({ id: credential.id })
|
||||
.from(credential)
|
||||
.where(eq(credential.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!cred) {
|
||||
return NextResponse.json({ members: [] }, { status: 200 })
|
||||
}
|
||||
|
||||
const members = await db
|
||||
@@ -43,178 +53,142 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
role: credentialMember.role,
|
||||
status: credentialMember.status,
|
||||
joinedAt: credentialMember.joinedAt,
|
||||
invitedBy: credentialMember.invitedBy,
|
||||
createdAt: credentialMember.createdAt,
|
||||
updatedAt: credentialMember.updatedAt,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(credentialMember)
|
||||
.leftJoin(user, eq(credentialMember.userId, user.id))
|
||||
.where(eq(credentialMember.credentialId, id))
|
||||
.innerJoin(user, eq(credentialMember.userId, user.id))
|
||||
.where(eq(credentialMember.credentialId, credentialId))
|
||||
|
||||
return NextResponse.json({ members }, { status: 200 })
|
||||
return NextResponse.json({ members })
|
||||
} catch (error) {
|
||||
logger.error('Failed to list credential members', error)
|
||||
logger.error('Failed to fetch credential members', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const addMemberSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
role: z.enum(['admin', 'member']).default('member'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const parseResult = upsertMemberSchema.safeParse(await request.json())
|
||||
if (!parseResult.success) {
|
||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const access = await getCredentialActorContext(id, session.user.id)
|
||||
if (!access.credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
if (!access.hasWorkspaceAccess || !access.isAdmin) {
|
||||
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
|
||||
const { id: credentialId } = await context.params
|
||||
|
||||
const admin = await requireAdminMembership(credentialId, session.user.id)
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const targetWorkspaceAccess = await checkWorkspaceAccess(
|
||||
access.credential.workspaceId,
|
||||
parseResult.data.userId
|
||||
)
|
||||
if (!targetWorkspaceAccess.hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User must have workspace access before being added to a credential' },
|
||||
{ status: 400 }
|
||||
)
|
||||
const body = await request.json()
|
||||
const parsed = addMemberSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { userId, role } = parsed.data
|
||||
const now = new Date()
|
||||
const [existingMember] = await db
|
||||
.select()
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: credentialMember.id, status: credentialMember.status })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, id),
|
||||
eq(credentialMember.userId, parseResult.data.userId)
|
||||
)
|
||||
and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingMember) {
|
||||
if (existing) {
|
||||
await db
|
||||
.update(credentialMember)
|
||||
.set({
|
||||
role: parseResult.data.role,
|
||||
status: 'active',
|
||||
joinedAt: existingMember.joinedAt ?? now,
|
||||
invitedBy: session.user.id,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(credentialMember.id, existingMember.id))
|
||||
} else {
|
||||
await db.insert(credentialMember).values({
|
||||
id: crypto.randomUUID(),
|
||||
credentialId: id,
|
||||
userId: parseResult.data.userId,
|
||||
role: parseResult.data.role,
|
||||
status: 'active',
|
||||
joinedAt: now,
|
||||
invitedBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.set({ role, status: 'active', updatedAt: now })
|
||||
.where(eq(credentialMember.id, existing.id))
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
await db.insert(credentialMember).values({
|
||||
id: crypto.randomUUID(),
|
||||
credentialId,
|
||||
userId,
|
||||
role,
|
||||
status: 'active',
|
||||
joinedAt: now,
|
||||
invitedBy: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 201 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to upsert credential member', error)
|
||||
logger.error('Failed to add credential member', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const parseResult = deleteMemberSchema.safeParse({
|
||||
userId: new URL(request.url).searchParams.get('userId'),
|
||||
})
|
||||
if (!parseResult.success) {
|
||||
return NextResponse.json({ error: parseResult.error.errors[0]?.message }, { status: 400 })
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const access = await getCredentialActorContext(id, session.user.id)
|
||||
if (!access.credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
if (!access.hasWorkspaceAccess || !access.isAdmin) {
|
||||
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
|
||||
const { id: credentialId } = await context.params
|
||||
const targetUserId = new URL(request.url).searchParams.get('userId')
|
||||
if (!targetUserId) {
|
||||
return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [memberToRevoke] = await db
|
||||
.select()
|
||||
const admin = await requireAdminMembership(credentialId, session.user.id)
|
||||
if (!admin) {
|
||||
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select({
|
||||
id: credentialMember.id,
|
||||
role: credentialMember.role,
|
||||
status: credentialMember.status,
|
||||
})
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, id),
|
||||
eq(credentialMember.userId, parseResult.data.userId)
|
||||
eq(credentialMember.credentialId, credentialId),
|
||||
eq(credentialMember.userId, targetUserId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!memberToRevoke) {
|
||||
if (!target) {
|
||||
return NextResponse.json({ error: 'Member not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (memberToRevoke.status !== 'active') {
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
}
|
||||
|
||||
if (memberToRevoke.role === 'admin') {
|
||||
if (target.role === 'admin') {
|
||||
const activeAdmins = await db
|
||||
.select({ id: credentialMember.id })
|
||||
.from(credentialMember)
|
||||
.where(
|
||||
and(
|
||||
eq(credentialMember.credentialId, id),
|
||||
eq(credentialMember.credentialId, credentialId),
|
||||
eq(credentialMember.role, 'admin'),
|
||||
eq(credentialMember.status, 'active')
|
||||
)
|
||||
)
|
||||
|
||||
if (activeAdmins.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot revoke the last active admin from a credential' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(credentialMember)
|
||||
.set({
|
||||
status: 'revoked',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(credentialMember.id, memberToRevoke.id))
|
||||
await db.delete(credentialMember).where(eq(credentialMember.id, target.id))
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke credential member', error)
|
||||
logger.error('Failed to remove credential member', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { db } from '@sim/db'
|
||||
import { credential, credentialMember } from '@sim/db/schema'
|
||||
import { credential, credentialMember, environment, 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 { getCredentialActorContext } from '@/lib/credentials/access'
|
||||
import {
|
||||
syncPersonalEnvCredentialsForUser,
|
||||
syncWorkspaceEnvCredentials,
|
||||
} from '@/lib/credentials/environment'
|
||||
|
||||
const logger = createLogger('CredentialByIdAPI')
|
||||
|
||||
@@ -138,6 +142,89 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Credential admin permission required' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (access.credential.type === 'env_personal' && access.credential.envKey) {
|
||||
const ownerUserId = access.credential.envOwnerUserId
|
||||
if (!ownerUserId) {
|
||||
return NextResponse.json({ error: 'Invalid personal secret owner' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [personalRow] = await db
|
||||
.select({ variables: environment.variables })
|
||||
.from(environment)
|
||||
.where(eq(environment.userId, ownerUserId))
|
||||
.limit(1)
|
||||
|
||||
const current = ((personalRow?.variables as Record<string, string> | null) ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
if (access.credential.envKey in current) {
|
||||
delete current[access.credential.envKey]
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(environment)
|
||||
.values({
|
||||
id: ownerUserId,
|
||||
userId: ownerUserId,
|
||||
variables: current,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [environment.userId],
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncPersonalEnvCredentialsForUser({
|
||||
userId: ownerUserId,
|
||||
envKeys: Object.keys(current),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
}
|
||||
|
||||
if (access.credential.type === 'env_workspace' && access.credential.envKey) {
|
||||
const [workspaceRow] = await db
|
||||
.select({
|
||||
id: workspaceEnvironment.id,
|
||||
createdAt: workspaceEnvironment.createdAt,
|
||||
variables: workspaceEnvironment.variables,
|
||||
})
|
||||
.from(workspaceEnvironment)
|
||||
.where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId))
|
||||
.limit(1)
|
||||
|
||||
const current = ((workspaceRow?.variables as Record<string, string> | null) ?? {}) as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
if (access.credential.envKey in current) {
|
||||
delete current[access.credential.envKey]
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(workspaceEnvironment)
|
||||
.values({
|
||||
id: workspaceRow?.id || crypto.randomUUID(),
|
||||
workspaceId: access.credential.workspaceId,
|
||||
variables: current,
|
||||
createdAt: workspaceRow?.createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [workspaceEnvironment.workspaceId],
|
||||
set: { variables: current, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
await syncWorkspaceEnvCredentials({
|
||||
workspaceId: access.credential.workspaceId,
|
||||
envKeys: Object.keys(current),
|
||||
actingUserId: session.user.id,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
}
|
||||
|
||||
await db.delete(credential).where(eq(credential.id, id))
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
|
||||
73
apps/sim/app/api/credentials/draft/route.ts
Normal file
73
apps/sim/app/api/credentials/draft/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { db } from '@sim/db'
|
||||
import { pendingCredentialDraft } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, lt } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('CredentialDraftAPI')
|
||||
|
||||
const DRAFT_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
const createDraftSchema = z.object({
|
||||
workspaceId: z.string().min(1),
|
||||
providerId: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const parsed = createDraftSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { workspaceId, providerId, displayName } = parsed.data
|
||||
const userId = session.user.id
|
||||
const now = new Date()
|
||||
|
||||
await db
|
||||
.delete(pendingCredentialDraft)
|
||||
.where(
|
||||
and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now))
|
||||
)
|
||||
|
||||
await db
|
||||
.insert(pendingCredentialDraft)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
workspaceId,
|
||||
providerId,
|
||||
displayName,
|
||||
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
|
||||
createdAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
pendingCredentialDraft.userId,
|
||||
pendingCredentialDraft.providerId,
|
||||
pendingCredentialDraft.workspaceId,
|
||||
],
|
||||
set: {
|
||||
displayName,
|
||||
expiresAt: new Date(now.getTime() + DRAFT_TTL_MS),
|
||||
createdAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
logger.info('Credential draft saved', { userId, workspaceId, providerId, displayName })
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to save credential draft', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -302,7 +302,7 @@ export const EnvVarDropdown: React.FC<EnvVarDropdownProps> = ({
|
||||
}}
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
<span>Create environment variable</span>
|
||||
<span>Create Secret</span>
|
||||
</PopoverItem>
|
||||
</PopoverScrollArea>
|
||||
) : (
|
||||
|
||||
@@ -473,7 +473,7 @@ function ConnectionsSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
{/* Secrets */}
|
||||
{envVars.length > 0 && (
|
||||
<div className='mb-[2px] last:mb-0'>
|
||||
<div
|
||||
@@ -489,7 +489,7 @@ function ConnectionsSection({
|
||||
'text-[var(--text-secondary)] group-hover:text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
Environment Variables
|
||||
Secrets
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
|
||||
@@ -22,10 +22,7 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
clearPendingOAuthCredentialDraft,
|
||||
readPendingCredentialCreateRequest,
|
||||
readPendingOAuthCredentialDraft,
|
||||
writePendingOAuthCredentialDraft,
|
||||
} from '@/lib/credentials/client-state'
|
||||
import {
|
||||
getCanonicalScopesForProvider,
|
||||
@@ -60,17 +57,6 @@ import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('CredentialsManager')
|
||||
|
||||
interface AuthAccount {
|
||||
id: string
|
||||
accountId: string
|
||||
providerId: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
interface AuthAccountsResponse {
|
||||
accounts?: AuthAccount[]
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'member', label: 'Member' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
@@ -78,8 +64,8 @@ const roleOptions = [
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'oauth', label: 'OAuth Account' },
|
||||
{ value: 'env_workspace', label: 'Workspace Environment' },
|
||||
{ value: 'env_personal', label: 'Personal Environment' },
|
||||
{ value: 'env_workspace', label: 'Workspace Secret' },
|
||||
{ value: 'env_personal', label: 'Personal Secret' },
|
||||
] as const
|
||||
|
||||
function typeBadgeVariant(type: WorkspaceCredential['type']): 'blue' | 'amber' | 'gray-secondary' {
|
||||
@@ -90,8 +76,8 @@ function typeBadgeVariant(type: WorkspaceCredential['type']): 'blue' | 'amber' |
|
||||
|
||||
function typeLabel(type: WorkspaceCredential['type']): string {
|
||||
if (type === 'oauth') return 'OAuth'
|
||||
if (type === 'env_workspace') return 'Workspace Env'
|
||||
return 'Personal Env'
|
||||
if (type === 'env_workspace') return 'Workspace Secret'
|
||||
return 'Personal Secret'
|
||||
}
|
||||
|
||||
function normalizeEnvKeyInput(raw: string): string {
|
||||
@@ -119,7 +105,6 @@ export function CredentialsManager() {
|
||||
const [detailsError, setDetailsError] = useState<string | null>(null)
|
||||
const [selectedEnvValueDraft, setSelectedEnvValueDraft] = useState('')
|
||||
const [isEditingEnvValue, setIsEditingEnvValue] = useState(false)
|
||||
const [isFinalizingOAuthDraft, setIsFinalizingOAuthDraft] = useState(false)
|
||||
const [showCreateOAuthRequiredModal, setShowCreateOAuthRequiredModal] = useState(false)
|
||||
const { data: session } = useSession()
|
||||
const currentUserId = session?.user?.id || ''
|
||||
@@ -168,16 +153,6 @@ export function CredentialsManager() {
|
||||
runBootstrapCredentials()
|
||||
}, [workspaceId, runBootstrapCredentials])
|
||||
|
||||
const fetchOAuthAccountsForProvider = async (providerId: string): Promise<AuthAccount[]> => {
|
||||
const response = await fetch(`/api/auth/accounts?provider=${encodeURIComponent(providerId)}`)
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to fetch OAuth accounts')
|
||||
}
|
||||
const data = (await response.json()) as AuthAccountsResponse
|
||||
return data.accounts ?? []
|
||||
}
|
||||
|
||||
const { data: workspacePermissions } = useWorkspacePermissionsQuery(workspaceId || null)
|
||||
const selectedCredential = useMemo(
|
||||
() => credentials.find((credential) => credential.id === selectedCredentialId) || null,
|
||||
@@ -325,92 +300,6 @@ export function CredentialsManager() {
|
||||
clearPendingCredentialCreateRequest()
|
||||
}, [workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return
|
||||
if (isFinalizingOAuthDraft) return
|
||||
|
||||
const draft = readPendingOAuthCredentialDraft()
|
||||
if (!draft) return
|
||||
|
||||
if (draft.workspaceId !== workspaceId) {
|
||||
return
|
||||
}
|
||||
|
||||
const draftAgeMs = Date.now() - draft.requestedAt
|
||||
if (draftAgeMs > 15 * 60 * 1000) {
|
||||
clearPendingOAuthCredentialDraft()
|
||||
return
|
||||
}
|
||||
|
||||
const finalize = async () => {
|
||||
setIsFinalizingOAuthDraft(true)
|
||||
try {
|
||||
await bootstrapCredentials.mutateAsync()
|
||||
const refetched = await refetchCredentials()
|
||||
const latestCredentials = refetched.data ?? credentials
|
||||
|
||||
const providerCredentials = latestCredentials
|
||||
.filter(
|
||||
(row): row is WorkspaceCredential & { accountId: string; providerId: string } =>
|
||||
row.type === 'oauth' && Boolean(row.accountId) && Boolean(row.providerId)
|
||||
)
|
||||
.filter((row) => row.providerId === draft.providerId)
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
|
||||
const newAccountCredential = providerCredentials.find(
|
||||
(row) => !draft.existingAccountIds.includes(row.accountId)
|
||||
)
|
||||
const newCredential = providerCredentials.find(
|
||||
(row) => !draft.existingCredentialIds.includes(row.id)
|
||||
)
|
||||
const targetCredential = newAccountCredential || newCredential || providerCredentials[0]
|
||||
|
||||
if (!targetCredential?.accountId || !targetCredential.providerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await createCredential.mutateAsync({
|
||||
workspaceId,
|
||||
type: 'oauth',
|
||||
displayName: draft.displayName,
|
||||
providerId: targetCredential.providerId,
|
||||
accountId: targetCredential.accountId,
|
||||
})
|
||||
|
||||
const credentialId = response?.credential?.id || targetCredential.id
|
||||
if (credentialId) {
|
||||
setSelectedCredentialId(credentialId)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('oauth-credentials-updated', {
|
||||
detail: { providerId: draft.providerId, workspaceId },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
setShowCreateModal(false)
|
||||
setCreateDisplayName('')
|
||||
setCreateError(null)
|
||||
clearPendingOAuthCredentialDraft()
|
||||
} catch (error) {
|
||||
logger.error('Failed to finalize OAuth credential draft', error)
|
||||
} finally {
|
||||
setIsFinalizingOAuthDraft(false)
|
||||
}
|
||||
}
|
||||
|
||||
void finalize()
|
||||
}, [
|
||||
workspaceId,
|
||||
credentials,
|
||||
isFinalizingOAuthDraft,
|
||||
bootstrapCredentials,
|
||||
refetchCredentials,
|
||||
createCredential,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCredential) {
|
||||
setSelectedEnvValueDraft('')
|
||||
@@ -542,13 +431,11 @@ export function CredentialsManager() {
|
||||
if (!createEnvKey.trim()) return
|
||||
const normalizedEnvKey = normalizeEnvKeyInput(createEnvKey)
|
||||
if (!isValidEnvVarName(normalizedEnvKey)) {
|
||||
setCreateError(
|
||||
'Environment variable key must contain only letters, numbers, and underscores.'
|
||||
)
|
||||
setCreateError('Secret key must contain only letters, numbers, and underscores.')
|
||||
return
|
||||
}
|
||||
if (!createEnvValue.trim()) {
|
||||
setCreateError('Environment variable value is required.')
|
||||
setCreateError('Secret value is required.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -617,29 +504,14 @@ export function CredentialsManager() {
|
||||
|
||||
setCreateError(null)
|
||||
try {
|
||||
let existingAccountIds: string[] = []
|
||||
try {
|
||||
const accounts = await fetchOAuthAccountsForProvider(selectedOAuthService.providerId)
|
||||
existingAccountIds = accounts.map((account) => account.id)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to capture OAuth account snapshot before connect', { error })
|
||||
}
|
||||
|
||||
const existingCredentialIds = credentials
|
||||
.filter(
|
||||
(row): row is WorkspaceCredential & { providerId: string } =>
|
||||
row.type === 'oauth' && Boolean(row.providerId)
|
||||
)
|
||||
.filter((row) => row.providerId === selectedOAuthService.providerId)
|
||||
.map((row) => row.id)
|
||||
|
||||
writePendingOAuthCredentialDraft({
|
||||
workspaceId,
|
||||
providerId: selectedOAuthService.providerId,
|
||||
displayName,
|
||||
existingCredentialIds,
|
||||
existingAccountIds,
|
||||
requestedAt: Date.now(),
|
||||
await fetch('/api/credentials/draft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
providerId: selectedOAuthService.providerId,
|
||||
displayName,
|
||||
}),
|
||||
})
|
||||
|
||||
await connectOAuthService.mutateAsync({
|
||||
@@ -647,7 +519,6 @@ export function CredentialsManager() {
|
||||
callbackURL: window.location.href,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
clearPendingOAuthCredentialDraft()
|
||||
const message = error instanceof Error ? error.message : 'Failed to start OAuth connection'
|
||||
setCreateError(message)
|
||||
logger.error('Failed to connect OAuth service', error)
|
||||
@@ -878,7 +749,7 @@ export function CredentialsManager() {
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
<Label htmlFor='credential-env-key'>Environment variable key</Label>
|
||||
<Label htmlFor='credential-env-key'>Secret key</Label>
|
||||
<Input
|
||||
id='credential-env-key'
|
||||
value={selectedCredential.envKey || ''}
|
||||
@@ -889,7 +760,7 @@ export function CredentialsManager() {
|
||||
/>
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='credential-env-value'>Environment variable value</Label>
|
||||
<Label htmlFor='credential-env-value'>Secret value</Label>
|
||||
{canEditSelectedEnvValue && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -937,11 +808,6 @@ export function CredentialsManager() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedCredential.type !== 'oauth' && (
|
||||
<p className='mt-[8px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
{`Linked env key: ${selectedCredential.envKey || 'unknown'} (${selectedCredential.type === 'env_workspace' ? 'workspace' : 'personal'})`}
|
||||
</p>
|
||||
)}
|
||||
{detailsError && (
|
||||
<div className='mt-[8px] rounded-[8px] border border-[var(--status-red)]/40 bg-[var(--status-red)]/10 px-[10px] py-[8px] text-[12px] text-[var(--status-red)]'>
|
||||
{detailsError}
|
||||
@@ -1126,7 +992,7 @@ export function CredentialsManager() {
|
||||
) : (
|
||||
<div className='flex flex-col gap-[10px]'>
|
||||
<div>
|
||||
<Label>Environment variable key</Label>
|
||||
<Label>Secret key</Label>
|
||||
<Input
|
||||
value={createEnvKey}
|
||||
onChange={(event) => {
|
||||
@@ -1146,7 +1012,7 @@ export function CredentialsManager() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Environment variable value</Label>
|
||||
<Label>Secret value</Label>
|
||||
<Input
|
||||
type='password'
|
||||
value={createEnvValue}
|
||||
|
||||
@@ -134,7 +134,7 @@ function WorkspaceVariableRow({
|
||||
<Trash />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete environment variable</Tooltip.Content>
|
||||
<Tooltip.Content>Delete secret</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
@@ -637,7 +637,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
<Trash />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Delete environment variable</Tooltip.Content>
|
||||
<Tooltip.Content>Delete secret</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
@@ -811,7 +811,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
filteredWorkspaceEntries.length === 0 &&
|
||||
(envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
|
||||
<div className='py-[16px] text-center text-[13px] text-[var(--text-muted)]'>
|
||||
No environment variables found matching "{searchTerm}"
|
||||
No secrets found matching "{searchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
clearPendingOAuthCredentialDraft,
|
||||
readPendingOAuthCredentialDraft,
|
||||
} from '@/lib/credentials/client-state'
|
||||
import type { Credential } from '@/lib/oauth'
|
||||
import { CREDENTIAL_SET } from '@/executor/constants'
|
||||
import { useCredentialSetDetail } from '@/hooks/queries/credential-sets'
|
||||
@@ -16,10 +12,6 @@ interface CredentialDetailResponse {
|
||||
credentials?: Credential[]
|
||||
}
|
||||
|
||||
interface AuthAccountsResponse {
|
||||
accounts?: Array<{ id: string }>
|
||||
}
|
||||
|
||||
export const oauthCredentialKeys = {
|
||||
list: (providerId?: string, workspaceId?: string, workflowId?: string) =>
|
||||
[
|
||||
@@ -38,80 +30,11 @@ interface FetchOAuthCredentialsParams {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
async function finalizePendingOAuthCredentialDraftIfNeeded(params: {
|
||||
providerId: string
|
||||
workspaceId?: string
|
||||
}) {
|
||||
const { providerId, workspaceId } = params
|
||||
if (!workspaceId || !providerId) return
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const draft = readPendingOAuthCredentialDraft()
|
||||
if (!draft) return
|
||||
if (draft.workspaceId !== workspaceId || draft.providerId !== providerId) return
|
||||
|
||||
const draftAgeMs = Date.now() - draft.requestedAt
|
||||
if (draftAgeMs > 15 * 60 * 1000) {
|
||||
clearPendingOAuthCredentialDraft()
|
||||
return
|
||||
}
|
||||
|
||||
const bootstrapResponse = await fetch('/api/credentials/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
if (!bootstrapResponse.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const accountsResponse = await fetch(
|
||||
`/api/auth/accounts?provider=${encodeURIComponent(providerId)}`
|
||||
)
|
||||
if (!accountsResponse.ok) {
|
||||
return
|
||||
}
|
||||
const accountsData = (await accountsResponse.json()) as AuthAccountsResponse
|
||||
const accountIds = (accountsData.accounts ?? []).map((account) => account.id)
|
||||
if (accountIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetAccountId =
|
||||
accountIds.find((accountId) => !draft.existingAccountIds.includes(accountId)) ?? accountIds[0]
|
||||
if (!targetAccountId) {
|
||||
return
|
||||
}
|
||||
|
||||
const createResponse = await fetch('/api/credentials', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workspaceId,
|
||||
type: 'oauth',
|
||||
displayName: draft.displayName,
|
||||
providerId,
|
||||
accountId: targetAccountId,
|
||||
}),
|
||||
})
|
||||
if (!createResponse.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
clearPendingOAuthCredentialDraft()
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('oauth-credentials-updated', {
|
||||
detail: { providerId, workspaceId },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchOAuthCredentials(
|
||||
params: FetchOAuthCredentialsParams
|
||||
): Promise<Credential[]> {
|
||||
const { providerId, workspaceId, workflowId } = params
|
||||
if (!providerId) return []
|
||||
await finalizePendingOAuthCredentialDraftIfNeeded({ providerId, workspaceId })
|
||||
const data = await fetchJson<CredentialListResponse>('/api/auth/oauth/credentials', {
|
||||
searchParams: {
|
||||
provider: providerId,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
oneTimeToken,
|
||||
organization,
|
||||
} from 'better-auth/plugins'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { headers } from 'next/headers'
|
||||
import Stripe from 'stripe'
|
||||
import {
|
||||
@@ -150,16 +150,6 @@ export const auth = betterAuth({
|
||||
account: {
|
||||
create: {
|
||||
before: async (account) => {
|
||||
// Only one credential per (userId, providerId) is allowed
|
||||
// If user reconnects (even with a different external account), delete the old one
|
||||
// and let Better Auth create the new one (returning false breaks account linking flow)
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(
|
||||
eq(schema.account.userId, account.userId),
|
||||
eq(schema.account.providerId, account.providerId)
|
||||
),
|
||||
})
|
||||
|
||||
const modifiedAccount = { ...account }
|
||||
|
||||
if (account.providerId === 'salesforce' && account.accessToken) {
|
||||
@@ -189,32 +179,148 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Microsoft refresh token expiry
|
||||
if (isMicrosoftProvider(account.providerId)) {
|
||||
modifiedAccount.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Delete the existing account so Better Auth can create the new one
|
||||
// This allows account linking/re-authorization to succeed
|
||||
await db.delete(schema.account).where(eq(schema.account.id, existing.id))
|
||||
|
||||
// Preserve the existing account ID so references (like workspace notifications) continue to work
|
||||
modifiedAccount.id = existing.id
|
||||
|
||||
logger.info('[account.create.before] Deleted existing account for re-authorization', {
|
||||
userId: account.userId,
|
||||
providerId: account.providerId,
|
||||
existingAccountId: existing.id,
|
||||
preservingId: true,
|
||||
})
|
||||
|
||||
// Sync webhooks for credential sets after reconnecting (in after hook)
|
||||
}
|
||||
|
||||
return { data: modifiedAccount }
|
||||
},
|
||||
after: async (account) => {
|
||||
/**
|
||||
* Migrate credentials from stale account rows to the newly created one.
|
||||
*
|
||||
* Each getUserInfo appends a random UUID to the stable external ID so
|
||||
* that Better Auth never blocks cross-user connections. This means
|
||||
* re-connecting the same external identity creates a new row. We detect
|
||||
* the stale siblings here by comparing the stable prefix (everything
|
||||
* before the trailing UUID), migrate any credential FKs to the new row,
|
||||
* then delete the stale rows.
|
||||
*/
|
||||
try {
|
||||
const UUID_SUFFIX_RE = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
const stablePrefix = account.accountId.replace(UUID_SUFFIX_RE, '')
|
||||
|
||||
if (stablePrefix && stablePrefix !== account.accountId) {
|
||||
const siblings = await db
|
||||
.select({ id: schema.account.id, accountId: schema.account.accountId })
|
||||
.from(schema.account)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.account.userId, account.userId),
|
||||
eq(schema.account.providerId, account.providerId),
|
||||
sql`${schema.account.id} != ${account.id}`
|
||||
)
|
||||
)
|
||||
|
||||
const staleRows = siblings.filter(
|
||||
(row) => row.accountId.replace(UUID_SUFFIX_RE, '') === stablePrefix
|
||||
)
|
||||
|
||||
if (staleRows.length > 0) {
|
||||
const staleIds = staleRows.map((row) => row.id)
|
||||
|
||||
await db
|
||||
.update(schema.credential)
|
||||
.set({ accountId: account.id })
|
||||
.where(inArray(schema.credential.accountId, staleIds))
|
||||
|
||||
await db.delete(schema.account).where(inArray(schema.account.id, staleIds))
|
||||
|
||||
logger.info('[account.create.after] Migrated credentials from stale accounts', {
|
||||
userId: account.userId,
|
||||
providerId: account.providerId,
|
||||
newAccountId: account.id,
|
||||
migratedFrom: staleIds,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[account.create.after] Failed to clean up stale accounts', {
|
||||
userId: account.userId,
|
||||
providerId: account.providerId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* If a pending credential draft exists for this (userId, providerId),
|
||||
* create the credential now with the user's chosen display name.
|
||||
* This is deterministic — the account row is guaranteed to exist.
|
||||
*/
|
||||
try {
|
||||
const [draft] = await db
|
||||
.select()
|
||||
.from(schema.pendingCredentialDraft)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.pendingCredentialDraft.userId, account.userId),
|
||||
eq(schema.pendingCredentialDraft.providerId, account.providerId),
|
||||
sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (draft) {
|
||||
const credentialId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
try {
|
||||
await db.insert(schema.credential).values({
|
||||
id: credentialId,
|
||||
workspaceId: draft.workspaceId,
|
||||
type: 'oauth',
|
||||
displayName: draft.displayName,
|
||||
providerId: account.providerId,
|
||||
accountId: account.id,
|
||||
createdBy: account.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await db.insert(schema.credentialMember).values({
|
||||
id: crypto.randomUUID(),
|
||||
credentialId,
|
||||
userId: account.userId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt: now,
|
||||
invitedBy: account.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
logger.info('[account.create.after] Created credential from draft', {
|
||||
credentialId,
|
||||
displayName: draft.displayName,
|
||||
providerId: account.providerId,
|
||||
accountId: account.id,
|
||||
})
|
||||
} catch (insertError: unknown) {
|
||||
const code =
|
||||
insertError && typeof insertError === 'object' && 'code' in insertError
|
||||
? (insertError as { code: string }).code
|
||||
: undefined
|
||||
if (code !== '23505') {
|
||||
throw insertError
|
||||
}
|
||||
logger.info('[account.create.after] Credential already exists, skipping draft', {
|
||||
providerId: account.providerId,
|
||||
accountId: account.id,
|
||||
})
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(schema.pendingCredentialDraft)
|
||||
.where(eq(schema.pendingCredentialDraft.id, draft.id))
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[account.create.after] Failed to create credential from draft', {
|
||||
userId: account.userId,
|
||||
providerId: account.providerId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
|
||||
await ensureUserStatsExists(account.userId)
|
||||
@@ -1487,7 +1593,7 @@ export const auth = betterAuth({
|
||||
})
|
||||
|
||||
return {
|
||||
id: `${data.user_id || data.hub_id.toString()}-${crypto.randomUUID()}`,
|
||||
id: `${(data.user_id || data.hub_id).toString()}-${crypto.randomUUID()}`,
|
||||
name: data.user || 'HubSpot User',
|
||||
email: data.user || `hubspot-${data.hub_id}@hubspot.com`,
|
||||
emailVerified: true,
|
||||
@@ -1541,7 +1647,7 @@ export const auth = betterAuth({
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
id: `${data.user_id || data.sub}-${crypto.randomUUID()}`,
|
||||
id: `${(data.user_id || data.sub).toString()}-${crypto.randomUUID()}`,
|
||||
name: data.name || 'Salesforce User',
|
||||
email: data.email || `salesforce-${data.user_id}@salesforce.com`,
|
||||
emailVerified: data.email_verified || true,
|
||||
@@ -1600,7 +1706,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${profile.data.id}-${crypto.randomUUID()}`,
|
||||
id: `${profile.data.id.toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.data.name || 'X User',
|
||||
email: `${profile.data.username}@x.com`,
|
||||
image: profile.data.profile_image_url,
|
||||
@@ -1680,7 +1786,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${profile.account_id}-${crypto.randomUUID()}`,
|
||||
id: `${profile.account_id.toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.name || profile.display_name || 'Confluence User',
|
||||
email: profile.email || `${profile.account_id}@atlassian.com`,
|
||||
image: profile.picture || undefined,
|
||||
@@ -1791,7 +1897,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${profile.account_id}-${crypto.randomUUID()}`,
|
||||
id: `${profile.account_id.toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.name || profile.display_name || 'Jira User',
|
||||
email: profile.email || `${profile.account_id}@atlassian.com`,
|
||||
image: profile.picture || undefined,
|
||||
@@ -1841,7 +1947,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${data.id}-${crypto.randomUUID()}`,
|
||||
id: `${data.id.toString()}-${crypto.randomUUID()}`,
|
||||
name: data.email ? data.email.split('@')[0] : 'Airtable User',
|
||||
email: data.email || `${data.id}@airtable.user`,
|
||||
emailVerified: !!data.email,
|
||||
@@ -1890,7 +1996,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${profile.bot?.owner?.user?.id || profile.id}-${crypto.randomUUID()}`,
|
||||
id: `${(profile.bot?.owner?.user?.id || profile.id).toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.name || profile.bot?.owner?.user?.name || 'Notion User',
|
||||
email: profile.person?.email || `${profile.id}@notion.user`,
|
||||
emailVerified: !!profile.person?.email,
|
||||
@@ -1957,7 +2063,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${data.id}-${crypto.randomUUID()}`,
|
||||
id: `${data.id.toString()}-${crypto.randomUUID()}`,
|
||||
name: data.name || 'Reddit User',
|
||||
email: `${data.name}@reddit.user`,
|
||||
image: data.icon_img || undefined,
|
||||
@@ -2029,7 +2135,7 @@ export const auth = betterAuth({
|
||||
const viewer = data.viewer
|
||||
|
||||
return {
|
||||
id: `${viewer.id}-${crypto.randomUUID()}`,
|
||||
id: `${viewer.id.toString()}-${crypto.randomUUID()}`,
|
||||
email: viewer.email,
|
||||
name: viewer.name,
|
||||
emailVerified: true,
|
||||
@@ -2092,7 +2198,7 @@ export const auth = betterAuth({
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
id: `${data.account_id}-${crypto.randomUUID()}`,
|
||||
id: `${data.account_id.toString()}-${crypto.randomUUID()}`,
|
||||
email: data.email,
|
||||
name: data.name?.display_name || data.email,
|
||||
emailVerified: data.email_verified || false,
|
||||
@@ -2143,7 +2249,7 @@ export const auth = betterAuth({
|
||||
const now = new Date()
|
||||
|
||||
return {
|
||||
id: `${profile.gid}-${crypto.randomUUID()}`,
|
||||
id: `${profile.gid.toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.name || 'Asana User',
|
||||
email: profile.email || `${profile.gid}@asana.user`,
|
||||
image: profile.photo?.image_128x128 || undefined,
|
||||
@@ -2378,7 +2484,7 @@ export const auth = betterAuth({
|
||||
const profile = await response.json()
|
||||
|
||||
return {
|
||||
id: `${profile.id}-${crypto.randomUUID()}`,
|
||||
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
|
||||
name:
|
||||
`${profile.first_name || ''} ${profile.last_name || ''}`.trim() || 'Zoom User',
|
||||
email: profile.email || `${profile.id}@zoom.user`,
|
||||
@@ -2445,7 +2551,7 @@ export const auth = betterAuth({
|
||||
const profile = await response.json()
|
||||
|
||||
return {
|
||||
id: `${profile.id}-${crypto.randomUUID()}`,
|
||||
id: `${profile.id.toString()}-${crypto.randomUUID()}`,
|
||||
name: profile.display_name || 'Spotify User',
|
||||
email: profile.email || `${profile.id}@spotify.user`,
|
||||
emailVerified: true,
|
||||
|
||||
@@ -9,6 +9,12 @@ interface AccessibleEnvCredential {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
function getPostgresErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined
|
||||
const err = error as { code?: string; cause?: { code?: string } }
|
||||
return err.code || err.cause?.code
|
||||
}
|
||||
|
||||
export async function getWorkspaceMemberUserIds(workspaceId: string): Promise<string[]> {
|
||||
const [workspaceRows, permissionRows] = await Promise.all([
|
||||
db
|
||||
@@ -184,17 +190,22 @@ export async function syncWorkspaceEnvCredentials(params: {
|
||||
}
|
||||
|
||||
const createdId = crypto.randomUUID()
|
||||
await db.insert(credential).values({
|
||||
id: createdId,
|
||||
workspaceId,
|
||||
type: 'env_workspace',
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
createdBy: actingUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
credentialIdsToEnsureMembership.add(createdId)
|
||||
try {
|
||||
await db.insert(credential).values({
|
||||
id: createdId,
|
||||
workspaceId,
|
||||
type: 'env_workspace',
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
createdBy: actingUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
credentialIdsToEnsureMembership.add(createdId)
|
||||
} catch (error: unknown) {
|
||||
const code = getPostgresErrorCode(error)
|
||||
if (code !== '23505') throw error
|
||||
}
|
||||
}
|
||||
|
||||
for (const credentialId of credentialIdsToEnsureMembership) {
|
||||
@@ -259,18 +270,23 @@ export async function syncPersonalEnvCredentialsForUser(params: {
|
||||
}
|
||||
|
||||
const createdId = crypto.randomUUID()
|
||||
await db.insert(credential).values({
|
||||
id: createdId,
|
||||
workspaceId,
|
||||
type: 'env_personal',
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
envOwnerUserId: userId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
await upsertCredentialAdminMember(createdId, userId)
|
||||
try {
|
||||
await db.insert(credential).values({
|
||||
id: createdId,
|
||||
workspaceId,
|
||||
type: 'env_personal',
|
||||
displayName: envKey,
|
||||
envKey,
|
||||
envOwnerUserId: userId,
|
||||
createdBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
await upsertCredentialAdminMember(createdId, userId)
|
||||
} catch (error: unknown) {
|
||||
const code = getPostgresErrorCode(error)
|
||||
if (code !== '23505') throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedKeys.length > 0) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, credential, credentialMember } from '@sim/db/schema'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { getServiceConfigByProviderId } from '@/lib/oauth'
|
||||
|
||||
interface SyncWorkspaceOAuthCredentialsForUserParams {
|
||||
workspaceId: string
|
||||
@@ -12,6 +13,12 @@ interface SyncWorkspaceOAuthCredentialsForUserResult {
|
||||
updatedMemberships: number
|
||||
}
|
||||
|
||||
function getPostgresErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined
|
||||
const err = error as { code?: string; cause?: { code?: string } }
|
||||
return err.code || err.cause?.code
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures connected OAuth accounts for a user exist as workspace-scoped credentials.
|
||||
*/
|
||||
@@ -37,6 +44,8 @@ export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
const existingCredentials = await db
|
||||
.select({
|
||||
id: credential.id,
|
||||
displayName: credential.displayName,
|
||||
providerId: credential.providerId,
|
||||
accountId: credential.accountId,
|
||||
})
|
||||
.from(credential)
|
||||
@@ -48,14 +57,39 @@ export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
)
|
||||
)
|
||||
|
||||
const now = new Date()
|
||||
const userAccountById = new Map(userAccounts.map((row) => [row.id, row]))
|
||||
for (const existingCredential of existingCredentials) {
|
||||
if (!existingCredential.accountId) continue
|
||||
const linkedAccount = userAccountById.get(existingCredential.accountId)
|
||||
if (!linkedAccount) continue
|
||||
|
||||
const normalizedLabel =
|
||||
getServiceConfigByProviderId(linkedAccount.providerId)?.name || linkedAccount.providerId
|
||||
const shouldNormalizeDisplayName =
|
||||
existingCredential.displayName === linkedAccount.accountId ||
|
||||
existingCredential.displayName === linkedAccount.providerId
|
||||
|
||||
if (!shouldNormalizeDisplayName || existingCredential.displayName === normalizedLabel) {
|
||||
continue
|
||||
}
|
||||
|
||||
await db
|
||||
.update(credential)
|
||||
.set({
|
||||
displayName: normalizedLabel,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(credential.id, existingCredential.id))
|
||||
}
|
||||
|
||||
const existingByAccountId = new Map(
|
||||
existingCredentials
|
||||
.filter((row): row is { id: string; accountId: string } => Boolean(row.accountId))
|
||||
.map((row) => [row.accountId, row.id])
|
||||
.filter((row) => Boolean(row.accountId))
|
||||
.map((row) => [row.accountId!, row.id])
|
||||
)
|
||||
|
||||
let createdCredentials = 0
|
||||
const now = new Date()
|
||||
|
||||
for (const acc of userAccounts) {
|
||||
if (existingByAccountId.has(acc.id)) {
|
||||
@@ -67,7 +101,7 @@ export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId,
|
||||
type: 'oauth',
|
||||
displayName: acc.accountId || acc.providerId,
|
||||
displayName: getServiceConfigByProviderId(acc.providerId)?.name || acc.providerId,
|
||||
providerId: acc.providerId,
|
||||
accountId: acc.id,
|
||||
createdBy: userId,
|
||||
@@ -75,8 +109,8 @@ export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
updatedAt: now,
|
||||
})
|
||||
createdCredentials += 1
|
||||
} catch (error: any) {
|
||||
if (error?.code !== '23505') {
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) !== '23505') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -94,9 +128,7 @@ export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
)
|
||||
|
||||
const credentialIdByAccountId = new Map(
|
||||
credentialRows
|
||||
.filter((row): row is { id: string; accountId: string } => Boolean(row.accountId))
|
||||
.map((row) => [row.accountId, row.id])
|
||||
credentialRows.filter((row) => Boolean(row.accountId)).map((row) => [row.accountId!, row.id])
|
||||
)
|
||||
const allCredentialIds = Array.from(credentialIdByAccountId.values())
|
||||
if (allCredentialIds.length === 0) {
|
||||
@@ -139,18 +171,24 @@ export async function syncWorkspaceOAuthCredentialsForUser(
|
||||
continue
|
||||
}
|
||||
|
||||
await db.insert(credentialMember).values({
|
||||
id: crypto.randomUUID(),
|
||||
credentialId,
|
||||
userId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt: now,
|
||||
invitedBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
updatedMemberships += 1
|
||||
try {
|
||||
await db.insert(credentialMember).values({
|
||||
id: crypto.randomUUID(),
|
||||
credentialId,
|
||||
userId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joinedAt: now,
|
||||
invitedBy: userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
updatedMemberships += 1
|
||||
} catch (error) {
|
||||
if (getPostgresErrorCode(error) !== '23505') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { createdCredentials, updatedMemberships }
|
||||
|
||||
Reference in New Issue
Block a user