Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/credentials/[id]/members/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
])
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)
Expand All @@ -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 (
<DetailSection title={`Members (${activeMembers.length})`}>
{membersLoading ? null : (
Expand All @@ -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),
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
onSelect: () => setIsLeaveConfirmOpen(true),
}
: isAdmin
? {
label: 'Remove',
disabled: actionDisabled,
onSelect: () => handleRemoveMember(member.userId),
}
: null
return (
<MemberRow
key={member.id}
member={{ ...member, userImage: member.userImage ?? null }}
roleOptions={MEMBER_ROLE_OPTIONS}
lockReason={lockReason}
canManage={isAdmin}
roleDisabled={!isAdmin || roleLocked || lockReason !== null}
removeDisabled={roleLocked || lockReason !== null}
showActions={showActions}
action={action}
roleDisabled={!isAdmin || actionDisabled}
onRoleChange={(role) => handleChangeMemberRole(member.userId, role)}
onRemove={() => handleRemoveMember(member.userId)}
/>
)
})}
</div>
)}

<ChipConfirmModal
open={isLeaveConfirmOpen}
onOpenChange={setIsLeaveConfirmOpen}
title='Leave credential'
text={[
'Are you sure you want to leave ',
{ text: credentialName, bold: true },
'? You will lose access to it until an admin adds you back.',
]}
confirm={{
label: 'Leave',
onClick: handleConfirmLeave,
pending: leaveCredential.isPending,
pendingLabel: 'Leaving...',
}}
/>
</DetailSection>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,12 @@ export function ConnectedCredentialDetail({
</DetailSection>

{credential.type !== 'personal_token' && (
<CredentialMembersSection credentialId={credential.id} isAdmin={isAdmin} />
<CredentialMembersSection
credentialId={credential.id}
credentialName={credential.displayName}
isAdmin={isAdmin}
leaveHref={integrationsHref}
/>
)}
{credential.type === 'personal_token' && credential.instanceUrl && (
<DetailSection title='GitLab instance'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,14 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
</DetailSection>
)}

{!isPersonal && <CredentialMembersSection credentialId={credential.id} isAdmin={isAdmin} />}
{!isPersonal && (
<CredentialMembersSection
credentialId={credential.id}
credentialName={credential.displayName}
isAdmin={isAdmin}
leaveHref={secretsHref}
/>
)}
</CredentialDetailLayout>

{!isPersonal && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
/>
))}
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/components/permissions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 24 additions & 15 deletions apps/sim/components/permissions/member-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TRole extends string = MemberRole> {
userId: string
userName: string | null
Expand All @@ -16,41 +25,39 @@ interface MemberRowProps<TRole extends string = MemberRole> {
member: MemberRowMember<TRole>
/** 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`.
*/
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<TRole extends string = MemberRole>({
member,
lockReason,
canManage,
showActions,
action,
roleDisabled,
removeDisabled,
roleOptions,
onRoleChange,
onRemove,
}: MemberRowProps<TRole>) {
return (
<div
className={cn(
'grid items-center gap-2',
canManage ? 'grid-cols-[1fr_120px_72px]' : 'grid-cols-[1fr_200px]'
showActions ? 'grid-cols-[1fr_120px_72px]' : 'grid-cols-[1fr_200px]'
)}
>
<div className='flex min-w-0 items-center gap-2.5'>
Expand Down Expand Up @@ -80,10 +87,12 @@ export function MemberRow<TRole extends string = MemberRole>({
onChange={(role) => onRoleChange?.(role as TRole)}
/>
</RoleLockTooltip>
{canManage && (
<Chip onClick={onRemove} disabled={removeDisabled} className='justify-self-end'>
Remove
</Chip>
{action && (
<RoleLockTooltip reason={action.disabled ? (action.disabledReason ?? null) : null}>
<Chip onClick={action.onSelect} disabled={action.disabled} className='justify-self-end'>
{action.label}
</Chip>
</RoleLockTooltip>
)}
</div>
)
Expand Down
31 changes: 30 additions & 1 deletion apps/sim/hooks/queries/credentials.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,6 +10,7 @@ import {
getSecretReferencesContract,
getSecretUsageContract,
getWorkspaceCredentialContract,
leaveCredentialMembershipContract,
listWorkspaceCredentialMembersContract,
removeWorkspaceCredentialMemberContract,
type SecretUsageScope,
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api/contracts/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof workspaceCredentialMemberSchema>
Expand Down
Loading
Loading