diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
index 2b1ae99f392..0a0962db7e6 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx
@@ -46,7 +46,6 @@ import {
} from '@/hooks/queries/credentials'
import {
useConnectOAuthService,
- useDisconnectOAuthService,
useOAuthConnections,
} from '@/hooks/queries/oauth/oauth-connections'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
@@ -74,7 +73,6 @@ export function ConnectedCredentialDetail({
const { data: oauthConnections = [] } = useOAuthConnections()
const connectOAuthService = useConnectOAuthService()
- const disconnectOAuthService = useDisconnectOAuthService()
const createDraft = useCreateCredentialDraft()
const deleteCredential = useDeleteWorkspaceCredential()
@@ -146,30 +144,15 @@ export function ConnectedCredentialDetail({
}
}
+ /**
+ * Every credential type disconnects through the workspace-scoped credential
+ * delete, which authorizes against credential admin — explicit members and
+ * derived workspace admins alike.
+ */
const handleConfirmDelete = async () => {
if (!credential) return
try {
- if (credential.type === 'service_account') {
- await deleteCredential.mutateAsync(credential.id)
- } else {
- if (!credential.accountId || !credential.providerId) {
- toast.error("Can't disconnect", {
- description: 'Missing account information. Try reconnecting this credential first.',
- })
- return
- }
- await disconnectOAuthService.mutateAsync({
- provider: credential.providerId.split('-')[0] || credential.providerId,
- providerId: credential.providerId,
- serviceId: credential.providerId,
- accountId: credential.accountId,
- })
- window.dispatchEvent(
- new CustomEvent('oauth-credentials-updated', {
- detail: { providerId: credential.providerId, workspaceId },
- })
- )
- }
+ await deleteCredential.mutateAsync(credential.id)
setShowDeleteConfirmDialog(false)
router.push(integrationsHref)
} catch (error) {
@@ -207,7 +190,7 @@ export function ConnectedCredentialDetail({
setShowDeleteConfirmDialog(true)}
- disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
+ disabled={deleteCredential.isPending}
>
Disconnect
@@ -303,7 +286,7 @@ export function ConnectedCredentialDetail({
confirm={{
label: 'Disconnect',
onClick: handleConfirmDelete,
- pending: disconnectOAuthService.isPending || deleteCredential.isPending,
+ pending: deleteCredential.isPending,
pendingLabel: 'Disconnecting...',
}}
/>
diff --git a/apps/sim/components/permissions/role-lock.tsx b/apps/sim/components/permissions/role-lock.tsx
index a3f6af078c1..4b6fed19f04 100644
--- a/apps/sim/components/permissions/role-lock.tsx
+++ b/apps/sim/components/permissions/role-lock.tsx
@@ -54,6 +54,9 @@ interface RoleLockTooltipProps {
/**
* Wraps a disabled role control in a tooltip explaining why the role is fixed.
* Renders children unchanged when there is no lock reason.
+ *
+ * The trigger is a `grid` so the wrapper stays layout-transparent; a
+ * shrink-to-content wrapper would size locked and unlocked rows differently.
*/
export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) {
if (!reason) return <>{children}>
@@ -61,7 +64,7 @@ export function RoleLockTooltip({ reason, children }: RoleLockTooltipProps) {
return (
- {children}
+ {children}
{reason}
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index 8d931e4e20e..3f0dceb37a3 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -20,6 +20,7 @@ import {
type WorkspaceCredentialType,
} from '@/lib/api/contracts'
import { environmentKeys } from '@/hooks/queries/environment'
+import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections'
import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys'
import {
fetchWorkspaceCredentialList,
@@ -211,6 +212,7 @@ export function useDeleteWorkspaceCredential() {
queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY })
queryClient.invalidateQueries({ queryKey: environmentKeys.all })
+ queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
},
})
}
diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts
index e3a5becbc0b..a1fa1e29813 100644
--- a/apps/sim/hooks/queries/oauth/oauth-connections.ts
+++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts
@@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type ConnectedAccount,
- disconnectOAuthContract,
listOAuthConnectionsContract,
type OAuthAccountSummary,
type OAuthConnection,
@@ -199,68 +198,5 @@ export function useConnectOAuthService() {
})
}
-interface DisconnectServiceParams {
- provider: string
- providerId?: string
- serviceId: string
- accountId?: string
-}
-
-/**
- * Disconnects an OAuth service account.
- * Performs optimistic update and rolls back on failure.
- */
-export function useDisconnectOAuthService() {
- const queryClient = useQueryClient()
-
- return useMutation({
- mutationFn: async ({ provider, providerId, accountId }: DisconnectServiceParams) => {
- return requestJson(disconnectOAuthContract, {
- body: {
- provider,
- providerId,
- accountId,
- },
- })
- },
- onMutate: async ({ serviceId, accountId }) => {
- await queryClient.cancelQueries({ queryKey: oauthConnectionsKeys.connections() })
-
- const previousServices = queryClient.getQueryData(
- oauthConnectionsKeys.connections()
- )
-
- if (previousServices) {
- queryClient.setQueryData(
- oauthConnectionsKeys.connections(),
- previousServices.map((svc) => {
- if (svc.id === serviceId) {
- const updatedAccounts =
- accountId && svc.accounts ? svc.accounts.filter((acc) => acc.id !== accountId) : []
- return {
- ...svc,
- accounts: updatedAccounts,
- isConnected: updatedAccounts.length > 0,
- }
- }
- return svc
- })
- )
- }
-
- return { previousServices }
- },
- onError: (_err, _variables, context) => {
- if (context?.previousServices) {
- queryClient.setQueryData(oauthConnectionsKeys.connections(), context.previousServices)
- }
- logger.error('Failed to disconnect service')
- },
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: oauthConnectionsKeys.connections() })
- },
- })
-}
-
/** Connected OAuth account for a specific provider. */
export type { ConnectedAccount }
diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts
index 6dc2ece8096..dbdf310231e 100644
--- a/apps/sim/lib/credentials/application/service-account.test.ts
+++ b/apps/sim/lib/credentials/application/service-account.test.ts
@@ -13,7 +13,6 @@ const mocks = vi.hoisted(() => ({
requireProvider: vi.fn(),
getCredential: vi.fn(),
getActor: vi.fn(),
- delete: vi.fn(),
deleteRecord: vi.fn(),
capture: vi.fn(),
}))
@@ -29,7 +28,6 @@ vi.mock('@sim/platform-authz/workspace', () => ({
}))
vi.mock('@/lib/credentials/orchestration', () => ({
createServiceAccountCredential: mocks.create,
- deleteConnectionCredential: mocks.delete,
deleteCredentialRecord: mocks.deleteRecord,
}))
vi.mock('@/lib/credentials/application/provider-catalog', () => ({
@@ -96,7 +94,6 @@ describe('credential service-account application operations', () => {
auditMetadata: { tenantId: 'tenant-1' },
})
mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }])
- mocks.delete.mockResolvedValue(true)
mocks.deleteRecord.mockResolvedValue(true)
mocks.requireProvider.mockReturnValue({
type: 'service_account',
@@ -196,7 +193,7 @@ describe('credential service-account application operations', () => {
code: 'forbidden',
detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED',
})
- expect(mocks.delete).not.toHaveBeenCalled()
+ expect(mocks.deleteRecord).not.toHaveBeenCalled()
})
it('rejects workspace keys before canonical loading on disconnect', async () => {
@@ -215,7 +212,7 @@ describe('credential service-account application operations', () => {
})
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
expect(mocks.getActor).not.toHaveBeenCalled()
- expect(mocks.delete).not.toHaveBeenCalled()
+ expect(mocks.deleteRecord).not.toHaveBeenCalled()
})
it('allows an explicit credential admin with workspace read access to disconnect', async () => {
@@ -227,7 +224,7 @@ describe('credential service-account application operations', () => {
input: { workspaceId: WORKSPACE_ID, credentialId: credential.id },
})
).resolves.toEqual({ credential, deleted: true })
- expect(mocks.delete).toHaveBeenCalledOnce()
+ expect(mocks.deleteRecord).toHaveBeenCalledOnce()
})
it('applies credential admin policy during authorization-only checks', async () => {
@@ -237,7 +234,7 @@ describe('credential service-account application operations', () => {
})
expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId)
- expect(mocks.delete).not.toHaveBeenCalled()
+ expect(mocks.deleteRecord).not.toHaveBeenCalled()
})
it('enforces personal-key workspace policy before credential authorization', async () => {
@@ -253,7 +250,7 @@ describe('credential service-account application operations', () => {
detailCode: 'PERSONAL_API_KEYS_DISABLED',
})
expect(mocks.getActor).not.toHaveBeenCalled()
- expect(mocks.delete).not.toHaveBeenCalled()
+ expect(mocks.deleteRecord).not.toHaveBeenCalled()
})
it('disconnects an administered credential', async () => {
@@ -263,15 +260,38 @@ describe('credential service-account application operations', () => {
})
expect(result).toEqual({ credential, deleted: true })
- expect(mocks.delete).toHaveBeenCalledWith({
- credentialId: credential.id,
- workspaceId: WORKSPACE_ID,
+ expect(mocks.deleteRecord).toHaveBeenCalledWith({ credential, reason: 'user_delete' })
+ })
+
+ it('deletes every type through the record manager so secret sources are torn down', async () => {
+ const oauthCredential = {
+ ...credential,
+ type: 'oauth',
+ providerId: 'google-email',
+ accountId: 'acct-1',
+ }
+ mocks.getCredential.mockResolvedValue(oauthCredential)
+ mocks.getActor.mockResolvedValue({
+ credential: oauthCredential,
+ member: { role: 'admin' },
+ hasWorkspaceAccess: true,
+ isAdmin: true,
+ })
+
+ const result = await deleteCredentialUseCase.execute({
+ principal,
+ input: { workspaceId: WORKSPACE_ID, credentialId: oauthCredential.id },
+ })
+
+ expect(result).toEqual({ credential: oauthCredential, deleted: true })
+ expect(mocks.deleteRecord).toHaveBeenCalledWith({
+ credential: oauthCredential,
reason: 'user_delete',
})
})
it('treats a concurrent disconnect as an idempotent success', async () => {
- mocks.delete.mockResolvedValue(false)
+ mocks.deleteRecord.mockResolvedValue(false)
const result = await deleteCredentialUseCase.execute({
principal,
diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts
index 078cb50f04d..fe3c058ba77 100644
--- a/apps/sim/lib/credentials/application/service-account.ts
+++ b/apps/sim/lib/credentials/application/service-account.ts
@@ -15,7 +15,6 @@ import {
import {
type CreateServiceAccountCredentialParams,
createServiceAccountCredential,
- deleteConnectionCredential,
deleteCredentialRecord,
} from '@/lib/credentials/orchestration'
import type { CredentialRow } from '@/lib/credentials/queries'
@@ -159,14 +158,7 @@ export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({
)
}
const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete'
- const deleted =
- context.credential.type === 'oauth' || context.credential.type === 'service_account'
- ? await deleteConnectionCredential({
- credentialId: context.credential.id,
- workspaceId: context.workspaceId,
- reason,
- })
- : await deleteCredentialRecord({ credential: context.credential, reason })
+ const deleted = await deleteCredentialRecord({ credential: context.credential, reason })
return { credential: context.credential, deleted }
},
projectAudit: ({ principal, result }) => {
diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts
index f16902ddf04..f2b8d866c29 100644
--- a/apps/sim/lib/credentials/deletion.ts
+++ b/apps/sim/lib/credentials/deletion.ts
@@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, or, sql } from 'drizzle-orm'
+import { and, eq, notExists, or, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { CREDENTIAL_SUBBLOCK_IDS } from '@/lib/workflows/persistence/utils'
@@ -101,6 +101,39 @@ export async function deleteConnectionCredential(
return deleted.length === 1
}
+/**
+ * Deletes the stored OAuth grant behind a credential once nothing references
+ * it. The `account` row is an OAuth credential's secret source, so leaving it
+ * behind keeps a usable grant; nothing is revoked provider-side.
+ *
+ * Scoped by `accountId`, not by owner — the caller is already authorized
+ * against the credential, which may belong to another user.
+ *
+ * The reference check is a predicate on the delete: `credential.accountId` is
+ * `ON DELETE CASCADE`, so a credential racing a separate check would be reaped
+ * by Postgres without {@link clearCredentialRefs} ever running.
+ */
+export async function deleteOrphanedOAuthAccount(accountId: string): Promise {
+ const deleted = await db
+ .delete(schema.account)
+ .where(
+ and(
+ eq(schema.account.id, accountId),
+ notExists(
+ db
+ .select({ referenced: sql`1` })
+ .from(schema.credential)
+ .where(eq(schema.credential.accountId, accountId))
+ )
+ )
+ )
+ .returning({ id: schema.account.id })
+
+ if (deleted.length > 0) {
+ logger.info('Deleted orphaned OAuth account', { accountId })
+ }
+}
+
/**
* Clears stored references to a credential across mutable workspace state
* (editor blocks, copilot checkpoints, knowledge connectors) and frozen
diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts
index 704a22c25fb..cec4f1e607a 100644
--- a/apps/sim/lib/credentials/draft-hooks.ts
+++ b/apps/sim/lib/credentials/draft-hooks.ts
@@ -4,6 +4,7 @@ import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, sql } from 'drizzle-orm'
+import { deleteOrphanedOAuthAccount } from '@/lib/credentials/deletion'
import { clearDeadFlag } from '@/lib/oauth/terminal-errors'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -184,15 +185,6 @@ export async function handleReconnectCredential(params: {
})
if (oldAccountId) {
- const [stillReferenced] = await db
- .select({ id: schema.credential.id })
- .from(schema.credential)
- .where(eq(schema.credential.accountId, oldAccountId))
- .limit(1)
-
- if (!stillReferenced) {
- await db.delete(schema.account).where(eq(schema.account.id, oldAccountId))
- logger.info('Deleted orphaned account after reconnect', { accountId: oldAccountId })
- }
+ await deleteOrphanedOAuthAccount(oldAccountId)
}
}
diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts
index 8ea6c8954b8..47064858db5 100644
--- a/apps/sim/lib/credentials/orchestration/index.test.ts
+++ b/apps/sim/lib/credentials/orchestration/index.test.ts
@@ -18,6 +18,7 @@ const {
mockIsClientCredentialAccountProviderId,
mockGetClientCredentialAccountDescriptor,
mockDeleteConnectionCredential,
+ mockDeleteOrphanedOAuthAccount,
} = vi.hoisted(() => ({
mockRecordAudit: vi.fn(),
mockGetCredentialActorContext: vi.fn(),
@@ -28,6 +29,7 @@ const {
// providers must not trigger the stored-blob read for authMethod/username.
mockGetClientCredentialAccountDescriptor: vi.fn(() => undefined),
mockDeleteConnectionCredential: vi.fn(),
+ mockDeleteOrphanedOAuthAccount: vi.fn(),
}))
vi.mock('@sim/audit', () => ({
@@ -51,6 +53,7 @@ vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({
}))
vi.mock('@/lib/credentials/deletion', () => ({
deleteConnectionCredential: mockDeleteConnectionCredential,
+ deleteOrphanedOAuthAccount: mockDeleteOrphanedOAuthAccount,
}))
vi.mock('@/lib/credentials/environment', () => ({
deleteWorkspaceEnvCredentials: vi.fn(),
@@ -550,4 +553,57 @@ describe('deleteCredentialRecord', () => {
})
expect(mockDeleteConnectionCredential).not.toHaveBeenCalled()
})
+
+ it('revokes the backing OAuth grant of a deleted oauth credential', async () => {
+ mockDeleteConnectionCredential.mockResolvedValueOnce(true)
+
+ const deleted = await deleteCredentialRecord({
+ credential: {
+ id: 'cred-1',
+ workspaceId: 'ws-1',
+ type: 'oauth',
+ providerId: 'google-email',
+ accountId: 'acct-1',
+ } as never,
+ reason: 'user_delete',
+ })
+
+ expect(deleted).toBe(true)
+ expect(mockDeleteOrphanedOAuthAccount).toHaveBeenCalledWith('acct-1')
+ })
+
+ it('leaves the OAuth grant alone when the credential row was already gone', async () => {
+ mockDeleteConnectionCredential.mockResolvedValueOnce(false)
+
+ const deleted = await deleteCredentialRecord({
+ credential: {
+ id: 'cred-1',
+ workspaceId: 'ws-1',
+ type: 'oauth',
+ providerId: 'google-email',
+ accountId: 'acct-1',
+ } as never,
+ reason: 'user_delete',
+ })
+
+ expect(deleted).toBe(false)
+ expect(mockDeleteOrphanedOAuthAccount).not.toHaveBeenCalled()
+ })
+
+ it('does not touch OAuth grants for a service-account credential', async () => {
+ mockDeleteConnectionCredential.mockResolvedValueOnce(true)
+
+ await deleteCredentialRecord({
+ credential: {
+ id: 'cred-1',
+ workspaceId: 'ws-1',
+ type: 'service_account',
+ providerId: 'google-service-account',
+ accountId: 'acct-1',
+ } as never,
+ reason: 'user_delete',
+ })
+
+ expect(mockDeleteOrphanedOAuthAccount).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts
index 84fd54888fb..2ca86cb8b07 100644
--- a/apps/sim/lib/credentials/orchestration/index.ts
+++ b/apps/sim/lib/credentials/orchestration/index.ts
@@ -24,7 +24,11 @@ import {
getClientCredentialAccountDescriptor,
isClientCredentialAccountProviderId,
} from '@/lib/credentials/client-credential-accounts/descriptors'
-import { type CredentialDeleteReason, deleteConnectionCredential } from '@/lib/credentials/deletion'
+import {
+ type CredentialDeleteReason,
+ deleteConnectionCredential,
+ deleteOrphanedOAuthAccount,
+} from '@/lib/credentials/deletion'
import { slackCustomBotDisplayName } from '@/lib/credentials/display-name'
import {
deleteWorkspaceEnvCredentials,
@@ -554,6 +558,18 @@ export async function deleteCredentialRecord(
return true
}
+ if (credentialRow.type === 'oauth') {
+ const deleted = await deleteConnectionCredential({
+ credentialId: credentialRow.id,
+ workspaceId: credentialRow.workspaceId,
+ reason: params.reason,
+ })
+ if (deleted && credentialRow.accountId) {
+ await deleteOrphanedOAuthAccount(credentialRow.accountId)
+ }
+ return deleted
+ }
+
return deleteConnectionCredential({
credentialId: credentialRow.id,
workspaceId: credentialRow.workspaceId,