checkpoint

This commit is contained in:
Vikhyath Mondreti
2026-02-11 19:58:24 -08:00
parent 253161afba
commit 7314675f50
16 changed files with 587 additions and 456 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,20 @@ CREATE INDEX "credential_member_role_idx" ON "credential_member" USING btree ("r
CREATE INDEX "credential_member_status_idx" ON "credential_member" USING btree ("status");--> statement-breakpoint
CREATE UNIQUE INDEX "credential_member_unique" ON "credential_member" USING btree ("credential_id","user_id");
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "pending_credential_draft" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"workspace_id" text NOT NULL,
"provider_id" text NOT NULL,
"display_name" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "pending_credential_draft" ADD CONSTRAINT "pending_credential_draft_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pending_credential_draft" ADD CONSTRAINT "pending_credential_draft_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "pending_draft_user_provider_ws" ON "pending_credential_draft" USING btree ("user_id","provider_id","workspace_id");
--> statement-breakpoint
DROP INDEX IF EXISTS "account_user_provider_unique";
--> statement-breakpoint
WITH workspace_user_access AS (

View File

@@ -2095,6 +2095,30 @@ export const credentialMember = pgTable(
})
)
export const pendingCredentialDraft = pgTable(
'pending_credential_draft',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
displayName: text('display_name').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
uniqueDraft: uniqueIndex('pending_draft_user_provider_ws').on(
table.userId,
table.providerId,
table.workspaceId
),
})
)
export const credentialSet = pgTable(
'credential_set',
{