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
54 changes: 43 additions & 11 deletions apps/sim/app/api/workspaces/members/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { captureServerEvent } from '@/lib/posthog/server'
import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access'
import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
import {
hasWorkspaceAdminAccess,
isOrganizationAdminOrOwner,
} from '@/lib/workspaces/permissions/utils'
import {
reassignWorkflowOwnershipForWorkspaceMemberRemovalTx,
transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx,
Expand Down Expand Up @@ -51,13 +54,52 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

const organizationId = workspaceRow[0].organizationId

/**
* Authority is settled before anything is answered about the target, so
* the standing-specific replies below only ever describe someone the
* caller can already see in the members list.
*/
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
const isSelf = userId === session.user.id

if (!hasAdminAccess && !isSelf) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}

if (workspaceRow[0].billedAccountUserId === userId) {
return NextResponse.json(
{ error: 'Cannot remove the workspace billing account. Please reassign billing first.' },
{ status: 400 }
)
}

/**
* Organization admins hold workspace admin across the whole organization
* through `member.role`, not through a `permissions` row, so removal has
* nothing to revoke. Left to fall through, the two shapes failed in two
* different ways: with no row it answered "user not found in workspace"
* about someone listed as an Admin on the very screen the caller clicked
* from, and with a row it deleted a grant the derived one immediately
* replaced — while the seat reconciliation below counts rows only, so
* that no-op could still drop the admin's organization membership.
*
* Mirrored by `workspaceMemberRemovalLockReason` on the client, and by the
* same guard on `PATCH /api/workspaces/[id]/permissions`, which refuses to
* re-role an organization admin for the same reason.
*/
if (organizationId && (await isOrganizationAdminOrOwner(userId, organizationId))) {
return NextResponse.json(
{
error: isSelf
? 'Organization admins are automatically workspace admins. Change your organization role to leave this workspace.'
: 'Organization admins are automatically workspace admins. Change their organization role to remove them from this workspace.',
},
{ status: 400 }
)
Comment thread
icecrasher321 marked this conversation as resolved.
}

// Check if the user to be removed actually has permissions for this workspace
const userPermission = await db
.select()
Expand All @@ -78,14 +120,6 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 })
}

// Check if current user has admin access to this workspace
const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId)
const isSelf = userId === session.user.id

if (!hasAdminAccess && !isSelf) {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}

// Removing the workspace owner is allowed for any admin: ownership transfers
// to the billing account in the transaction below. The billing account itself
// stays protected by the guard above (and personal workspaces, where owner ==
Expand Down Expand Up @@ -113,8 +147,6 @@ export const DELETE = withRouteHandler(
}
}

const organizationId = workspaceRow[0].organizationId

const { ownershipTransferred, workflowOwnershipReassignment } = await db.transaction(
async (tx) => {
const didTransferOwnership =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @vitest-environment jsdom
*
* A row action the server would refuse stays visible and greyed rather than
* disappearing, so the row can say why. That only works if three things hold at
* once: the item is actually disabled (Radix greys it), the reason reaches
* pointer users through the platform tooltip — which needs the wrapping span,
* since a disabled item is `pointer-events-none` and never sees the hover — and
* the reason reaches assistive tech through the accessible name, since Radix
* skips disabled items in a menu's roving focus.
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu/row-actions-menu'

const LOCK_REASON = 'Organization admins are automatically workspace admins.'

let root: Root | null = null
let container: HTMLDivElement | null = null

function mount(ui: ReactNode) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(ui))
}

/** Opens the `...` menu the way a pointer does — Radix opens on `pointerdown`. */
function openMenu() {
const trigger = container?.querySelector('button')
if (!trigger) throw new Error('Menu trigger did not render')
act(() => {
trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
})
}

function item(): HTMLElement {
const node = document.querySelector('[role="menuitem"]')
if (!node) throw new Error('No menu item rendered')
return node as HTMLElement
}

afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
root = null
container = null
})

describe('a disabled row action explains itself', () => {
function mountLockedRemove() {
mount(
<RowActionsMenu
label='Teammate actions'
actions={[
{
label: 'Remove',
destructive: true,
disabled: true,
tooltip: LOCK_REASON,
onSelect: () => {},
},
]}
/>
)
openMenu()
}

it('greys the item out instead of hiding it', () => {
mountLockedRemove()

const remove = item()
expect(remove.textContent).toBe('Remove')
expect(remove.getAttribute('data-disabled')).not.toBeNull()
expect(remove.className).toContain('data-[disabled]:opacity-50')
})

it('shows the reason in the platform tooltip on hover', () => {
mountLockedRemove()

expect(document.querySelector('[role="tooltip"]')).toBeNull()

/* The wrapping span, not the item — a disabled item is `pointer-events-none`. */
const hoverTarget = item().parentElement
if (!hoverTarget) throw new Error('Tooltip trigger wrapper did not render')
act(() => {
hoverTarget.dispatchEvent(
new MouseEvent('pointerover', { bubbles: true, clientX: 120, clientY: 120 })
)
})

expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(LOCK_REASON)
})

it('folds the reason into the accessible name for assistive tech', () => {
mountLockedRemove()

expect(item().getAttribute('aria-label')).toBe(`Remove — ${LOCK_REASON}`)
})

it('leaves an enabled action unlabelled and untooltipped', () => {
mount(
<RowActionsMenu
label='Teammate actions'
actions={[{ label: 'Copy email', onSelect: () => {} }]}
/>
)
openMenu()

expect(item().getAttribute('aria-label')).toBeNull()
expect(item().getAttribute('data-disabled')).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ interface RowActionsMenuProps {
* An action with a `tooltip` gets its item wrapped in a plain span tooltip
* trigger (the settings-header chip pattern) — a disabled item is
* `pointer-events-none`, so the wrapper is what keeps hover working.
*
* A disabled item's tooltip also folds into its accessible name, because Radix
* skips disabled items in a menu's roving focus: without this the explanation
* would reach pointer users only, and assistive tech would announce a dead
* "Remove" with no reason attached.
*/
export function RowActionsMenu({ label, actions, triggerClassName }: RowActionsMenuProps) {
return (
Expand All @@ -50,6 +55,11 @@ export function RowActionsMenu({ label, actions, triggerClassName }: RowActionsM
key={action.label}
onSelect={action.onSelect}
disabled={action.disabled}
aria-label={
action.disabled && action.tooltip
? `${action.label} — ${action.tooltip}`
: undefined
}
className={action.destructive ? 'text-[var(--text-error)]' : undefined}
>
{action.label}
Expand Down
Loading
Loading