@@ -80,10 +87,12 @@ export function MemberRow({
onChange={(role) => onRoleChange?.(role as TRole)}
/>
- {canManage && (
-
- Remove
-
+ {action && (
+
+
+ {action.label}
+
+
)}
)
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index 9b0b0f5443d..1a9c0c43127 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -1,6 +1,6 @@
'use client'
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { type QueryKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import type { ContractBodyInput, ContractQueryInput } from '@/lib/api/contracts'
import {
@@ -10,6 +10,7 @@ import {
getSecretReferencesContract,
getSecretUsageContract,
getWorkspaceCredentialContract,
+ leaveCredentialMembershipContract,
listWorkspaceCredentialMembersContract,
removeWorkspaceCredentialMemberContract,
type SecretUsageScope,
@@ -311,6 +312,34 @@ export function useRemoveWorkspaceCredentialMember() {
})
}
+/**
+ * Revokes the viewer's own membership of a credential. The caller navigates
+ * away from a credential the viewer can no longer read, so the affected caches
+ * are only marked stale — refetching them while the detail page is still
+ * mounted would flash "not found" — and whatever mounts next refetches them.
+ */
+export function useLeaveCredential() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (credentialId: string) =>
+ requestJson(leaveCredentialMembershipContract, { query: { credentialId } }),
+ onSuccess: (_data, credentialId) => {
+ const staleKeys: QueryKey[] = [
+ workspaceCredentialKeys.lists(),
+ workspaceCredentialKeys.detail(credentialId),
+ OAUTH_CREDENTIALS_KEY,
+ environmentKeys.all,
+ oauthConnectionsKeys.connections(),
+ ]
+ for (const queryKey of staleKeys) {
+ queryClient.invalidateQueries({ queryKey, refetchType: 'none' })
+ }
+ invalidateSelectorQueries(queryClient)
+ },
+ })
+}
+
/**
* The trail is written by every run that resolves the secret, so it goes stale quickly. A
* short window keeps "last used" meaningful without refetching on every panel interaction.
diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts
index 008ec9b1955..f7e6ac573eb 100644
--- a/apps/sim/lib/api/contracts/credentials.ts
+++ b/apps/sim/lib/api/contracts/credentials.ts
@@ -365,6 +365,8 @@ export const workspaceCredentialMemberSchema = z.object({
userImage: z.string().nullable().optional(),
/** `workspace-admin` roles are derived from workspace admin and cannot be changed. */
roleSource: z.enum(['explicit', 'workspace-admin']).optional(),
+ /** Holds an active explicit admin grant; the last-admin guards count these, not `role`. */
+ explicitAdmin: z.boolean(),
})
export type WorkspaceCredentialMember = z.output
diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts
index 114fffdbc2b..11ede7914d9 100644
--- a/apps/sim/lib/credentials/members.ts
+++ b/apps/sim/lib/credentials/members.ts
@@ -30,6 +30,11 @@ export interface CredentialMemberView {
userEmail: string | null
userImage: string | null
roleSource: 'explicit' | 'workspace-admin'
+ /**
+ * Whether the user holds an active explicit admin grant, independent of any
+ * inherited workspace-admin role. This is what the last-admin guards count.
+ */
+ explicitAdmin: boolean
}
export async function listCredentialMembers(
@@ -52,7 +57,14 @@ export async function listCredentialMembers(
.where(eq(credentialMember.credentialId, credential.id))
const byUser = new Map(
- explicitMembers.map((member) => [member.userId, { ...member, roleSource: 'explicit' as const }])
+ explicitMembers.map((member) => [
+ member.userId,
+ {
+ ...member,
+ roleSource: 'explicit' as const,
+ explicitAdmin: member.role === 'admin' && member.status === 'active',
+ },
+ ])
)
if (isSharedCredentialType(credential.type)) {
@@ -75,6 +87,7 @@ export async function listCredentialMembers(
userEmail: workspaceMember.email,
userImage: workspaceMember.image ?? null,
roleSource: 'workspace-admin',
+ explicitAdmin: false,
})
}
}
@@ -148,6 +161,7 @@ export async function listCredentialMembersPage(
roleSource: sql<
'explicit' | 'workspace-admin'
>`case when ${inheritedAdmin} then 'workspace-admin' else 'explicit' end`,
+ explicitAdmin: sql`coalesce(${credentialMember.role} = 'admin' and ${credentialMember.status} = 'active', false)`,
})
.from(user)
.leftJoin(