From 7314675f50cc1526d4426b41520755484cd67438 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 11 Feb 2026 19:58:24 -0800 Subject: [PATCH] checkpoint --- apps/sim/app/api/auth/accounts/route.ts | 3 +- .../api/auth/oauth2/shopify/store/route.ts | 9 +- apps/sim/app/api/auth/trello/store/route.ts | 6 +- .../app/api/credentials/[id]/members/route.ts | 232 ++++++++---------- apps/sim/app/api/credentials/[id]/route.ts | 89 ++++++- apps/sim/app/api/credentials/draft/route.ts | 73 ++++++ .../sub-block/components/env-var-dropdown.tsx | 2 +- .../preview-editor/preview-editor.tsx | 4 +- .../credentials/credentials-manager.tsx | 170 ++----------- .../components/environment/environment.tsx | 6 +- apps/sim/hooks/queries/oauth-credentials.ts | 77 ------ apps/sim/lib/auth/auth.ts | 192 +++++++++++---- apps/sim/lib/credentials/environment.ts | 62 +++-- apps/sim/lib/credentials/oauth.ts | 80 ++++-- .../migrations/0154_luxuriant_maria_hill.sql | 14 ++ packages/db/schema.ts | 24 ++ 16 files changed, 587 insertions(+), 456 deletions(-) create mode 100644 apps/sim/app/api/credentials/draft/route.ts diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index ed385b6d8..aebb5d6a2 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -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, diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index cf7aef92a..85729149b 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -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, diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index fff52b0a8..97fc9b8ab 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -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() diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 1d4a1dd17..4289cc121 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -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 }) } } diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 1b2066b0c..f393aea9a 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -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 | 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 | 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) { diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts new file mode 100644 index 000000000..e55248b15 --- /dev/null +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx index 96b11ebf2..5df676c31 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown.tsx @@ -302,7 +302,7 @@ export const EnvVarDropdown: React.FC = ({ }} > - Create environment variable + Create Secret ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 936e8f114..87b042b69 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -473,7 +473,7 @@ function ConnectionsSection({ )} - {/* Environment Variables */} + {/* Secrets */} {envVars.length > 0 && (
- Environment Variables + Secrets (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 => { - 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() {
) : (
- +
- + {canEditSelectedEnvValue && ( - Delete environment variable + Delete secret
@@ -637,7 +637,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment - Delete environment variable + Delete secret
@@ -811,7 +811,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment filteredWorkspaceEntries.length === 0 && (envVars.length > 0 || Object.keys(workspaceVars).length > 0) && (
- No environment variables found matching "{searchTerm}" + No secrets found matching "{searchTerm}"
)} diff --git a/apps/sim/hooks/queries/oauth-credentials.ts b/apps/sim/hooks/queries/oauth-credentials.ts index 6a66fa4b6..b300db6ee 100644 --- a/apps/sim/hooks/queries/oauth-credentials.ts +++ b/apps/sim/hooks/queries/oauth-credentials.ts @@ -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 { const { providerId, workspaceId, workflowId } = params if (!providerId) return [] - await finalizePendingOAuthCredentialDraftIfNeeded({ providerId, workspaceId }) const data = await fetchJson('/api/auth/oauth/credentials', { searchParams: { provider: providerId, diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index be5b961f0..1f232ee94 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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, diff --git a/apps/sim/lib/credentials/environment.ts b/apps/sim/lib/credentials/environment.ts index dffeb9277..1a9beef43 100644 --- a/apps/sim/lib/credentials/environment.ts +++ b/apps/sim/lib/credentials/environment.ts @@ -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 { 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) { diff --git a/apps/sim/lib/credentials/oauth.ts b/apps/sim/lib/credentials/oauth.ts index 6d434fc9e..8173b96c6 100644 --- a/apps/sim/lib/credentials/oauth.ts +++ b/apps/sim/lib/credentials/oauth.ts @@ -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 } diff --git a/packages/db/migrations/0154_luxuriant_maria_hill.sql b/packages/db/migrations/0154_luxuriant_maria_hill.sql index 6e1d2fc75..c99289275 100644 --- a/packages/db/migrations/0154_luxuriant_maria_hill.sql +++ b/packages/db/migrations/0154_luxuriant_maria_hill.sql @@ -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 ( diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 4d122b7da..bdf133687 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -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', {