diff --git a/apps/sim/app/api/credentials/[id]/members/route.test.ts b/apps/sim/app/api/credentials/[id]/members/route.test.ts index b7960846808..8d08a511c53 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.test.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.test.ts @@ -75,6 +75,7 @@ describe('/api/credentials/[id]/members compatibility', () => { joinedAt: new Date('2026-08-02T00:00:00.000Z'), userName: 'Member', userEmail: 'member@example.com', + explicitAdmin: false, }, ]) }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts index 19d3211a318..9458d61bfdb 100644 --- a/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts +++ b/apps/sim/app/api/v2/credentials/[credentialId]/members/route.ts @@ -39,7 +39,7 @@ export const GET = defineV2JsonRoute({ }), useCase: listCredentialMembersUseCase, present: ({ members, nextCursorKeys }, { params, query }) => ({ - data: members.map((member) => ({ + data: members.map(({ explicitAdmin, ...member }) => ({ ...member, joinedAt: member.joinedAt?.toISOString() ?? null, })), diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx index 11d861a8b03..ca9506b40c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx @@ -1,12 +1,20 @@ 'use client' + +import { useState } from 'react' +import { ChipConfirmModal, toast } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { useRouter } from 'next/navigation' import { credentialRoleLockReason, MEMBER_ROLE_OPTIONS, type MemberRole, MemberRow, + type MemberRowAction, } from '@/components/permissions' +import { useSession } from '@/lib/auth/client' import { + useLeaveCredential, useRemoveWorkspaceCredentialMember, useUpsertWorkspaceCredentialMember, useWorkspaceCredentialMembers, @@ -17,24 +25,38 @@ const logger = createLogger('CredentialMembersSection') interface CredentialMembersSectionProps { credentialId: string + credentialName: string isAdmin: boolean + /** Where the viewer lands after leaving, since leaving revokes their access. */ + leaveHref: string } /** - * Active-member list for a credential: avatar + identity, a role dropdown, and a - * remove action. The last remaining admin cannot be demoted or removed. Shared - * by every credential detail surface. + * Active-member list for a credential: avatar + identity, a role dropdown, a + * remove action for admins, and a leave action on the viewer's own row. The + * last remaining admin cannot be demoted, removed, or leave. Shared by every + * credential detail surface. */ -export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMembersSectionProps) { +export function CredentialMembersSection({ + credentialId, + credentialName, + isAdmin, + leaveHref, +}: CredentialMembersSectionProps) { + const router = useRouter() + const { data: session } = useSession() const { data: members = [], isPending: membersLoading } = useWorkspaceCredentialMembers(credentialId) const upsertMember = useUpsertWorkspaceCredentialMember() const removeMember = useRemoveWorkspaceCredentialMember() + const leaveCredential = useLeaveCredential() + + const [isLeaveConfirmOpen, setIsLeaveConfirmOpen] = useState(false) + const viewerId = session?.user?.id const activeMembers = members.filter((member) => member.status === 'active') - const explicitAdminCount = activeMembers.filter( - (member) => member.role === 'admin' && member.roleSource !== 'workspace-admin' - ).length + const explicitAdminCount = activeMembers.filter((member) => member.explicitAdmin).length + const showActions = isAdmin || activeMembers.some((member) => member.userId === viewerId) const handleChangeMemberRole = async (userId: string, role: MemberRole) => { const current = activeMembers.find((member) => member.userId === userId) @@ -54,6 +76,19 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe } } + const handleConfirmLeave = async () => { + try { + await leaveCredential.mutateAsync(credentialId) + setIsLeaveConfirmOpen(false) + router.push(leaveHref) + } catch (error) { + toast.error("Couldn't leave credential", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + logger.error('Failed to leave credential', error) + } + } + return ( {membersLoading ? null : ( @@ -64,22 +99,56 @@ export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMe member.role === 'admin' && member.roleSource !== 'workspace-admin' && explicitAdminCount <= 1 + const actionDisabled = roleLocked || lockReason !== null + const action: MemberRowAction | null = + member.userId === viewerId + ? { + label: 'Leave', + disabled: actionDisabled, + disabledReason: + lockReason ?? + (roleLocked ? 'Make someone else an admin before leaving' : null), + onSelect: () => setIsLeaveConfirmOpen(true), + } + : isAdmin + ? { + label: 'Remove', + disabled: actionDisabled, + onSelect: () => handleRemoveMember(member.userId), + } + : null return ( handleChangeMemberRole(member.userId, role)} - onRemove={() => handleRemoveMember(member.userId)} /> ) })} )} + + ) } 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 06c5a43ec9f..71005eaf063 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 @@ -318,7 +318,12 @@ export function ConnectedCredentialDetail({ {credential.type !== 'personal_token' && ( - + )} {credential.type === 'personal_token' && credential.instanceUrl && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx index 3b46902192d..9226ce4cf42 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx @@ -291,7 +291,14 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) { )} - {!isPersonal && } + {!isPersonal && ( + + )} {!isPersonal && ( diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx index d271d37f0c3..c264f9df597 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/components/skill-editors-card.tsx @@ -47,10 +47,17 @@ export function SkillEditorsCard({ editors, canEdit }: SkillEditorsCardProps) { }} roleOptions={SKILL_EDITOR_ROLE_OPTIONS} lockReason={skillEditorLockReason(editor.isWorkspaceAdmin)} - canManage={canEdit} + showActions={canEdit} + action={ + canEdit + ? { + label: 'Remove', + disabled: editor.isWorkspaceAdmin, + onSelect: () => editors.removeEditor(editor.userId), + } + : null + } roleDisabled - removeDisabled={editor.isWorkspaceAdmin} - onRemove={() => editors.removeEditor(editor.userId)} /> ))} diff --git a/apps/sim/components/permissions/index.ts b/apps/sim/components/permissions/index.ts index 04caa7302b8..71e7f1465fc 100644 --- a/apps/sim/components/permissions/index.ts +++ b/apps/sim/components/permissions/index.ts @@ -6,7 +6,7 @@ export { SKILL_EDITOR_ROLE_OPTIONS, type SkillEditorRole, } from './member-role-options' -export { MemberRow, type MemberRowMember } from './member-row' +export { MemberRow, type MemberRowAction, type MemberRowMember } from './member-row' export { type OrgRole, OrgRoleSelector, diff --git a/apps/sim/components/permissions/member-row.tsx b/apps/sim/components/permissions/member-row.tsx index 07cb8fa251a..8c7f3e00726 100644 --- a/apps/sim/components/permissions/member-row.tsx +++ b/apps/sim/components/permissions/member-row.tsx @@ -4,6 +4,15 @@ import { Avatar, Chip, ChipDropdown, cn, OverflowText } from '@sim/emcn' import type { MemberRole } from './member-role-options' import { RoleLockTooltip } from './role-lock' +/** The row's trailing chip: Remove for a manager, Leave on the viewer's own row. */ +export interface MemberRowAction { + label: string + disabled: boolean + /** Why the action is disabled, shown on hover. */ + disabledReason?: string | null + onSelect: () => void +} + export interface MemberRowMember { userId: string userName: string | null @@ -16,10 +25,10 @@ interface MemberRowProps { member: MemberRowMember /** Why the role is fixed (derived access); null when editable. */ lockReason: string | null - /** Whether the viewer can act on rows (shows the Remove column). */ - canManage: boolean + /** Whether the list reserves a trailing action column; the same for every row. */ + showActions: boolean + action: MemberRowAction | null roleDisabled: boolean - removeDisabled: boolean /** * Role choices for the dropdown. A surface whose membership is binary * (skills) passes its own single option and always sets `roleDisabled`. @@ -27,30 +36,28 @@ interface MemberRowProps { roleOptions: readonly { value: TRole; label: string }[] /** Omitted on surfaces with nothing to switch between (the role stays disabled). */ onRoleChange?: (role: TRole) => void - onRemove: () => void } /** * One member row of a shared-resource member list: avatar + identity, a role - * dropdown (wrapped in a lock tooltip when the role is derived), and a remove - * action for managers. Consumers own the policy (who is locked/disabled); this - * row owns the chrome. + * dropdown (wrapped in a lock tooltip when the role is derived), and an + * optional trailing action. Consumers own the policy (who is locked/disabled); + * this row owns the chrome. */ export function MemberRow({ member, lockReason, - canManage, + showActions, + action, roleDisabled, - removeDisabled, roleOptions, onRoleChange, - onRemove, }: MemberRowProps) { return (
@@ -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(