diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
index 62393f2d479..444d6f573bf 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx
@@ -196,7 +196,7 @@ export function Admin() {
)}
-
+
@@ -231,10 +231,10 @@ export function Admin() {
)}
-
+
-
User Management
+
User Management
void
+ /** When false, the Delete item is disabled (e.g. non-admins on workspace keys). */
+ canDelete?: boolean
+}
+
+/**
+ * Trailing `...` actions menu for an API key row. Mirrors the Secrets /
+ * Teammates row menu so the settings experience is consistent.
+ */
+function ApiKeyRowMenu({ keyName, onDelete, canDelete = true }: ApiKeyRowMenuProps) {
+ return (
+
+
+
+
+
+
+
+
+ copyKeyName(keyName)}>Copy name
+
+ Delete
+
+
+
+
+ )
+}
+
export function ApiKeys() {
const { data: session } = useSession()
const userId = session?.user?.id
@@ -164,16 +223,14 @@ export function ApiKeys() {
{key.displayKey || key.key}
-
{
+ {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
- disabled={!canManageWorkspaceKeys}
- >
- Delete
-
+ canDelete={canManageWorkspaceKeys}
+ />
))}
@@ -197,16 +254,14 @@ export function ApiKeys() {
{key.displayKey || key.key}
- {
+ {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
- disabled={!canManageWorkspaceKeys}
- >
- Delete
-
+ canDelete={canManageWorkspaceKeys}
+ />
))}
@@ -235,15 +290,13 @@ export function ApiKeys() {
{key.displayKey || key.key}
- {
+ {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
- >
- Delete
-
+ />
{isConflict && (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx
index f41d180ed6c..6f5f05477d3 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx
@@ -20,7 +20,13 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ chipVariants,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
type FileInputOptions,
+ MoreHorizontal,
Search,
TagInput,
type TagItem,
@@ -516,13 +522,26 @@ export function CredentialSets() {
- handleRemoveMember(member.id)}
- disabled={removeMember.isPending}
- >
- Remove
-
+
+
+
+
+
+
+
+ handleRemoveMember(member.id)}
+ disabled={removeMember.isPending}
+ >
+ Remove
+
+
+
)
@@ -561,25 +580,41 @@ export function CredentialSets() {
- handleResendInvitation(invitation.id, email)}
- disabled={
- resendingInvitations.has(invitation.id) ||
- (resendCooldowns[invitation.id] ?? 0) > 0
- }
- >
- {resendingInvitations.has(invitation.id)
- ? 'Sending...'
- : resendCooldowns[invitation.id]
- ? `Resend (${resendCooldowns[invitation.id]}s)`
- : 'Resend'}
-
- handleCancelInvitation(invitation.id)}
- disabled={cancellingInvitations.has(invitation.id)}
- >
- {cancellingInvitations.has(invitation.id) ? 'Cancelling...' : 'Cancel'}
-
+
+
+
+
+
+
+
+ handleResendInvitation(invitation.id, email)}
+ disabled={
+ resendingInvitations.has(invitation.id) ||
+ (resendCooldowns[invitation.id] ?? 0) > 0
+ }
+ >
+ {resendingInvitations.has(invitation.id)
+ ? 'Sending...'
+ : resendCooldowns[invitation.id]
+ ? `Resend (${resendCooldowns[invitation.id]}s)`
+ : 'Resend'}
+
+ handleCancelInvitation(invitation.id)}
+ disabled={cancellingInvitations.has(invitation.id)}
+ >
+ {cancellingInvitations.has(invitation.id)
+ ? 'Cancelling...'
+ : 'Cancel'}
+
+
+
)
@@ -729,14 +764,30 @@ export function CredentialSets() {
-
-
setViewingSet(set)}>Details
-
handleDeleteClick(set)}
- disabled={deletingSetIds.has(set.id)}
- >
- {deletingSetIds.has(set.id) ? 'Deleting...' : 'Delete'}
-
+
+
+
+
+
+
+
+
+ setViewingSet(set)}>
+ Details
+
+ handleDeleteClick(set)}
+ disabled={deletingSetIds.has(set.id)}
+ >
+ Delete
+
+
+
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
index 797a86fafe2..86260a8fa51 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx
@@ -5,7 +5,18 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { Chip, ChipConfirmModal, ChipInput, Search } from '@/components/emcn'
+import {
+ Chip,
+ ChipConfirmModal,
+ ChipInput,
+ chipVariants,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ MoreHorizontal,
+ Search,
+} from '@/components/emcn'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
@@ -134,14 +145,30 @@ export function CustomTools() {
)}
-
-
setEditingTool(tool.id)}>Edit
-
handleDeleteClick(tool.id)}
- disabled={deletingTools.has(tool.id)}
- >
- {deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
-
+
+
+
+
+
+
+
+
+ setEditingTool(tool.id)}>
+ Edit
+
+ handleDeleteClick(tool.id)}
+ disabled={deletingTools.has(tool.id)}
+ >
+ Delete
+
+
+
))}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
index 987ef148f6f..c468f1e8e4e 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx
@@ -12,6 +12,12 @@ import {
Chip,
ChipConfirmModal,
ChipInput,
+ chipVariants,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ MoreHorizontal,
Search,
Tooltip,
} from '@/components/emcn'
@@ -120,10 +126,27 @@ function ServerListItem({
- Details
-
- {isDeleting ? 'Deleting...' : 'Delete'}
-
+
+
+
+
+
+
+
+ Details
+
+ Delete
+
+
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
index 19865911ddd..4cdf68f9e54 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx
@@ -3,7 +3,7 @@
import { useCallback, useMemo, useState } from 'react'
import { useParams } from 'next/navigation'
import { useQueryStates } from 'nuqs'
-import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn'
+import { Badge, Button, ChipInput, ChipSelect, Label, Skeleton } from '@/components/emcn'
import { AnthropicIcon, OpenAIIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import {
@@ -50,10 +50,10 @@ const TABS: { id: MothershipTab; label: string }[] = [
{ id: 'byok', label: 'BYOK' },
]
-const ENV_OPTIONS: { id: MothershipEnv; label: string }[] = [
- { id: 'dev', label: 'Dev' },
- { id: 'staging', label: 'Staging' },
- { id: 'prod', label: 'Prod' },
+const ENV_OPTIONS: { value: MothershipEnv; label: string }[] = [
+ { value: 'dev', label: 'Dev' },
+ { value: 'staging', label: 'Staging' },
+ { value: 'prod', label: 'Prod' },
]
function defaultTimeRange() {
@@ -81,11 +81,11 @@ function formatDate(d: string | null | undefined) {
}
function Divider() {
- return
+ return
}
function SectionLabel({ children }: { children: React.ReactNode }) {
- return {children}
+ return {children}
}
export function Mothership() {
@@ -104,23 +104,14 @@ export function Mothership() {
{/* Environment selector */}
Environment
-
- {ENV_OPTIONS.map((opt) => (
- setMothershipParams({ env: opt.id })}
- className={cn(
- 'rounded-md px-3 py-1 font-medium text-sm transition-colors',
- environment === opt.id
- ? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
- : 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
- )}
- >
- {opt.label}
-
- ))}
-
+
setMothershipParams({ env: value as MothershipEnv })}
+ placeholder='Select environment'
+ options={ENV_OPTIONS}
+ />
{/* Tab bar */}
@@ -149,20 +140,18 @@ export function Mothership() {
From
- setStart(e.target.value)}
- className='h-[30px] text-caption'
/>
To
- setEnd(e.target.value)}
- className='h-[30px] text-caption'
/>
@@ -431,23 +420,23 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
Enterprise Name
- {
setNewName(e.target.value)
setGeneratedKey(null)
}}
placeholder='e.g. Acme Corp'
- className='h-[32px] w-[200px]'
+ className='w-[200px]'
/>
Expiration (optional)
- setNewExpiry(e.target.value)}
- className='h-[32px] w-[160px]'
+ className='w-[160px]'
/>
void
+ /** Opens credential details; omit when the row has no backing credential. */
+ onViewDetails?: () => void
+ /** Deletes the secret (or clears the draft row); omit when the caller can't delete. */
+ onDelete?: () => void
+}
+
+/**
+ * Trailing `...` actions menu for a secret row. Mirrors the Teammates /
+ * Organization member menu so the settings experience is consistent.
+ */
+function SecretRowMenu({ onCopyName, onViewDetails, onDelete }: SecretRowMenuProps) {
+ return (
+
+
+
+
+
+
+
+ {onViewDetails && (
+ View details
+ )}
+ Copy name
+ {onDelete && (
+
+ Delete
+
+ )}
+
+
+ )
+}
const generateRowId = (() => {
let counter = 0
@@ -201,23 +259,11 @@ function WorkspaceVariableRow({
canEdit={canEdit}
name={`workspace_env_value_${envKey}_${generateShortId()}`}
/>
- onViewDetails(envKey)}
- disabled={!hasCredential}
- className={cn('ml-2', !hasCredential && 'opacity-40')}
- >
- Details
-
- {canEdit ? (
-
-
- onDelete(envKey)} aria-label='Delete secret' />
-
- Delete secret
-
- ) : (
-
- )}
+ copyName(envKey)}
+ onViewDetails={hasCredential ? () => onViewDetails(envKey) : undefined}
+ onDelete={canEdit ? () => onDelete(envKey) : undefined}
+ />
)
}
@@ -262,22 +308,19 @@ function NewWorkspaceVariableRow({
onPaste={onPaste ? (e) => onPaste(e, index) : undefined}
placeholder='Enter value'
name={`new_workspace_value_${envVar.id || index}_${generateShortId()}`}
- className='col-span-2 ml-0'
+ className='ml-0'
/>
-
-
- {
- onUpdate(index, 'key', '')
- onUpdate(index, 'value', '')
- }}
- disabled={!hasContent}
- aria-label='Delete secret'
- />
-
- {hasContent && Delete secret }
-
+ {hasContent ? (
+ copyName(envVar.key)}
+ onDelete={() => {
+ onUpdate(index, 'key', '')
+ onUpdate(index, 'value', '')
+ }}
+ />
+ ) : (
+
+ )}
{keyError && (
-
-
- removeEnvVar(originalIndex)}
- disabled={!hasContent}
- aria-label='Delete secret'
- />
-
- {hasContent && Delete secret }
-
+ {hasContent ? (
+ copyName(envVar.key)}
+ onDelete={() => removeEnvVar(originalIndex)}
+ />
+ ) : (
+
+ )}
{keyError && (
- setToolToView(tool)}>Edit
- setToolToDelete(tool)}
- disabled={deleteToolMutation.isPending}
- >
- Remove
-
+
+
+
+
+
+
+
+ setToolToView(tool)}>
+ Edit
+
+ setToolToDelete(tool)}
+ disabled={deleteToolMutation.isPending}
+ >
+ Remove
+
+
+
))}
@@ -1021,10 +1043,29 @@ export function WorkflowMcpServers() {
- setSelectedServerId(server.id)}>Details
- setServerToDelete(server)} disabled={isDeleting}>
- {isDeleting ? 'Deleting...' : 'Delete'}
-
+
+
+
+
+
+
+
+ setSelectedServerId(server.id)}>
+ Details
+
+ setServerToDelete(server)}
+ disabled={isDeleting}
+ >
+ Delete
+
+
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
index 516ffdab35b..edf87395914 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx
@@ -14,6 +14,7 @@ import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getDependsOnFields } from '@/blocks/utils'
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
+import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -98,6 +99,7 @@ export const Dropdown = memo(function Dropdown({
searchable = false,
}: DropdownProps) {
const activeSearchTarget = useActiveSearchTarget()
+ const { isToolAllowed } = usePermissionConfig()
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) as [
string | string[] | null | undefined,
(value: string | string[]) => void,
@@ -214,19 +216,42 @@ export const Dropdown = memo(function Dropdown({
return opts
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
+ /**
+ * Operation IDs whose resolved tool is denied by the caller's permission
+ * group. Only the `operation` selector of a block with a tool selector is
+ * gated. Denied operations are hidden from the picker (still resolvable for
+ * label display); the server is the authoritative gate regardless.
+ */
+ const deniedOperationIds = useMemo(() => {
+ const denied = new Set()
+ if (subBlockId !== 'operation') return denied
+ const selectTool = blockConfig?.tools?.config?.tool
+ if (!selectTool) return denied
+ for (const opt of allOptions) {
+ const optionId = typeof opt === 'string' ? opt : opt.id
+ try {
+ const toolId = selectTool({ operation: optionId })
+ if (toolId && !isToolAllowed(toolId)) denied.add(optionId)
+ } catch {
+ // Unresolvable from the operation alone — leave it visible; the server still enforces.
+ }
+ }
+ return denied
+ }, [subBlockId, blockConfig, allOptions, isToolAllowed])
+
const comboboxOptions = useMemo((): ComboboxOption[] => {
return allOptions.map((opt) => {
if (typeof opt === 'string') {
- return { label: opt.toLowerCase(), value: opt }
+ return { label: opt.toLowerCase(), value: opt, hidden: deniedOperationIds.has(opt) }
}
return {
label: opt.label.toLowerCase(),
value: opt.id,
icon: 'icon' in opt ? opt.icon : undefined,
- hidden: opt.hidden,
+ hidden: opt.hidden || deniedOperationIds.has(opt.id),
}
})
- }, [allOptions])
+ }, [allOptions, deniedOperationIds])
const optionMap = useMemo(() => {
return new Map(comboboxOptions.map((opt) => [opt.value, opt.label]))
@@ -234,16 +259,18 @@ export const Dropdown = memo(function Dropdown({
const defaultOptionValue = useMemo(() => {
if (multiSelect) return undefined
+
+ const firstSelectable = comboboxOptions.find((opt) => !opt.hidden)
if (defaultValue !== undefined) {
+ // Don't seed a denied operation as the default; use the first allowed option.
+ if (deniedOperationIds.has(defaultValue)) {
+ return firstSelectable?.value
+ }
return defaultValue
}
- if (comboboxOptions.length > 0) {
- return comboboxOptions[0].value
- }
-
- return undefined
- }, [defaultValue, comboboxOptions, multiSelect])
+ return firstSelectable?.value
+ }, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect])
useEffect(() => {
if (multiSelect || defaultOptionValue === undefined) {
diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx
index 22bede4363f..6ac886fec28 100644
--- a/apps/sim/ee/access-control/components/access-control.tsx
+++ b/apps/sim/ee/access-control/components/access-control.tsx
@@ -1,18 +1,12 @@
'use client'
-import { useCallback, useMemo, useRef, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { ArrowRight, ChevronDown, Plus } from 'lucide-react'
+import { ArrowRight, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
- Avatar,
- AvatarFallback,
- AvatarImage,
Checkbox,
Chip,
- ChipConfirmModal,
- ChipDropdown,
ChipInput,
ChipModal,
ChipModalBody,
@@ -20,485 +14,42 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
- ChipModalTabs,
- chipVariants,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
+ ChipTag,
Label,
- MoreHorizontal,
Search,
- Skeleton,
- Switch,
- toast,
} from '@/components/emcn'
-import { ArrowLeft } from '@/components/emcn/icons'
-import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
import { getEnv, isTruthy } from '@/lib/core/config/env'
-import { cn } from '@/lib/core/utils/cn'
-import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
-import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
-import { getUserColor } from '@/lib/workspaces/colors'
-import { MemberRow } from '@/app/workspace/[workspaceId]/settings/components/member-list'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
-import { getAllBlocks } from '@/blocks'
+import { GroupDetail } from '@/ee/access-control/components/group-detail'
+import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
import {
- type PermissionGroup,
- useBulkAddPermissionGroupMembers,
useCreatePermissionGroup,
- useDeletePermissionGroup,
useOrganizationWorkspaces,
- usePermissionGroupMembers,
usePermissionGroups,
- useRemovePermissionGroupMember,
- useUpdatePermissionGroup,
useUserPermissionConfig,
} from '@/ee/access-control/hooks/permission-groups'
-import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
-import { useOrganizationRoster } from '@/hooks/queries/organization'
-import { useProviderModels } from '@/hooks/queries/providers'
-import {
- DYNAMIC_MODEL_PROVIDERS,
- getProviderModels,
- PROVIDER_DEFINITIONS,
-} from '@/providers/models'
-import type { ProviderId } from '@/providers/types'
-import { getAllProviderIds, getProviderFromModel } from '@/providers/utils'
-import type { ProviderName } from '@/stores/providers'
const logger = createLogger('AccessControl')
-/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */
-const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
- { value: 'public', label: 'Anyone with link' },
- { value: 'password', label: 'Password' },
- { value: 'email', label: 'Email' },
- { value: 'sso', label: 'SSO' },
-]
-const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value)
-
-interface OrganizationMemberOption {
- userId: string
- user: {
- name: string | null
- email: string
- image?: string | null
- }
-}
-
-interface AddMembersModalProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- availableMembers: OrganizationMemberOption[]
- selectedMemberIds: Set
- setSelectedMemberIds: React.Dispatch>>
- onAddMembers: () => void
- isAdding: boolean
- errorMessage: string | null
-}
-
-function AddMembersModal({
- open,
- onOpenChange,
- availableMembers,
- selectedMemberIds,
- setSelectedMemberIds,
- onAddMembers,
- isAdding,
- errorMessage,
-}: AddMembersModalProps) {
- const [searchTerm, setSearchTerm] = useState('')
-
- const filteredMembers = useMemo(() => {
- if (!searchTerm.trim()) return availableMembers
- const query = searchTerm.toLowerCase()
- return availableMembers.filter((m) => {
- const name = m.user?.name || ''
- const email = m.user?.email || ''
- return name.toLowerCase().includes(query) || email.toLowerCase().includes(query)
- })
- }, [availableMembers, searchTerm])
-
- const allFilteredSelected = useMemo(() => {
- if (filteredMembers.length === 0) return false
- return filteredMembers.every((m) => selectedMemberIds.has(m.userId))
- }, [filteredMembers, selectedMemberIds])
-
- const handleToggleAll = () => {
- if (allFilteredSelected) {
- const filteredIds = new Set(filteredMembers.map((m) => m.userId))
- setSelectedMemberIds((prev) => {
- const next = new Set(prev)
- filteredIds.forEach((id) => next.delete(id))
- return next
- })
- } else {
- setSelectedMemberIds((prev) => {
- const next = new Set(prev)
- filteredMembers.forEach((m) => next.add(m.userId))
- return next
- })
- }
- }
-
- const handleToggleMember = (userId: string) => {
- setSelectedMemberIds((prev) => {
- const next = new Set(prev)
- if (next.has(userId)) {
- next.delete(userId)
- } else {
- next.add(userId)
- }
- return next
- })
- }
-
- return (
- {
- if (!o) setSearchTerm('')
- onOpenChange(o)
- }}
- size='sm'
- srTitle='Add Members'
- >
- onOpenChange(false)}>Add Members
-
- {availableMembers.length === 0 ? (
-
- All organization members are already in this group.
-
- ) : (
-
-
-
- setSearchTerm(e.target.value)}
- className='min-w-0 flex-1'
- />
-
- {allFilteredSelected ? 'Deselect All' : 'Select All'}
-
-
-
-
- {filteredMembers.length === 0 ? (
-
- No members found matching "{searchTerm}"
-
- ) : (
-
- {filteredMembers.map((member) => {
- const name = member.user?.name || 'Unknown'
- const email = member.user?.email || ''
- const avatarInitial = name.charAt(0).toUpperCase()
- const isSelected = selectedMemberIds.has(member.userId)
-
- return (
-
handleToggleMember(member.userId)}
- className='flex items-center gap-2.5 rounded-sm px-2 py-1.5 hover-hover:bg-[var(--surface-active)]'
- >
-
-
- {member.user?.image && (
-
- )}
-
- {avatarInitial}
-
-
-
-
- )
- })}
-
- )}
-
-
-
- )}
- {errorMessage}
-
- {
- setSearchTerm('')
- onOpenChange(false)
- }}
- primaryAction={{
- label: isAdding ? 'Adding...' : 'Add Members',
- onClick: onAddMembers,
- disabled: selectedMemberIds.size === 0 || isAdding,
- }}
- />
-
- )
-}
-
-interface WorkspaceSelectProps {
- workspaceIds: string[]
- onChange: (ids: string[]) => void
- options: { value: string; label: string }[]
- disabled?: boolean
- isLoading?: boolean
- fullWidth?: boolean
- className?: string
- /**
- * When false, the "All workspaces" reset option is hidden and an empty
- * selection reads as a prompt. Non-default groups must target ≥1 workspace.
- */
- allowAllWorkspaces?: boolean
-}
-
-/**
- * Workspace scope multi-select. With `allowAllWorkspaces` an empty selection
- * reads as "All workspaces" (the default group); otherwise it prompts for a
- * selection, since non-default groups must target specific workspaces.
- */
-function WorkspaceSelect({
- workspaceIds,
- onChange,
- options,
- disabled = false,
- isLoading = false,
- fullWidth = false,
- className,
- allowAllWorkspaces = true,
-}: WorkspaceSelectProps) {
- return (
-
- )
-}
-
-interface ModelDenylistControls {
- isModelAllowed: (model: string) => boolean
- onToggleModel: (model: string) => void
- onSetModelsDenied: (models: string[], denied: boolean) => void
-}
-
-interface ModelCheckboxGridProps extends ModelDenylistControls {
- models: string[]
- isLoading: boolean
-}
-
-function ModelCheckboxGrid({
- models,
- isLoading,
- isModelAllowed,
- onToggleModel,
- onSetModelsDenied,
-}: ModelCheckboxGridProps) {
- const [search, setSearch] = useState('')
-
- const sortedModels = useMemo(() => [...models].sort((a, b) => a.localeCompare(b)), [models])
-
- const filteredModels = useMemo(() => {
- if (!search.trim()) return sortedModels
- const query = search.toLowerCase()
- return sortedModels.filter((model) => model.toLowerCase().includes(query))
- }, [sortedModels, search])
-
- if (isLoading) {
- return Loading models…
- }
-
- if (models.length === 0) {
- return (
-
- No models available for this provider.
-
- )
- }
-
- const allFilteredAllowed = filteredModels.every((model) => isModelAllowed(model))
-
- return (
-
-
- setSearch(e.target.value)}
- className='min-w-0 flex-1'
- />
- onSetModelsDenied(filteredModels, allFilteredAllowed)}>
- {allFilteredAllowed ? 'Block All' : 'Allow All'}
-
-
-
- {filteredModels.map((model) => {
- const checkboxId = `model-${model}`
- return (
-
- onToggleModel(model)}
- />
- {model}
-
- )
- })}
-
-
- )
-}
-
-interface DynamicProviderModelsProps extends ModelDenylistControls {
- provider: ProviderName
- workspaceId?: string
-}
-
-function DynamicProviderModels({ provider, workspaceId, ...controls }: DynamicProviderModelsProps) {
- const { data, isPending } = useProviderModels(provider, workspaceId)
- return
-}
-
-interface StaticProviderModelsProps extends ModelDenylistControls {
- providerId: ProviderId
-}
-
-function StaticProviderModels({ providerId, ...controls }: StaticProviderModelsProps) {
- const models = useMemo(() => getProviderModels(providerId), [providerId])
- return
-}
-
-interface ProviderRowProps extends ModelDenylistControls {
- providerId: ProviderId
- isProviderAllowed: boolean
- onToggleProvider: () => void
- deniedCount: number
- workspaceId?: string
-}
-
-function ProviderRow({
- providerId,
- isProviderAllowed,
- onToggleProvider,
- deniedCount,
- workspaceId,
- ...controls
-}: ProviderRowProps) {
- const [expanded, setExpanded] = useState(false)
-
- const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon
- const providerName =
- PROVIDER_DEFINITIONS[providerId]?.name ||
- providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
- const isDynamic = (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId)
- const checkboxId = `provider-${providerId}`
-
- return (
-
-
-
onToggleProvider()}
- />
-
- isProviderAllowed && setExpanded((prev) => !prev)}
- disabled={!isProviderAllowed}
- className={cn(
- 'flex flex-1 items-center gap-2 text-left',
- isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60'
- )}
- >
- {providerName}
- {isProviderAllowed && deniedCount > 0 && (
-
- {deniedCount} blocked
-
- )}
- {isProviderAllowed && (
-
- )}
-
-
- {expanded && isProviderAllowed && (
-
- {isDynamic ? (
-
- ) : (
-
- )}
-
- )}
-
- )
-}
-
export function AccessControl() {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
- // Access control is governed by the workspace's OWNING organization, which may
- // differ from the caller's active org (e.g. external members). Resolve the org
- // id and the caller's admin status server-side from the workspace so gating is
- // never keyed off the session's active org.
+ /**
+ * Access control is governed by the workspace's OWNING organization, which may
+ * differ from the caller's active org (e.g. external members). Resolve the org
+ * id and the caller's admin status server-side from the workspace so gating is
+ * never keyed off the session's active org.
+ */
const { data: userPermissionConfig, isPending: entitlementLoading } =
useUserPermissionConfig(workspaceId)
const organizationId = userPermissionConfig?.organizationId ?? undefined
const currentUserIsOrgAdmin = userPermissionConfig?.isOrgAdmin ?? false
- // Group + roster reads require org admin/owner on the host org; only fetch them
- // for admins to avoid surfacing expected 403s for non-admins/external members.
const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups(
organizationId,
!!organizationId && currentUserIsOrgAdmin
)
- const { data: roster } = useOrganizationRoster(currentUserIsOrgAdmin ? organizationId : undefined)
const { data: organizationWorkspaces = [], isPending: workspacesLoading } =
useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin)
@@ -512,277 +63,15 @@ export function AccessControl() {
(!!organizationId && currentUserIsOrgAdmin && groupsLoading)
const createPermissionGroup = useCreatePermissionGroup()
- const updatePermissionGroup = useUpdatePermissionGroup()
- const deletePermissionGroup = useDeletePermissionGroup()
- const bulkAddMembers = useBulkAddPermissionGroupMembers()
const [searchTerm, setSearchTerm] = useState('')
+ const [selectedGroupId, setSelectedGroupId] = useState(null)
const [showCreateModal, setShowCreateModal] = useState(false)
- const [viewingGroup, setViewingGroup] = useState(null)
- // Monotonic token for scope-affecting writes (workspace select + default
- // toggle, which both change the group's workspace scope). Only the most
- // recent write may reconcile or revert the local viewingGroup, so rapid
- // multi-select toggles can't settle on a stale, out-of-order response.
- const scopeWriteSeqRef = useRef(0)
const [newGroupName, setNewGroupName] = useState('')
const [newGroupDescription, setNewGroupDescription] = useState('')
const [newGroupIsDefault, setNewGroupIsDefault] = useState(false)
const [newGroupWorkspaceIds, setNewGroupWorkspaceIds] = useState([])
const [createError, setCreateError] = useState(null)
- const [deletingGroup, setDeletingGroup] = useState<{ id: string; name: string } | null>(null)
- const [deletingGroupIds, setDeletingGroupIds] = useState>(() => new Set())
-
- const { data: members = [], isPending: membersLoading } = usePermissionGroupMembers(
- organizationId,
- viewingGroup?.id
- )
- const removeMember = useRemovePermissionGroupMember()
-
- const [showConfigModal, setShowConfigModal] = useState(false)
- const [configTab, setConfigTab] = useState<'members' | 'providers' | 'blocks' | 'platform'>(
- 'providers'
- )
- const [editingConfig, setEditingConfig] = useState(null)
- const [showAddMembersModal, setShowAddMembersModal] = useState(false)
- const [addMembersError, setAddMembersError] = useState(null)
- const [selectedMemberIds, setSelectedMemberIds] = useState>(() => new Set())
- const [providerSearchTerm, setProviderSearchTerm] = useState('')
- const [integrationSearchTerm, setIntegrationSearchTerm] = useState('')
- const [platformSearchTerm, setPlatformSearchTerm] = useState('')
- const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
-
- const platformFeatures = useMemo(
- () => [
- {
- id: 'hide-knowledge-base',
- label: 'Knowledge Base',
- category: 'Sidebar',
- configKey: 'hideKnowledgeBaseTab' as const,
- },
- {
- id: 'hide-tables',
- label: 'Tables',
- category: 'Sidebar',
- configKey: 'hideTablesTab' as const,
- },
- {
- id: 'hide-copilot',
- label: 'Chat',
- category: 'Workflow Panel',
- configKey: 'hideCopilot' as const,
- },
- {
- id: 'hide-integrations',
- label: 'Integrations',
- category: 'Settings Tabs',
- configKey: 'hideIntegrationsTab' as const,
- },
- {
- id: 'hide-secrets',
- label: 'Secrets',
- category: 'Settings Tabs',
- configKey: 'hideSecretsTab' as const,
- },
- {
- id: 'hide-api-keys',
- label: 'API Keys',
- category: 'Settings Tabs',
- configKey: 'hideApiKeysTab' as const,
- },
- {
- id: 'hide-files',
- label: 'Files',
- category: 'Settings Tabs',
- configKey: 'hideFilesTab' as const,
- },
- {
- id: 'hide-deploy-api',
- label: 'API',
- category: 'Deploy Tabs',
- configKey: 'hideDeployApi' as const,
- },
- {
- id: 'hide-deploy-mcp',
- label: 'MCP',
- category: 'Deploy Tabs',
- configKey: 'hideDeployMcp' as const,
- },
- {
- id: 'hide-deploy-a2a',
- label: 'A2A',
- category: 'Deploy Tabs',
- configKey: 'hideDeployA2a' as const,
- },
- {
- id: 'hide-deploy-chatbot',
- label: 'Chat',
- category: 'Deploy Tabs',
- configKey: 'hideDeployChatbot' as const,
- },
- {
- id: 'hide-deploy-template',
- label: 'Template',
- category: 'Deploy Tabs',
- configKey: 'hideDeployTemplate' as const,
- },
- {
- id: 'disable-mcp',
- label: 'MCP Tools',
- category: 'Tools',
- configKey: 'disableMcpTools' as const,
- },
- {
- id: 'disable-custom-tools',
- label: 'Custom Tools',
- category: 'Tools',
- configKey: 'disableCustomTools' as const,
- },
- {
- id: 'disable-skills',
- label: 'Skills',
- category: 'Tools',
- configKey: 'disableSkills' as const,
- },
- {
- id: 'hide-trace-spans',
- label: 'Trace Spans',
- category: 'Logs',
- configKey: 'hideTraceSpans' as const,
- },
- {
- id: 'disable-invitations',
- label: 'Invitations',
- category: 'Collaboration',
- configKey: 'disableInvitations' as const,
- },
- {
- id: 'hide-inbox',
- label: 'Sim Mailer',
- category: 'Features',
- configKey: 'hideInboxTab' as const,
- },
- {
- id: 'disable-public-api',
- label: 'Public API',
- category: 'Features',
- configKey: 'disablePublicApi' as const,
- },
- {
- id: 'disable-public-file-sharing',
- label: 'Public Sharing',
- category: 'Files',
- configKey: 'disablePublicFileSharing' as const,
- },
- ],
- []
- )
-
- const filteredPlatformFeatures = useMemo(() => {
- if (!platformSearchTerm.trim()) return platformFeatures
- const search = platformSearchTerm.toLowerCase()
- return platformFeatures.filter(
- (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search)
- )
- }, [platformFeatures, platformSearchTerm])
-
- const platformCategories = useMemo(() => {
- const categories: Record = {}
- for (const feature of filteredPlatformFeatures) {
- if (!categories[feature.category]) {
- categories[feature.category] = []
- }
- categories[feature.category].push(feature)
- }
- return categories
- }, [filteredPlatformFeatures])
-
- const platformCategoryColumns = useMemo(() => {
- const categoryGroups = [
- ['Sidebar', 'Deploy Tabs', 'Collaboration'],
- ['Workflow Panel', 'Tools', 'Features'],
- ['Settings Tabs', 'Logs'],
- ]
-
- const assignedCategories = new Set(categoryGroups.flat())
- // Files has its own section below (with the file-sharing auth modes), so it
- // stays out of the feature-toggle grid.
- const unassigned = Object.keys(platformCategories).filter(
- (c) => c !== 'Files' && !assignedCategories.has(c)
- )
- const groups = unassigned.length > 0 ? [...categoryGroups, unassigned] : categoryGroups
-
- return groups
- .map((column) =>
- column
- .map((category) => ({
- category,
- features: platformCategories[category] ?? [],
- }))
- .filter((section) => section.features.length > 0)
- )
- .filter((column) => column.length > 0)
- }, [platformCategories])
-
- const hasConfigChanges = useMemo(() => {
- if (!viewingGroup || !editingConfig) return false
- const original = viewingGroup.config
- return JSON.stringify(original) !== JSON.stringify(editingConfig)
- }, [viewingGroup, editingConfig])
-
- const allBlocks = useMemo(() => {
- const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type))
- return blocks.sort((a, b) => {
- const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
- const catA = categoryOrder[a.category] ?? 3
- const catB = categoryOrder[b.category] ?? 3
- if (catA !== catB) return catA - catB
- return a.name.localeCompare(b.name)
- })
- }, [])
- const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: showConfigModal })
-
- const allProviderIds = useMemo(() => {
- const allIds = getAllProviderIds()
- const blacklist = blacklistedProvidersData?.blacklistedProviders ?? []
- if (blacklist.length === 0) return allIds
- return allIds.filter((id) => !blacklist.includes(id.toLowerCase()))
- }, [blacklistedProvidersData])
-
- const filteredProviders = useMemo(() => {
- if (!providerSearchTerm.trim()) return allProviderIds
- const query = providerSearchTerm.toLowerCase()
- return allProviderIds.filter((id) => id.toLowerCase().includes(query))
- }, [allProviderIds, providerSearchTerm])
-
- const filteredBlocks = useMemo(() => {
- if (!integrationSearchTerm.trim()) return allBlocks
- const query = integrationSearchTerm.toLowerCase()
- return allBlocks.filter((b) => b.name.toLowerCase().includes(query))
- }, [allBlocks, integrationSearchTerm])
-
- const filteredCoreBlocks = useMemo(() => {
- return filteredBlocks.filter((block) => block.category === 'blocks')
- }, [filteredBlocks])
-
- const filteredToolBlocks = useMemo(() => {
- return filteredBlocks
- .filter((block) => block.category === 'tools' || block.category === 'triggers')
- .sort((a, b) => a.name.localeCompare(b.name))
- }, [filteredBlocks])
-
- const organizationMembers = useMemo(() => {
- if (!roster?.members) return []
- return roster.members
- .filter((m) => m.role !== 'external')
- .map((m) => ({
- userId: m.userId,
- user: {
- name: m.name,
- email: m.email,
- image: m.image,
- },
- }))
- }, [roster])
const workspaceOptions = useMemo(
() => organizationWorkspaces.map((ws) => ({ value: ws.id, label: ws.name })),
@@ -795,6 +84,11 @@ export function AccessControl() {
return permissionGroups.filter((g) => g.name.toLowerCase().includes(searchLower))
}, [permissionGroups, searchTerm])
+ const selectedGroup = useMemo(
+ () => (selectedGroupId ? permissionGroups.find((g) => g.id === selectedGroupId) : undefined),
+ [permissionGroups, selectedGroupId]
+ )
+
const handleCreatePermissionGroup = useCallback(async () => {
if (!newGroupName.trim() || !organizationId) return
setCreateError(null)
@@ -804,8 +98,6 @@ export function AccessControl() {
name: newGroupName.trim(),
description: newGroupDescription.trim() || undefined,
isDefault: newGroupIsDefault,
- // Only the default group is organization-wide; every other group targets
- // specific workspaces (omitted for the default group).
workspaceIds: newGroupIsDefault ? undefined : newGroupWorkspaceIds,
})
setShowCreateModal(false)
@@ -815,11 +107,7 @@ export function AccessControl() {
setNewGroupWorkspaceIds([])
} catch (error) {
logger.error('Failed to create permission group', error)
- if (error instanceof Error) {
- setCreateError(error.message)
- } else {
- setCreateError('Failed to create permission group')
- }
+ setCreateError(error instanceof Error ? error.message : 'Failed to create permission group')
}
}, [
newGroupName,
@@ -839,385 +127,6 @@ export function AccessControl() {
setCreateError(null)
}, [])
- const handleBackToList = useCallback(() => {
- setViewingGroup(null)
- }, [])
-
- const handleDeleteClick = useCallback((group: PermissionGroup) => {
- setDeletingGroup({ id: group.id, name: group.name })
- }, [])
-
- const confirmDelete = useCallback(async () => {
- if (!deletingGroup || !organizationId) return
- setDeletingGroupIds((prev) => new Set(prev).add(deletingGroup.id))
- try {
- await deletePermissionGroup.mutateAsync({
- permissionGroupId: deletingGroup.id,
- organizationId,
- })
- setDeletingGroup(null)
- if (viewingGroup?.id === deletingGroup.id) {
- setViewingGroup(null)
- }
- } catch (error) {
- logger.error('Failed to delete permission group', error)
- } finally {
- setDeletingGroupIds((prev) => {
- const next = new Set(prev)
- next.delete(deletingGroup.id)
- return next
- })
- }
- }, [deletingGroup, organizationId, deletePermissionGroup, viewingGroup?.id])
-
- const handleRemoveMember = useCallback(
- async (memberId: string) => {
- if (!viewingGroup || !organizationId) return
- try {
- await removeMember.mutateAsync({
- organizationId,
- permissionGroupId: viewingGroup.id,
- memberId,
- })
- } catch (error) {
- logger.error('Failed to remove member', error)
- toast.error("Couldn't remove member", {
- description: getErrorMessage(error, 'Please try again in a moment.'),
- })
- }
- },
- [viewingGroup, organizationId, removeMember]
- )
-
- const handleOpenConfigModal = useCallback(() => {
- if (!viewingGroup) return
- setEditingConfig({ ...viewingGroup.config })
- setConfigTab('providers')
- setShowConfigModal(true)
- }, [viewingGroup])
-
- const handleSaveConfig = useCallback(async () => {
- if (!viewingGroup || !editingConfig || !organizationId) return
- try {
- await updatePermissionGroup.mutateAsync({
- id: viewingGroup.id,
- organizationId,
- config: editingConfig,
- })
- setShowConfigModal(false)
- setEditingConfig(null)
- setProviderSearchTerm('')
- setIntegrationSearchTerm('')
- setPlatformSearchTerm('')
- setViewingGroup((prev) => (prev ? { ...prev, config: editingConfig } : null))
- } catch (error) {
- logger.error('Failed to update config', error)
- }
- }, [viewingGroup, editingConfig, organizationId, updatePermissionGroup])
-
- const handleCloseConfigModal = useCallback(() => {
- if (hasConfigChanges) {
- setShowUnsavedChanges(true)
- } else {
- setShowConfigModal(false)
- setProviderSearchTerm('')
- setIntegrationSearchTerm('')
- setPlatformSearchTerm('')
- }
- }, [hasConfigChanges])
-
- const handleDiscardConfig = useCallback(() => {
- setShowUnsavedChanges(false)
- setShowConfigModal(false)
- setEditingConfig(null)
- setProviderSearchTerm('')
- setIntegrationSearchTerm('')
- setPlatformSearchTerm('')
- }, [])
-
- const handleSaveConfigFromUnsaved = useCallback(() => {
- setShowUnsavedChanges(false)
- handleSaveConfig()
- }, [handleSaveConfig])
-
- const handleOpenAddMembersModal = useCallback(() => {
- setSelectedMemberIds(new Set())
- setAddMembersError(null)
- setShowAddMembersModal(true)
- }, [])
-
- const handleAddSelectedMembers = useCallback(async () => {
- if (!viewingGroup || !organizationId || selectedMemberIds.size === 0) return
- setAddMembersError(null)
- try {
- // Bulk add is all-or-nothing for conflicts: a conflicting selection
- // returns a 409 (no one is added) and the named error is shown inline so
- // the admin can adjust the selection.
- await bulkAddMembers.mutateAsync({
- organizationId,
- permissionGroupId: viewingGroup.id,
- userIds: Array.from(selectedMemberIds),
- })
- setShowAddMembersModal(false)
- setSelectedMemberIds(new Set())
- } catch (error) {
- logger.error('Failed to add members', error)
- setAddMembersError(getErrorMessage(error, 'Failed to add members'))
- }
- }, [viewingGroup, organizationId, selectedMemberIds, bulkAddMembers])
-
- const handleScopeChange = useCallback(
- async (workspaceIds: string[]) => {
- if (!viewingGroup || !organizationId) return
- // Zero workspaces is allowed: the group then governs nothing (the resolver
- // inner-joins on the workspace link table, so an empty group never matches
- // any workspace). Re-add a workspace to make it active again.
- const previous = viewingGroup
- const seq = ++scopeWriteSeqRef.current
-
- setViewingGroup((prev) =>
- prev
- ? {
- ...prev,
- workspaces: organizationWorkspaces.filter((ws) => workspaceIds.includes(ws.id)),
- }
- : null
- )
- try {
- const result = await updatePermissionGroup.mutateAsync({
- id: viewingGroup.id,
- organizationId,
- workspaceIds,
- })
-
- if (seq !== scopeWriteSeqRef.current) return
- setViewingGroup((prev) =>
- prev
- ? {
- ...prev,
- workspaces: organizationWorkspaces.filter((ws) =>
- result.permissionGroup.workspaceIds.includes(ws.id)
- ),
- }
- : null
- )
- } catch (error) {
- logger.error('Failed to update workspace scope', error)
- // Only the latest write may revert, so a failed earlier request can't
- // clobber a newer (successful) selection.
- if (seq !== scopeWriteSeqRef.current) return
- setViewingGroup(previous)
- toast.error("Couldn't update workspaces", {
- description: getErrorMessage(error, 'Please try again in a moment.'),
- })
- }
- },
- [viewingGroup, organizationId, organizationWorkspaces, updatePermissionGroup]
- )
-
- const handleToggleDefault = useCallback(
- async (enabled: boolean) => {
- if (!viewingGroup || !organizationId) return
- const seq = ++scopeWriteSeqRef.current
- try {
- // Promoting forces all-workspaces; demoting leaves the group non-default
- // with no workspaces (inert) until it is re-scoped from the selector — the
- // route handles this from `isDefault: false` alone, so no workspace list
- // (bounded by the per-group cap) is sent.
- const result = await updatePermissionGroup.mutateAsync({
- id: viewingGroup.id,
- organizationId,
- isDefault: enabled,
- })
-
- if (seq !== scopeWriteSeqRef.current) return
- setViewingGroup((prev) =>
- prev
- ? {
- ...prev,
- isDefault: result.permissionGroup.isDefault,
- workspaces: result.permissionGroup.isDefault
- ? []
- : organizationWorkspaces.filter((ws) =>
- result.permissionGroup.workspaceIds.includes(ws.id)
- ),
- }
- : null
- )
- } catch (error) {
- logger.error('Failed to toggle default group', error)
- toast.error("Couldn't update the default group", {
- description: getErrorMessage(error, 'Please try again in a moment.'),
- })
- }
- },
- [viewingGroup, organizationId, organizationWorkspaces, updatePermissionGroup]
- )
-
- const toggleIntegration = useCallback(
- (blockType: string) => {
- if (!editingConfig) return
- const current = editingConfig.allowedIntegrations
- if (current === null) {
- const allExcept = allBlocks.map((b) => b.type).filter((t) => t !== blockType)
- setEditingConfig({ ...editingConfig, allowedIntegrations: allExcept })
- } else if (current.includes(blockType)) {
- const updated = current.filter((t) => t !== blockType)
- setEditingConfig({
- ...editingConfig,
- allowedIntegrations: updated.length === allBlocks.length ? null : updated,
- })
- } else {
- const updated = [...current, blockType]
- setEditingConfig({
- ...editingConfig,
- allowedIntegrations: updated.length === allBlocks.length ? null : updated,
- })
- }
- },
- [editingConfig, allBlocks]
- )
-
- const toggleProvider = useCallback(
- (providerId: string) => {
- if (!editingConfig) return
- const current = editingConfig.allowedModelProviders
- if (current === null) {
- const allExcept = allProviderIds.filter((p) => p !== providerId)
- setEditingConfig({ ...editingConfig, allowedModelProviders: allExcept })
- } else if (current.includes(providerId)) {
- const updated = current.filter((p) => p !== providerId)
- setEditingConfig({
- ...editingConfig,
- allowedModelProviders: updated.length === allProviderIds.length ? null : updated,
- })
- } else {
- const updated = [...current, providerId]
- setEditingConfig({
- ...editingConfig,
- allowedModelProviders: updated.length === allProviderIds.length ? null : updated,
- })
- }
- },
- [editingConfig, allProviderIds]
- )
-
- const isFileShareAuthAllowed = useCallback(
- (authType: ShareAuthType) => {
- if (!editingConfig) return true
- return (
- editingConfig.allowedFileShareAuthTypes === null ||
- editingConfig.allowedFileShareAuthTypes.includes(authType)
- )
- },
- [editingConfig]
- )
-
- const toggleFileShareAuthType = useCallback(
- (authType: ShareAuthType) => {
- if (!editingConfig) return
- const current = editingConfig.allowedFileShareAuthTypes
- const next =
- current === null
- ? ALL_FILE_SHARE_AUTH_TYPES.filter((t) => t !== authType)
- : current.includes(authType)
- ? current.filter((t) => t !== authType)
- : [...current, authType]
- // A full list collapses back to `null` ("all allowed").
- setEditingConfig({
- ...editingConfig,
- allowedFileShareAuthTypes: next.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : next,
- })
- },
- [editingConfig]
- )
-
- const isIntegrationAllowed = useCallback(
- (blockType: string) => {
- if (!editingConfig) return true
- return (
- editingConfig.allowedIntegrations === null ||
- editingConfig.allowedIntegrations.includes(blockType)
- )
- },
- [editingConfig]
- )
-
- const isProviderAllowed = useCallback(
- (providerId: string) => {
- if (!editingConfig) return true
- return (
- editingConfig.allowedModelProviders === null ||
- editingConfig.allowedModelProviders.includes(providerId)
- )
- },
- [editingConfig]
- )
-
- const isModelAllowed = useCallback(
- (model: string) => {
- if (!editingConfig) return true
- const normalized = model.toLowerCase()
- return !editingConfig.deniedModels.some((denied) => denied.toLowerCase() === normalized)
- },
- [editingConfig]
- )
-
- const toggleModel = useCallback(
- (model: string) => {
- if (!editingConfig) return
- const normalized = model.toLowerCase()
- const isDenied = editingConfig.deniedModels.some(
- (denied) => denied.toLowerCase() === normalized
- )
- const deniedModels = isDenied
- ? editingConfig.deniedModels.filter((denied) => denied.toLowerCase() !== normalized)
- : [...editingConfig.deniedModels, model]
- setEditingConfig({ ...editingConfig, deniedModels })
- },
- [editingConfig]
- )
-
- const setModelsDenied = useCallback(
- (models: string[], denied: boolean) => {
- if (!editingConfig) return
- if (denied) {
- const existing = new Set(editingConfig.deniedModels.map((m) => m.toLowerCase()))
- const additions = models.filter((m) => !existing.has(m.toLowerCase()))
- if (additions.length === 0) return
- setEditingConfig({
- ...editingConfig,
- deniedModels: [...editingConfig.deniedModels, ...additions],
- })
- } else {
- const toRemove = new Set(models.map((m) => m.toLowerCase()))
- setEditingConfig({
- ...editingConfig,
- deniedModels: editingConfig.deniedModels.filter((m) => !toRemove.has(m.toLowerCase())),
- })
- }
- },
- [editingConfig]
- )
-
- const deniedCountByProvider = useMemo(() => {
- const counts: Record = {}
- for (const model of editingConfig?.deniedModels ?? []) {
- try {
- const providerId = getProviderFromModel(model)
- counts[providerId] = (counts[providerId] ?? 0) + 1
- } catch {
- // Unknown/blacklisted provider — omit from counts.
- }
- }
- return counts
- }, [editingConfig?.deniedModels])
-
- const availableMembersToAdd = useMemo(() => {
- const existingMemberUserIds = new Set(members.map((m) => m.userId))
- return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId))
- }, [organizationMembers, members])
-
if (isLoading) {
return null
}
@@ -1232,566 +141,18 @@ export function AccessControl() {
)
}
- const deleteConfirmModal = (
- setDeletingGroup(null)}
- srTitle='Delete Permission Group'
- title='Delete Permission Group'
- text={[
- 'Are you sure you want to delete ',
- { text: deletingGroup?.name ?? 'this group', bold: true },
- '? ',
- { text: 'All members will be removed from this group.', error: true },
- ' This action cannot be undone.',
- ]}
- confirm={{
- label: 'Delete',
- onClick: confirmDelete,
- pending: deletePermissionGroup.isPending,
- pendingLabel: 'Deleting...',
- }}
- />
- )
-
- if (viewingGroup) {
+ if (selectedGroup && organizationId) {
return (
- <>
-
-
-
- Access Control
-
-
- handleDeleteClick(viewingGroup)}
- disabled={deletingGroupIds.has(viewingGroup.id)}
- >
- {deletingGroupIds.has(viewingGroup.id) ? 'Deleting...' : 'Delete'}
-
- Configure
-
-
-
-
-
-
-
{viewingGroup.name}
- {viewingGroup.description && (
-
{viewingGroup.description}
- )}
- {!viewingGroup.isDefault && !membersLoading && (
-
- {viewingGroup.workspaces.length === 0
- ? 'Applies to no one yet — add workspaces below to choose who this group governs.'
- : members.length === 0
- ? 'Applies to all members of its workspaces.'
- : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}.`}
-
- )}
-
-
-
-
-
- Applies to everyone in the organization not assigned to another group, including
- external workspace members
-
- handleToggleDefault(checked)}
- disabled={updatePermissionGroup.isPending}
- />
-
-
-
-
- {viewingGroup.isDefault ? (
-
-
- Governs every workspace in the organization
-
-
- ) : (
-
-
-
- {viewingGroup.workspaces.length > 0
- ? `Governs ${viewingGroup.workspaces.length} workspace${
- viewingGroup.workspaces.length === 1 ? '' : 's'
- }`
- : 'Select the workspaces this group governs'}
-
- ws.id)}
- onChange={handleScopeChange}
- options={workspaceOptions}
- isLoading={workspacesLoading}
- allowAllWorkspaces={false}
- className='flex-shrink-0'
- />
-
- {viewingGroup.workspaces.length > 0 && (
-
- {viewingGroup.workspaces.map((ws) => (
-
- ))}
-
- )}
-
- )}
-
-
-
-
-
- {
- if (!open && hasConfigChanges) {
- setShowUnsavedChanges(true)
- } else {
- setShowConfigModal(open)
- if (!open) {
- setProviderSearchTerm('')
- setIntegrationSearchTerm('')
- setPlatformSearchTerm('')
- }
- }
- }}
- srTitle='Configure Permissions'
- size='xl'
- className='h-[84vh]'
- >
- {
- if (hasConfigChanges) {
- setShowUnsavedChanges(true)
- } else {
- setShowConfigModal(false)
- setProviderSearchTerm('')
- setIntegrationSearchTerm('')
- setPlatformSearchTerm('')
- }
- }}
- >
- Configure Permissions
-
-
-
- setConfigTab(value as 'members' | 'providers' | 'blocks' | 'platform')
- }
- />
- {configTab === 'members' && !viewingGroup.isDefault && (
-
-
-
- {members.length === 0
- ? 'Applies to all members'
- : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`}
-
-
- Add
-
-
- {membersLoading ? (
-
- {[1, 2].map((i) => (
-
-
-
-
- ))}
-
- ) : members.length === 0 ? (
-
-
- This group applies to everyone in its workspaces, including external members.
- Add members to restrict it to specific people.
-
-
- ) : (
-
- {members.map((member) => (
-
-
-
-
-
-
-
- handleRemoveMember(member.id)}
- >
- Remove
-
-
-
- }
- />
- ))}
-
- )}
-
- )}
- {configTab === 'providers' && (
-
-
- setProviderSearchTerm(e.target.value)}
- className='min-w-0 flex-1'
- />
- {
- const allAllowed =
- editingConfig?.allowedModelProviders === null ||
- allProviderIds.every((id) =>
- editingConfig?.allowedModelProviders?.includes(id)
- )
- setEditingConfig((prev) =>
- prev ? { ...prev, allowedModelProviders: allAllowed ? [] : null } : prev
- )
- }}
- >
- {editingConfig?.allowedModelProviders === null ||
- allProviderIds.every((id) => editingConfig?.allowedModelProviders?.includes(id))
- ? 'Deselect All'
- : 'Select All'}
-
-
-
- {filteredProviders.map((providerId) => (
-
toggleProvider(providerId)}
- deniedCount={deniedCountByProvider[providerId] ?? 0}
- workspaceId={workspaceId}
- isModelAllowed={isModelAllowed}
- onToggleModel={toggleModel}
- onSetModelsDenied={setModelsDenied}
- />
- ))}
-
-
- )}
- {configTab === 'blocks' && (
-
-
- setIntegrationSearchTerm(e.target.value)}
- className='min-w-0 flex-1'
- />
- {
- const allAllowed =
- editingConfig?.allowedIntegrations === null ||
- allBlocks.every((b) => editingConfig?.allowedIntegrations?.includes(b.type))
- setEditingConfig((prev) =>
- prev
- ? {
- ...prev,
- allowedIntegrations: allAllowed ? ['start_trigger'] : null,
- }
- : prev
- )
- }}
- >
- {editingConfig?.allowedIntegrations === null ||
- allBlocks.every((b) => editingConfig?.allowedIntegrations?.includes(b.type))
- ? 'Deselect All'
- : 'Select All'}
-
-
-
- {filteredCoreBlocks.length > 0 && (
-
-
- Core Blocks
-
-
- {filteredCoreBlocks.map((block) => {
- const BlockIcon = block.icon
- const checkboxId = `block-${block.type}`
- return (
-
- toggleIntegration(block.type)}
- />
-
- {BlockIcon && (
-
- )}
-
- {block.name}
-
- )
- })}
-
-
- )}
- {filteredToolBlocks.length > 0 && (
-
-
- Integrations and Triggers
-
-
- {filteredToolBlocks.map((block) => {
- const BlockIcon = block.icon
- const checkboxId = `block-${block.type}`
- return (
-
- toggleIntegration(block.type)}
- />
-
- {BlockIcon && (
-
- )}
-
- {block.name}
-
- )
- })}
-
-
- )}
-
-
- )}
- {configTab === 'platform' && (
-
-
- setPlatformSearchTerm(e.target.value)}
- className='min-w-0 flex-1'
- />
- {
- const allVisible = platformFeatures.every(
- (f) => !editingConfig?.[f.configKey]
- )
- setEditingConfig((prev) =>
- prev
- ? {
- ...prev,
- ...Object.fromEntries(
- platformFeatures.map((f) => [f.configKey, allVisible])
- ),
- }
- : prev
- )
- }}
- >
- {platformFeatures.every((f) => !editingConfig?.[f.configKey])
- ? 'Deselect All'
- : 'Select All'}
-
-
-
- {platformCategoryColumns.map((column, columnIndex) => (
-
- {column.map(({ category, features }) => (
-
-
- {category}
-
-
- {features.map((feature) => (
-
-
- setEditingConfig((prev) =>
- prev
- ? { ...prev, [feature.configKey]: checked !== true }
- : prev
- )
- }
- />
- {feature.label}
-
- ))}
-
-
- ))}
-
- ))}
-
-
-
- Files
-
-
-
- setEditingConfig((prev) =>
- prev ? { ...prev, disablePublicFileSharing: checked !== true } : prev
- )
- }
- />
- Public Sharing
-
-
-
- Auth modes public file-share links may use
-
-
- {FILE_SHARE_AUTH_TYPE_OPTIONS.map(({ value, label }) => (
-
- toggleFileShareAuthType(value)}
- disabled={editingConfig?.disablePublicFileSharing}
- />
- {label}
-
- ))}
-
-
-
-
- )}
-
- {configTab !== 'members' && (
-
- )}
-
-
-
- setShowUnsavedChanges(false)}>
- Unsaved Changes
-
-
-
- You have unsaved changes. Do you want to save them before closing?
-
-
- setShowUnsavedChanges(false)}
- secondaryActions={[
- {
- label: 'Discard Changes',
- onClick: handleDiscardConfig,
- variant: 'destructive',
- },
- ]}
- primaryAction={{
- label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes',
- onClick: handleSaveConfigFromUnsaved,
- disabled: updatePermissionGroup.isPending,
- }}
- />
-
-
- {
- setShowAddMembersModal(open)
- if (!open) setAddMembersError(null)
- }}
- availableMembers={availableMembersToAdd}
- selectedMemberIds={selectedMemberIds}
- setSelectedMemberIds={setSelectedMemberIds}
- onAddMembers={handleAddSelectedMembers}
- isAdding={bulkAddMembers.isPending}
- errorMessage={addMembersError}
- />
-
- {deleteConfirmModal}
- >
+ setSelectedGroupId(null)}
+ onDeleted={() => setSelectedGroupId(null)}
+ />
)
}
@@ -1841,7 +202,7 @@ export function AccessControl() {
setViewingGroup(group)}
+ onClick={() => setSelectedGroupId(group.id)}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
@@ -1850,9 +211,9 @@ export function AccessControl() {
{group.name}
{group.isDefault && (
-
+
Default
-
+
)}
@@ -1861,9 +222,7 @@ export function AccessControl() {
: `${
group.memberCount === 0
? 'All members'
- : `${group.memberCount} member${
- group.memberCount === 1 ? '' : 's'
- }`
+ : `${group.memberCount} member${group.memberCount === 1 ? '' : 's'}`
} · ${group.workspaces.length} workspace${
group.workspaces.length === 1 ? '' : 's'
}`}
@@ -1934,7 +293,7 @@ export function AccessControl() {
{!newGroupIsDefault && (
Applies to all members of the selected workspaces. Restrict to specific people
- later from Configure → Members.
+ later from the group's Members section.
)}
@@ -1953,8 +312,6 @@ export function AccessControl() {
}}
/>
-
- {deleteConfirmModal}
>
)
}
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
new file mode 100644
index 00000000000..5477c7a0fa9
--- /dev/null
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -0,0 +1,1784 @@
+'use client'
+
+import { useCallback, useMemo, useRef, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { ChevronDown, Plus } from 'lucide-react'
+import {
+ Checkbox,
+ Chip,
+ ChipConfirmModal,
+ ChipInput,
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ ChipModalTabs,
+ ChipTag,
+ chipVariants,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Info,
+ MoreHorizontal,
+ Search,
+ Skeleton,
+ Switch,
+ toast,
+} from '@/components/emcn'
+import { ArrowLeft } from '@/components/emcn/icons'
+import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
+import { cn } from '@/lib/core/utils/cn'
+import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
+import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
+import {
+ MemberAvatar,
+ MemberRow,
+} from '@/app/workspace/[workspaceId]/settings/components/member-list'
+import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
+import { getAllBlocks } from '@/blocks'
+import type { BlockConfig } from '@/blocks/types'
+import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
+import {
+ type PermissionGroup,
+ type PermissionGroupWorkspaceRef,
+ useBulkAddPermissionGroupMembers,
+ useDeletePermissionGroup,
+ usePermissionGroupMembers,
+ useRemovePermissionGroupMember,
+ useUpdatePermissionGroup,
+} from '@/ee/access-control/hooks/permission-groups'
+import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
+import { useOrganizationRoster } from '@/hooks/queries/organization'
+import { useProviderModels } from '@/hooks/queries/providers'
+import {
+ DYNAMIC_MODEL_PROVIDERS,
+ getProviderModels,
+ PROVIDER_DEFINITIONS,
+} from '@/providers/models'
+import type { ProviderId } from '@/providers/types'
+import { getAllProviderIds, getProviderFromModel } from '@/providers/utils'
+import type { ProviderName } from '@/stores/providers'
+import { getTool } from '@/tools/utils'
+
+const logger = createLogger('AccessControlGroupDetail')
+
+type ConfigTab = 'general' | 'providers' | 'blocks' | 'platform'
+
+/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */
+const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
+ { value: 'public', label: 'Anyone with link' },
+ { value: 'password', label: 'Password' },
+ { value: 'email', label: 'Email' },
+ { value: 'sso', label: 'SSO' },
+]
+const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value)
+
+interface OrganizationMemberOption {
+ userId: string
+ user: {
+ name: string | null
+ email: string
+ image?: string | null
+ }
+}
+
+interface AddMembersModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ availableMembers: OrganizationMemberOption[]
+ selectedMemberIds: Set
+ setSelectedMemberIds: React.Dispatch>>
+ onAddMembers: () => void
+ isAdding: boolean
+ errorMessage: string | null
+}
+
+function AddMembersModal({
+ open,
+ onOpenChange,
+ availableMembers,
+ selectedMemberIds,
+ setSelectedMemberIds,
+ onAddMembers,
+ isAdding,
+ errorMessage,
+}: AddMembersModalProps) {
+ const [searchTerm, setSearchTerm] = useState('')
+
+ const filteredMembers = useMemo(() => {
+ if (!searchTerm.trim()) return availableMembers
+ const query = searchTerm.toLowerCase()
+ return availableMembers.filter((m) => {
+ const name = m.user?.name || ''
+ const email = m.user?.email || ''
+ return name.toLowerCase().includes(query) || email.toLowerCase().includes(query)
+ })
+ }, [availableMembers, searchTerm])
+
+ const allFilteredSelected = useMemo(() => {
+ if (filteredMembers.length === 0) return false
+ return filteredMembers.every((m) => selectedMemberIds.has(m.userId))
+ }, [filteredMembers, selectedMemberIds])
+
+ const handleToggleAll = () => {
+ if (allFilteredSelected) {
+ const filteredIds = new Set(filteredMembers.map((m) => m.userId))
+ setSelectedMemberIds((prev) => {
+ const next = new Set(prev)
+ filteredIds.forEach((id) => next.delete(id))
+ return next
+ })
+ } else {
+ setSelectedMemberIds((prev) => {
+ const next = new Set(prev)
+ filteredMembers.forEach((m) => next.add(m.userId))
+ return next
+ })
+ }
+ }
+
+ const handleToggleMember = (userId: string) => {
+ setSelectedMemberIds((prev) => {
+ const next = new Set(prev)
+ if (next.has(userId)) {
+ next.delete(userId)
+ } else {
+ next.add(userId)
+ }
+ return next
+ })
+ }
+
+ return (
+ {
+ if (!o) setSearchTerm('')
+ onOpenChange(o)
+ }}
+ size='sm'
+ srTitle='Add Members'
+ >
+ onOpenChange(false)}>Add Members
+
+ {availableMembers.length === 0 ? (
+
+ All organization members are already in this group.
+
+ ) : (
+
+
+
+ setSearchTerm(e.target.value)}
+ className='min-w-0 flex-1'
+ />
+
+ {allFilteredSelected ? 'Deselect All' : 'Select All'}
+
+
+
+
+ {filteredMembers.length === 0 ? (
+
+ No members found matching "{searchTerm}"
+
+ ) : (
+
+ {filteredMembers.map((member) => {
+ const name = member.user?.name || 'Unknown'
+ const email = member.user?.email || ''
+ const isSelected = selectedMemberIds.has(member.userId)
+
+ return (
+
handleToggleMember(member.userId)}
+ className='flex items-center gap-2.5 rounded-sm p-2 text-left hover-hover:bg-[var(--surface-active)]'
+ >
+
+
+
+
+ {name}
+
+
+ {email}
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ )}
+ {errorMessage}
+
+ {
+ setSearchTerm('')
+ onOpenChange(false)
+ }}
+ primaryAction={{
+ label: isAdding ? 'Adding...' : 'Add Members',
+ onClick: onAddMembers,
+ disabled: selectedMemberIds.size === 0 || isAdding,
+ }}
+ />
+
+ )
+}
+
+interface DenylistGridItem {
+ id: string
+ label: string
+}
+
+interface DenylistControls {
+ isAllowed: (id: string) => boolean
+ onToggle: (id: string) => void
+ onSetDenied: (ids: string[], denied: boolean) => void
+}
+
+interface CheckboxGridProps extends DenylistControls {
+ items: DenylistGridItem[]
+ isLoading: boolean
+ searchPlaceholder: string
+ emptyLabel: string
+}
+
+/**
+ * Searchable two-column checkbox grid over a denylist. A checked item is
+ * allowed; unchecking adds it to the denylist. Shared by the model deny-list
+ * (per provider) and the tool deny-list (per integration block).
+ */
+function CheckboxGrid({
+ items,
+ isLoading,
+ searchPlaceholder,
+ emptyLabel,
+ isAllowed,
+ onToggle,
+ onSetDenied,
+}: CheckboxGridProps) {
+ const [search, setSearch] = useState('')
+
+ const sortedItems = useMemo(
+ () => [...items].sort((a, b) => a.label.localeCompare(b.label)),
+ [items]
+ )
+
+ const filteredItems = useMemo(() => {
+ if (!search.trim()) return sortedItems
+ const query = search.toLowerCase()
+ return sortedItems.filter(
+ (item) => item.label.toLowerCase().includes(query) || item.id.toLowerCase().includes(query)
+ )
+ }, [sortedItems, search])
+
+ if (isLoading) {
+ return Loading…
+ }
+
+ if (items.length === 0) {
+ return {emptyLabel}
+ }
+
+ const allFilteredAllowed = filteredItems.every((item) => isAllowed(item.id))
+
+ return (
+
+
+ setSearch(e.target.value)}
+ className='min-w-0 flex-1'
+ />
+
+ onSetDenied(
+ filteredItems.map((item) => item.id),
+ allFilteredAllowed
+ )
+ }
+ >
+ {allFilteredAllowed ? 'Block All' : 'Allow All'}
+
+
+
+ {filteredItems.map((item) => {
+ const checkboxId = `denylist-${item.id}`
+ return (
+
+ onToggle(item.id)}
+ />
+ {item.label}
+
+ )
+ })}
+
+
+ )
+}
+
+interface DynamicProviderModelsProps extends DenylistControls {
+ provider: ProviderName
+ workspaceId?: string
+}
+
+function DynamicProviderModels({ provider, workspaceId, ...controls }: DynamicProviderModelsProps) {
+ const { data, isPending } = useProviderModels(provider, workspaceId)
+ const items = useMemo(
+ () => (data?.models ?? []).map((model) => ({ id: model, label: model })),
+ [data?.models]
+ )
+ return (
+
+ )
+}
+
+interface StaticProviderModelsProps extends DenylistControls {
+ providerId: ProviderId
+}
+
+function StaticProviderModels({ providerId, ...controls }: StaticProviderModelsProps) {
+ const items = useMemo(
+ () => getProviderModels(providerId).map((model) => ({ id: model, label: model })),
+ [providerId]
+ )
+ return (
+
+ )
+}
+
+interface ProviderRowProps extends DenylistControls {
+ providerId: ProviderId
+ isProviderAllowed: boolean
+ onToggleProvider: () => void
+ deniedCount: number
+ workspaceId?: string
+}
+
+function ProviderRow({
+ providerId,
+ isProviderAllowed,
+ onToggleProvider,
+ deniedCount,
+ workspaceId,
+ ...controls
+}: ProviderRowProps) {
+ const [expanded, setExpanded] = useState(false)
+
+ const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon
+ const providerName =
+ PROVIDER_DEFINITIONS[providerId]?.name ||
+ providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
+ const isDynamic = (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId)
+ const checkboxId = `provider-${providerId}`
+
+ return (
+
+
+
onToggleProvider()}
+ />
+
+ isProviderAllowed && setExpanded((prev) => !prev)}
+ disabled={!isProviderAllowed}
+ className={cn(
+ 'flex flex-1 items-center gap-2 text-left',
+ isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60'
+ )}
+ >
+ {providerName}
+ {isProviderAllowed && deniedCount > 0 && (
+
+ {deniedCount} blocked
+
+ )}
+ {isProviderAllowed && (
+
+ )}
+
+
+ {expanded && isProviderAllowed && (
+
+ {isDynamic ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ )
+}
+
+interface BlockToolRowProps extends DenylistControls {
+ block: BlockConfig
+ isBlockAllowed: boolean
+ onToggleBlock: () => void
+ deniedCount: number
+}
+
+/**
+ * Integration/trigger block row. The checkbox drives whole-block access
+ * (`allowedIntegrations`); when allowed and the block exposes more than one
+ * tool, the row expands to a per-tool deny-list grid (`deniedTools`), mirroring
+ * the provider → models pattern.
+ */
+function BlockToolRow({
+ block,
+ isBlockAllowed,
+ onToggleBlock,
+ deniedCount,
+ ...controls
+}: BlockToolRowProps) {
+ const [expanded, setExpanded] = useState(false)
+ const BlockIcon = block.icon
+ const checkboxId = `block-${block.type}`
+
+ const toolItems = useMemo(
+ () => (block.tools?.access ?? []).map((id) => ({ id, label: getTool(id)?.name ?? id })),
+ [block.tools?.access]
+ )
+ const isExpandable = toolItems.length > 1
+
+ return (
+
+
+
onToggleBlock()}
+ />
+
+ {BlockIcon && }
+
+ isBlockAllowed && isExpandable && setExpanded((prev) => !prev)}
+ disabled={!isBlockAllowed || !isExpandable}
+ className={cn(
+ 'flex flex-1 items-center gap-2 text-left',
+ isBlockAllowed && isExpandable ? 'cursor-pointer' : 'cursor-default',
+ !isBlockAllowed && 'opacity-60'
+ )}
+ >
+ {block.name}
+ {isBlockAllowed && deniedCount > 0 && (
+
+ {deniedCount} blocked
+
+ )}
+ {isBlockAllowed && isExpandable && (
+
+ )}
+
+
+ {expanded && isBlockAllowed && isExpandable && (
+
+
+
+ )}
+
+ )
+}
+
+interface GroupDetailProps {
+ group: PermissionGroup
+ organizationId: string
+ workspaceId?: string
+ workspaceOptions: { value: string; label: string }[]
+ organizationWorkspaces: PermissionGroupWorkspaceRef[]
+ workspacesLoading: boolean
+ onBack: () => void
+ onDeleted: () => void
+}
+
+/**
+ * Full-surface, tabbed configuration view for a single permission group. Owns
+ * its own editing buffer (`editingConfig`), scope/default writes, and member
+ * management — replacing the former cramped configure modal.
+ */
+export function GroupDetail({
+ group,
+ organizationId,
+ workspaceId,
+ workspaceOptions,
+ organizationWorkspaces,
+ workspacesLoading,
+ onBack,
+ onDeleted,
+}: GroupDetailProps) {
+ const updatePermissionGroup = useUpdatePermissionGroup()
+ const deletePermissionGroup = useDeletePermissionGroup()
+ const removeMember = useRemovePermissionGroupMember()
+ const bulkAddMembers = useBulkAddPermissionGroupMembers()
+
+ /**
+ * Local, authoritative copy of the group while the detail view is open. Seeded
+ * from the prop and re-seeded only when the selected group id changes, so
+ * optimistic scope/default/config writes are not clobbered by list refetches.
+ */
+ const [viewingGroup, setViewingGroup] = useState(group)
+ const [editingConfig, setEditingConfig] = useState({ ...group.config })
+ const prevGroupIdRef = useRef(group.id)
+ if (prevGroupIdRef.current !== group.id) {
+ prevGroupIdRef.current = group.id
+ setViewingGroup(group)
+ setEditingConfig({ ...group.config })
+ }
+
+ /**
+ * Monotonic token for scope-affecting writes (workspace select + default
+ * toggle). Only the most recent write may reconcile or revert the local
+ * group, so rapid multi-select toggles can't settle on a stale response.
+ */
+ const scopeWriteSeqRef = useRef(0)
+
+ const [configTab, setConfigTab] = useState('general')
+ const [providerSearchTerm, setProviderSearchTerm] = useState('')
+ const [integrationSearchTerm, setIntegrationSearchTerm] = useState('')
+ const [platformSearchTerm, setPlatformSearchTerm] = useState('')
+
+ const [showAddMembersModal, setShowAddMembersModal] = useState(false)
+ const [addMembersError, setAddMembersError] = useState(null)
+ const [selectedMemberIds, setSelectedMemberIds] = useState>(() => new Set())
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
+ const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
+
+ const { data: members = [], isPending: membersLoading } = usePermissionGroupMembers(
+ organizationId,
+ viewingGroup.id
+ )
+ const { data: roster } = useOrganizationRoster(organizationId)
+ const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: true })
+
+ const allBlocks = useMemo(() => {
+ const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type))
+ return blocks.sort((a, b) => {
+ const categoryOrder = { triggers: 0, blocks: 1, tools: 2 }
+ const catA = categoryOrder[a.category] ?? 3
+ const catB = categoryOrder[b.category] ?? 3
+ if (catA !== catB) return catA - catB
+ return a.name.localeCompare(b.name)
+ })
+ }, [])
+
+ const allProviderIds = useMemo(() => {
+ const allIds = getAllProviderIds()
+ const blacklist = blacklistedProvidersData?.blacklistedProviders ?? []
+ if (blacklist.length === 0) return allIds
+ return allIds.filter((id) => !blacklist.includes(id.toLowerCase()))
+ }, [blacklistedProvidersData])
+
+ /** Maps every tool id to ALL block types that expose it (some tools are shared across blocks). */
+ const toolBlockTypes = useMemo(() => {
+ const map: Record = {}
+ for (const block of allBlocks) {
+ for (const toolId of block.tools?.access ?? []) {
+ ;(map[toolId] ??= []).push(block.type)
+ }
+ }
+ return map
+ }, [allBlocks])
+
+ const platformFeatures = useMemo(
+ () => [
+ {
+ id: 'hide-knowledge-base',
+ label: 'Knowledge Base',
+ category: 'Sidebar',
+ configKey: 'hideKnowledgeBaseTab' as const,
+ hint: 'Hide the Knowledge Base module from the sidebar.',
+ },
+ {
+ id: 'hide-tables',
+ label: 'Tables',
+ category: 'Sidebar',
+ configKey: 'hideTablesTab' as const,
+ hint: 'Hide the Tables module from the sidebar.',
+ },
+ {
+ id: 'hide-copilot',
+ label: 'Chat',
+ category: 'Workflow Panel',
+ configKey: 'hideCopilot' as const,
+ hint: 'Hide the Chat panel so users cannot build or edit with natural language.',
+ },
+ {
+ id: 'hide-integrations',
+ label: 'Integrations',
+ category: 'Settings Tabs',
+ configKey: 'hideIntegrationsTab' as const,
+ hint: 'Hide the Integrations settings tab (OAuth connections).',
+ },
+ {
+ id: 'hide-secrets',
+ label: 'Secrets',
+ category: 'Settings Tabs',
+ configKey: 'hideSecretsTab' as const,
+ hint: 'Hide the Secrets (environment variables) settings tab.',
+ },
+ {
+ id: 'hide-api-keys',
+ label: 'API Keys',
+ category: 'Settings Tabs',
+ configKey: 'hideApiKeysTab' as const,
+ hint: 'Hide the API Keys settings tab.',
+ },
+ {
+ id: 'hide-files',
+ label: 'Files',
+ category: 'Settings Tabs',
+ configKey: 'hideFilesTab' as const,
+ hint: 'Hide the Files settings tab.',
+ },
+ {
+ id: 'hide-deploy-api',
+ label: 'API',
+ category: 'Deploy Tabs',
+ configKey: 'hideDeployApi' as const,
+ hint: 'Hide the API deployment option.',
+ },
+ {
+ id: 'hide-deploy-mcp',
+ label: 'MCP',
+ category: 'Deploy Tabs',
+ configKey: 'hideDeployMcp' as const,
+ hint: 'Hide the MCP server deployment option.',
+ },
+ {
+ id: 'hide-deploy-a2a',
+ label: 'A2A',
+ category: 'Deploy Tabs',
+ configKey: 'hideDeployA2a' as const,
+ hint: 'Hide the agent-to-agent deployment option.',
+ },
+ {
+ id: 'hide-deploy-chatbot',
+ label: 'Chat',
+ category: 'Deploy Tabs',
+ configKey: 'hideDeployChatbot' as const,
+ hint: 'Hide the chatbot deployment option.',
+ },
+ {
+ id: 'hide-deploy-template',
+ label: 'Template',
+ category: 'Deploy Tabs',
+ configKey: 'hideDeployTemplate' as const,
+ hint: 'Hide the template publishing option.',
+ },
+ {
+ id: 'disable-mcp',
+ label: 'MCP Tools',
+ category: 'Tools',
+ configKey: 'disableMcpTools' as const,
+ hint: 'Block agents from calling MCP tools.',
+ },
+ {
+ id: 'disable-custom-tools',
+ label: 'Custom Tools',
+ category: 'Tools',
+ configKey: 'disableCustomTools' as const,
+ hint: 'Block agents from calling user-defined custom tools.',
+ },
+ {
+ id: 'disable-skills',
+ label: 'Skills',
+ category: 'Tools',
+ configKey: 'disableSkills' as const,
+ hint: 'Block agents from loading skills.',
+ },
+ {
+ id: 'hide-trace-spans',
+ label: 'Trace Spans',
+ category: 'Logs',
+ configKey: 'hideTraceSpans' as const,
+ hint: 'Hide per-block trace spans in logs.',
+ },
+ {
+ id: 'disable-invitations',
+ label: 'Invitations',
+ category: 'Collaboration',
+ configKey: 'disableInvitations' as const,
+ hint: 'Prevent users from inviting others to workspaces.',
+ },
+ {
+ id: 'hide-inbox',
+ label: 'Sim Mailer',
+ category: 'Features',
+ configKey: 'hideInboxTab' as const,
+ hint: 'Hide the Sim Mailer inbox.',
+ },
+ {
+ id: 'disable-public-api',
+ label: 'Public API',
+ category: 'Features',
+ configKey: 'disablePublicApi' as const,
+ hint: 'Disable public API access to deployed workflows.',
+ },
+ ],
+ []
+ )
+
+ const filteredPlatformFeatures = useMemo(() => {
+ if (!platformSearchTerm.trim()) return platformFeatures
+ const search = platformSearchTerm.toLowerCase()
+ return platformFeatures.filter(
+ (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search)
+ )
+ }, [platformFeatures, platformSearchTerm])
+
+ const platformCategories = useMemo(() => {
+ const categories: Record = {}
+ for (const feature of filteredPlatformFeatures) {
+ if (!categories[feature.category]) {
+ categories[feature.category] = []
+ }
+ categories[feature.category].push(feature)
+ }
+ return categories
+ }, [filteredPlatformFeatures])
+
+ const platformCategoryColumns = useMemo(() => {
+ const categoryGroups = [
+ ['Sidebar', 'Deploy Tabs', 'Collaboration'],
+ ['Workflow Panel', 'Tools', 'Features'],
+ ['Settings Tabs', 'Logs'],
+ ]
+
+ const assignedCategories = new Set(categoryGroups.flat())
+ const unassigned = Object.keys(platformCategories).filter(
+ (c) => c !== 'Files' && !assignedCategories.has(c)
+ )
+ const groups = unassigned.length > 0 ? [...categoryGroups, unassigned] : categoryGroups
+
+ return groups
+ .map((column) =>
+ column
+ .map((category) => ({
+ category,
+ features: platformCategories[category] ?? [],
+ }))
+ .filter((section) => section.features.length > 0)
+ )
+ .filter((column) => column.length > 0)
+ }, [platformCategories])
+
+ const hasConfigChanges = useMemo(() => {
+ return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig)
+ }, [viewingGroup.config, editingConfig])
+
+ const filteredProviders = useMemo(() => {
+ if (!providerSearchTerm.trim()) return allProviderIds
+ const query = providerSearchTerm.toLowerCase()
+ return allProviderIds.filter((id) => id.toLowerCase().includes(query))
+ }, [allProviderIds, providerSearchTerm])
+
+ const filteredBlocks = useMemo(() => {
+ if (!integrationSearchTerm.trim()) return allBlocks
+ const query = integrationSearchTerm.toLowerCase()
+ return allBlocks.filter((b) => b.name.toLowerCase().includes(query))
+ }, [allBlocks, integrationSearchTerm])
+
+ const filteredCoreBlocks = useMemo(
+ () => filteredBlocks.filter((block) => block.category === 'blocks'),
+ [filteredBlocks]
+ )
+
+ const filteredToolBlocks = useMemo(
+ () =>
+ filteredBlocks
+ .filter((block) => block.category === 'tools' || block.category === 'triggers')
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ [filteredBlocks]
+ )
+
+ const organizationMembers = useMemo(() => {
+ if (!roster?.members) return []
+ return roster.members
+ .filter((m) => m.role !== 'external')
+ .map((m) => ({
+ userId: m.userId,
+ user: { name: m.name, email: m.email, image: m.image },
+ }))
+ }, [roster])
+
+ const availableMembersToAdd = useMemo(() => {
+ const existingMemberUserIds = new Set(members.map((m) => m.userId))
+ return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId))
+ }, [organizationMembers, members])
+
+ const isIntegrationAllowed = useCallback(
+ (blockType: string) =>
+ editingConfig.allowedIntegrations === null ||
+ editingConfig.allowedIntegrations.includes(blockType),
+ [editingConfig.allowedIntegrations]
+ )
+
+ /**
+ * Drops denied tools whose integration is no longer allowed, keeping the
+ * invariant that `deniedTools` only holds tools of currently-allowed blocks.
+ * Without this, disabling then re-enabling an integration would silently
+ * re-apply stale per-tool denials. Tools we can't attribute to a known block
+ * are preserved.
+ */
+ const pruneDeniedTools = useCallback(
+ (allowedIntegrations: string[] | null, deniedTools: string[]) => {
+ if (allowedIntegrations === null) return deniedTools
+ const allowed = new Set(allowedIntegrations)
+ const pruned = deniedTools.filter((toolId) => {
+ const blockTypes = toolBlockTypes[toolId]
+ return !blockTypes || blockTypes.some((bt) => allowed.has(bt))
+ })
+ return pruned.length === deniedTools.length ? deniedTools : pruned
+ },
+ [toolBlockTypes]
+ )
+
+ const toggleIntegration = useCallback(
+ (blockType: string) => {
+ setEditingConfig((prev) => {
+ const current = prev.allowedIntegrations
+ let nextAllowed: string[] | null
+ if (current === null) {
+ nextAllowed = allBlocks.map((b) => b.type).filter((t) => t !== blockType)
+ } else if (current.includes(blockType)) {
+ const updated = current.filter((t) => t !== blockType)
+ nextAllowed = updated.length === allBlocks.length ? null : updated
+ } else {
+ const updated = [...current, blockType]
+ nextAllowed = updated.length === allBlocks.length ? null : updated
+ }
+ return {
+ ...prev,
+ allowedIntegrations: nextAllowed,
+ deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
+ }
+ })
+ },
+ [allBlocks, pruneDeniedTools]
+ )
+
+ /** Allow or deny a whole section's blocks at once, respecting the active filter. */
+ const setBlocksAllowed = useCallback(
+ (blocks: BlockConfig[], allowed: boolean) => {
+ setEditingConfig((prev) => {
+ const allTypes = allBlocks.map((b) => b.type)
+ const current =
+ prev.allowedIntegrations === null ? new Set(allTypes) : new Set(prev.allowedIntegrations)
+ for (const block of blocks) {
+ if (allowed) current.add(block.type)
+ else current.delete(block.type)
+ }
+ const nextArr = allTypes.filter((t) => current.has(t))
+ const nextAllowed = nextArr.length === allTypes.length ? null : nextArr
+ return {
+ ...prev,
+ allowedIntegrations: nextAllowed,
+ deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
+ }
+ })
+ },
+ [allBlocks, pruneDeniedTools]
+ )
+
+ const isToolAllowed = useCallback(
+ (toolId: string) => !editingConfig.deniedTools.includes(toolId),
+ [editingConfig.deniedTools]
+ )
+
+ const toggleTool = useCallback((toolId: string) => {
+ setEditingConfig((prev) => {
+ const denied = prev.deniedTools.includes(toolId)
+ return {
+ ...prev,
+ deniedTools: denied
+ ? prev.deniedTools.filter((t) => t !== toolId)
+ : [...prev.deniedTools, toolId],
+ }
+ })
+ }, [])
+
+ const setToolsDenied = useCallback((toolIds: string[], denied: boolean) => {
+ setEditingConfig((prev) => {
+ if (denied) {
+ const existing = new Set(prev.deniedTools)
+ const additions = toolIds.filter((t) => !existing.has(t))
+ if (additions.length === 0) return prev
+ return { ...prev, deniedTools: [...prev.deniedTools, ...additions] }
+ }
+ const toRemove = new Set(toolIds)
+ return { ...prev, deniedTools: prev.deniedTools.filter((t) => !toRemove.has(t)) }
+ })
+ }, [])
+
+ const deniedCountByBlock = useMemo(() => {
+ const denied = new Set(editingConfig.deniedTools)
+ const counts: Record = {}
+ for (const block of allBlocks) {
+ let count = 0
+ for (const toolId of block.tools?.access ?? []) {
+ if (denied.has(toolId)) count++
+ }
+ if (count > 0) counts[block.type] = count
+ }
+ return counts
+ }, [editingConfig.deniedTools, allBlocks])
+
+ const isProviderAllowed = useCallback(
+ (providerId: string) =>
+ editingConfig.allowedModelProviders === null ||
+ editingConfig.allowedModelProviders.includes(providerId),
+ [editingConfig.allowedModelProviders]
+ )
+
+ const toggleProvider = useCallback(
+ (providerId: string) => {
+ setEditingConfig((prev) => {
+ const current = prev.allowedModelProviders
+ if (current === null) {
+ const allExcept = allProviderIds.filter((p) => p !== providerId)
+ return { ...prev, allowedModelProviders: allExcept }
+ }
+ if (current.includes(providerId)) {
+ const updated = current.filter((p) => p !== providerId)
+ return {
+ ...prev,
+ allowedModelProviders: updated.length === allProviderIds.length ? null : updated,
+ }
+ }
+ const updated = [...current, providerId]
+ return {
+ ...prev,
+ allowedModelProviders: updated.length === allProviderIds.length ? null : updated,
+ }
+ })
+ },
+ [allProviderIds]
+ )
+
+ /** Allow or deny a set of providers at once, respecting the active search filter. */
+ const setProvidersAllowed = useCallback(
+ (providerIds: string[], allowed: boolean) => {
+ setEditingConfig((prev) => {
+ const current =
+ prev.allowedModelProviders === null
+ ? new Set(allProviderIds)
+ : new Set(prev.allowedModelProviders)
+ for (const id of providerIds) {
+ if (allowed) current.add(id)
+ else current.delete(id)
+ }
+ const nextArr = allProviderIds.filter((id) => current.has(id))
+ return {
+ ...prev,
+ allowedModelProviders: nextArr.length === allProviderIds.length ? null : nextArr,
+ }
+ })
+ },
+ [allProviderIds]
+ )
+
+ const isModelAllowed = useCallback(
+ (model: string) => {
+ const normalized = model.toLowerCase()
+ return !editingConfig.deniedModels.some((denied) => denied.toLowerCase() === normalized)
+ },
+ [editingConfig.deniedModels]
+ )
+
+ const toggleModel = useCallback((model: string) => {
+ setEditingConfig((prev) => {
+ const normalized = model.toLowerCase()
+ const isDenied = prev.deniedModels.some((denied) => denied.toLowerCase() === normalized)
+ return {
+ ...prev,
+ deniedModels: isDenied
+ ? prev.deniedModels.filter((denied) => denied.toLowerCase() !== normalized)
+ : [...prev.deniedModels, model],
+ }
+ })
+ }, [])
+
+ const setModelsDenied = useCallback((models: string[], denied: boolean) => {
+ setEditingConfig((prev) => {
+ if (denied) {
+ const existing = new Set(prev.deniedModels.map((m) => m.toLowerCase()))
+ const additions = models.filter((m) => !existing.has(m.toLowerCase()))
+ if (additions.length === 0) return prev
+ return { ...prev, deniedModels: [...prev.deniedModels, ...additions] }
+ }
+ const toRemove = new Set(models.map((m) => m.toLowerCase()))
+ return {
+ ...prev,
+ deniedModels: prev.deniedModels.filter((m) => !toRemove.has(m.toLowerCase())),
+ }
+ })
+ }, [])
+
+ const deniedCountByProvider = useMemo(() => {
+ const counts: Record = {}
+ for (const model of editingConfig.deniedModels) {
+ try {
+ const providerId = getProviderFromModel(model)
+ counts[providerId] = (counts[providerId] ?? 0) + 1
+ } catch {
+ // Unknown/blacklisted provider — omit from counts.
+ }
+ }
+ return counts
+ }, [editingConfig.deniedModels])
+
+ const isFileShareAuthAllowed = useCallback(
+ (authType: ShareAuthType) =>
+ editingConfig.allowedFileShareAuthTypes === null ||
+ editingConfig.allowedFileShareAuthTypes.includes(authType),
+ [editingConfig.allowedFileShareAuthTypes]
+ )
+
+ const toggleFileShareAuthType = useCallback((authType: ShareAuthType) => {
+ setEditingConfig((prev) => {
+ const current = prev.allowedFileShareAuthTypes
+ const next =
+ current === null
+ ? ALL_FILE_SHARE_AUTH_TYPES.filter((t) => t !== authType)
+ : current.includes(authType)
+ ? current.filter((t) => t !== authType)
+ : [...current, authType]
+ return {
+ ...prev,
+ allowedFileShareAuthTypes: next.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : next,
+ }
+ })
+ }, [])
+
+ /** Persists the editing buffer. Returns whether the save succeeded so callers can decide whether to navigate away. */
+ const handleSaveConfig = useCallback(async (): Promise => {
+ try {
+ await updatePermissionGroup.mutateAsync({
+ id: viewingGroup.id,
+ organizationId,
+ config: editingConfig,
+ })
+ setViewingGroup((prev) => ({ ...prev, config: editingConfig }))
+ return true
+ } catch (error) {
+ logger.error('Failed to update config', error)
+ toast.error("Couldn't save changes", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ return false
+ }
+ }, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup])
+
+ const handleDiscardConfig = useCallback(() => {
+ setEditingConfig({ ...viewingGroup.config })
+ }, [viewingGroup.config])
+
+ const handleBack = useCallback(() => {
+ if (hasConfigChanges) {
+ setShowUnsavedChanges(true)
+ return
+ }
+ onBack()
+ }, [hasConfigChanges, onBack])
+
+ const handleScopeChange = useCallback(
+ async (workspaceIds: string[]) => {
+ const previous = viewingGroup
+ const seq = ++scopeWriteSeqRef.current
+
+ setViewingGroup((prev) => ({
+ ...prev,
+ workspaces: organizationWorkspaces.filter((ws) => workspaceIds.includes(ws.id)),
+ }))
+ try {
+ const result = await updatePermissionGroup.mutateAsync({
+ id: viewingGroup.id,
+ organizationId,
+ workspaceIds,
+ })
+ if (seq !== scopeWriteSeqRef.current) return
+ setViewingGroup((prev) => ({
+ ...prev,
+ workspaces: organizationWorkspaces.filter((ws) =>
+ result.permissionGroup.workspaceIds.includes(ws.id)
+ ),
+ }))
+ } catch (error) {
+ logger.error('Failed to update workspace scope', error)
+ if (seq !== scopeWriteSeqRef.current) return
+ setViewingGroup(previous)
+ toast.error("Couldn't update workspaces", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ }
+ },
+ [viewingGroup, organizationId, organizationWorkspaces, updatePermissionGroup]
+ )
+
+ const handleToggleDefault = useCallback(
+ async (enabled: boolean) => {
+ const seq = ++scopeWriteSeqRef.current
+ try {
+ const result = await updatePermissionGroup.mutateAsync({
+ id: viewingGroup.id,
+ organizationId,
+ isDefault: enabled,
+ })
+ if (seq !== scopeWriteSeqRef.current) return
+ setViewingGroup((prev) => ({
+ ...prev,
+ isDefault: result.permissionGroup.isDefault,
+ workspaces: result.permissionGroup.isDefault
+ ? []
+ : organizationWorkspaces.filter((ws) =>
+ result.permissionGroup.workspaceIds.includes(ws.id)
+ ),
+ }))
+ } catch (error) {
+ logger.error('Failed to toggle default group', error)
+ toast.error("Couldn't update the default group", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ }
+ },
+ [viewingGroup.id, organizationId, organizationWorkspaces, updatePermissionGroup]
+ )
+
+ const handleRemoveMember = useCallback(
+ async (memberId: string) => {
+ try {
+ await removeMember.mutateAsync({
+ organizationId,
+ permissionGroupId: viewingGroup.id,
+ memberId,
+ })
+ } catch (error) {
+ logger.error('Failed to remove member', error)
+ toast.error("Couldn't remove member", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ }
+ },
+ [viewingGroup.id, organizationId, removeMember]
+ )
+
+ const handleOpenAddMembersModal = useCallback(() => {
+ setSelectedMemberIds(new Set())
+ setAddMembersError(null)
+ setShowAddMembersModal(true)
+ }, [])
+
+ const handleAddSelectedMembers = useCallback(async () => {
+ if (selectedMemberIds.size === 0) return
+ setAddMembersError(null)
+ try {
+ await bulkAddMembers.mutateAsync({
+ organizationId,
+ permissionGroupId: viewingGroup.id,
+ userIds: Array.from(selectedMemberIds),
+ })
+ setShowAddMembersModal(false)
+ setSelectedMemberIds(new Set())
+ } catch (error) {
+ logger.error('Failed to add members', error)
+ setAddMembersError(getErrorMessage(error, 'Failed to add members'))
+ }
+ }, [viewingGroup.id, organizationId, selectedMemberIds, bulkAddMembers])
+
+ const confirmDelete = useCallback(async () => {
+ try {
+ await deletePermissionGroup.mutateAsync({
+ permissionGroupId: viewingGroup.id,
+ organizationId,
+ })
+ setShowDeleteConfirm(false)
+ onDeleted()
+ } catch (error) {
+ logger.error('Failed to delete permission group', error)
+ toast.error("Couldn't delete group", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ }
+ }, [viewingGroup.id, organizationId, deletePermissionGroup, onDeleted])
+
+ const tabs = useMemo(
+ () => [
+ { value: 'general' as const, label: 'General' },
+ { value: 'providers' as const, label: 'Model Providers' },
+ { value: 'blocks' as const, label: 'Blocks' },
+ { value: 'platform' as const, label: 'Platform' },
+ ],
+ []
+ )
+
+ const filteredProvidersAllAllowed = filteredProviders.every((id) => isProviderAllowed(id))
+ const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type))
+ const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type))
+ const platformAllVisible = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey])
+
+ return (
+ <>
+
+
+
+ Access Control
+
+ setShowDeleteConfirm(true)}
+ disabled={deletePermissionGroup.isPending}
+ >
+ {deletePermissionGroup.isPending ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+ setConfigTab(value as ConfigTab)}
+ />
+
+
+
+
+
+
+
{viewingGroup.name}
+ {viewingGroup.description && (
+
{viewingGroup.description}
+ )}
+
+
+ {configTab === 'general' && (
+ <>
+
+
+
+ Applies to everyone in the organization not assigned to another group,
+ including external workspace members
+
+ handleToggleDefault(checked)}
+ disabled={updatePermissionGroup.isPending}
+ />
+
+
+
+
+ {viewingGroup.isDefault ? (
+
+
+ Governs every workspace in the organization
+
+
+ ) : (
+
+
+
+ {viewingGroup.workspaces.length > 0
+ ? `Governs ${viewingGroup.workspaces.length} workspace${
+ viewingGroup.workspaces.length === 1 ? '' : 's'
+ }`
+ : 'Select the workspaces this group governs'}
+
+ ws.id)}
+ onChange={handleScopeChange}
+ options={workspaceOptions}
+ isLoading={workspacesLoading}
+ allowAllWorkspaces={false}
+ className='flex-shrink-0'
+ />
+
+ {viewingGroup.workspaces.length > 0 && (
+
+ {viewingGroup.workspaces.map((ws) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {!viewingGroup.isDefault && (
+
+
+
+
+ {members.length === 0
+ ? 'Applies to all members of its workspaces. Add members to restrict it to specific people.'
+ : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`}
+
+
+ Add
+
+
+ {membersLoading ? (
+
+ {[1, 2].map((i) => (
+
+
+
+
+ ))}
+
+ ) : (
+ members.length > 0 && (
+
+ {members.map((member) => (
+
+
+
+
+
+
+
+ handleRemoveMember(member.id)}
+ >
+ Remove
+
+
+
+ }
+ />
+ ))}
+
+ )
+ )}
+
+
+ )}
+ >
+ )}
+
+ {configTab === 'providers' && (
+
+
+ setProviderSearchTerm(e.target.value)}
+ className='min-w-0 flex-1'
+ />
+
+ setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)
+ }
+ >
+ {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
+
+
+
+ {filteredProviders.map((providerId) => (
+
toggleProvider(providerId)}
+ deniedCount={deniedCountByProvider[providerId] ?? 0}
+ workspaceId={workspaceId}
+ isAllowed={isModelAllowed}
+ onToggle={toggleModel}
+ onSetDenied={setModelsDenied}
+ />
+ ))}
+
+
+ )}
+
+ {configTab === 'blocks' && (
+
+
+ setIntegrationSearchTerm(e.target.value)}
+ className='min-w-0 flex-1'
+ />
+
+
+ {filteredCoreBlocks.length > 0 && (
+
+
+
+ Core Blocks
+
+
+ setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)
+ }
+ >
+ {coreBlocksAllAllowed ? 'Deselect All' : 'Select All'}
+
+
+
+ {filteredCoreBlocks.map((block) => {
+ const BlockIcon = block.icon
+ const checkboxId = `block-${block.type}`
+ return (
+
+ toggleIntegration(block.type)}
+ />
+
+ {BlockIcon && (
+
+ )}
+
+ {block.name}
+
+ )
+ })}
+
+
+ )}
+ {filteredToolBlocks.length > 0 && (
+
+
+
+
+ Integrations and Triggers
+
+
+ Allow a whole integration with its checkbox, then expand it to deny
+ specific tools while keeping the rest available.
+
+
+
+ setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)
+ }
+ >
+ {toolBlocksAllAllowed ? 'Deselect All' : 'Select All'}
+
+
+
+ {filteredToolBlocks.map((block) => (
+ toggleIntegration(block.type)}
+ deniedCount={deniedCountByBlock[block.type] ?? 0}
+ isAllowed={isToolAllowed}
+ onToggle={toggleTool}
+ onSetDenied={setToolsDenied}
+ />
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {configTab === 'platform' && (
+
+
+ setPlatformSearchTerm(e.target.value)}
+ className='min-w-0 flex-1'
+ />
+
+ setEditingConfig((prev) => ({
+ ...prev,
+ ...Object.fromEntries(
+ filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible])
+ ),
+ }))
+ }
+ >
+ {platformAllVisible ? 'Deselect All' : 'Select All'}
+
+
+
+ {platformCategoryColumns.map((column, columnIndex) => (
+
+ {column.map(({ category, features }) => (
+
+
+ {category}
+
+
+ {features.map((feature) => (
+
+
+
+ setEditingConfig((prev) => ({
+ ...prev,
+ [feature.configKey]: checked !== true,
+ }))
+ }
+ />
+ {feature.label}
+
+ {feature.hint}
+
+ ))}
+
+
+ ))}
+
+ ))}
+
+
+
Files
+
+
+ setEditingConfig((prev) => ({
+ ...prev,
+ disablePublicFileSharing: checked !== true,
+ }))
+ }
+ />
+ Public Sharing
+
+
+
+ Auth modes public file-share links may use
+
+
+ {FILE_SHARE_AUTH_TYPE_OPTIONS.map(({ value, label }) => (
+
+ toggleFileShareAuthType(value)}
+ disabled={editingConfig.disablePublicFileSharing}
+ />
+ {label}
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ {hasConfigChanges && (
+
+ Unsaved changes
+
+ Discard
+
+
+ {updatePermissionGroup.isPending ? 'Saving...' : 'Save'}
+
+
+ )}
+
+
+ {
+ setShowAddMembersModal(open)
+ if (!open) setAddMembersError(null)
+ }}
+ availableMembers={availableMembersToAdd}
+ selectedMemberIds={selectedMemberIds}
+ setSelectedMemberIds={setSelectedMemberIds}
+ onAddMembers={handleAddSelectedMembers}
+ isAdding={bulkAddMembers.isPending}
+ errorMessage={addMembersError}
+ />
+
+ setShowDeleteConfirm(false)}
+ srTitle='Delete Permission Group'
+ title='Delete Permission Group'
+ text={[
+ 'Are you sure you want to delete ',
+ { text: viewingGroup.name, bold: true },
+ '? ',
+ { text: 'All members will be removed from this group.', error: true },
+ ' This action cannot be undone.',
+ ]}
+ confirm={{
+ label: 'Delete',
+ onClick: confirmDelete,
+ pending: deletePermissionGroup.isPending,
+ pendingLabel: 'Deleting...',
+ }}
+ />
+
+
+ setShowUnsavedChanges(false)}>
+ Unsaved Changes
+
+
+
+ You have unsaved changes. Do you want to save them before leaving?
+
+
+ setShowUnsavedChanges(false)}
+ secondaryActions={[
+ {
+ label: 'Discard Changes',
+ onClick: () => {
+ setShowUnsavedChanges(false)
+ onBack()
+ },
+ variant: 'destructive',
+ },
+ ]}
+ primaryAction={{
+ label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes',
+ onClick: async () => {
+ const saved = await handleSaveConfig()
+ if (saved) {
+ setShowUnsavedChanges(false)
+ onBack()
+ }
+ },
+ disabled: updatePermissionGroup.isPending,
+ }}
+ />
+
+ >
+ )
+}
diff --git a/apps/sim/ee/access-control/components/workspace-select.tsx b/apps/sim/ee/access-control/components/workspace-select.tsx
new file mode 100644
index 00000000000..f8f2496aa13
--- /dev/null
+++ b/apps/sim/ee/access-control/components/workspace-select.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import { ChipDropdown } from '@/components/emcn'
+
+interface WorkspaceSelectProps {
+ workspaceIds: string[]
+ onChange: (ids: string[]) => void
+ options: { value: string; label: string }[]
+ disabled?: boolean
+ isLoading?: boolean
+ fullWidth?: boolean
+ className?: string
+ /**
+ * When false, the "All workspaces" reset option is hidden and an empty
+ * selection reads as a prompt. Non-default groups must target ≥1 workspace.
+ */
+ allowAllWorkspaces?: boolean
+}
+
+/**
+ * Workspace scope multi-select. With `allowAllWorkspaces` an empty selection
+ * reads as "All workspaces" (the default group); otherwise it prompts for a
+ * selection, since non-default groups must target specific workspaces.
+ */
+export function WorkspaceSelect({
+ workspaceIds,
+ onChange,
+ options,
+ disabled = false,
+ isLoading = false,
+ fullWidth = false,
+ className,
+ allowAllWorkspaces = true,
+}: WorkspaceSelectProps) {
+ return (
+
+ )
+}
diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts
index d78ba101395..8c24c396706 100644
--- a/apps/sim/ee/access-control/utils/permission-check.test.ts
+++ b/apps/sim/ee/access-control/utils/permission-check.test.ts
@@ -17,6 +17,7 @@ const {
allowedIntegrations: null,
allowedModelProviders: null,
deniedModels: [],
+ deniedTools: [],
hideTraceSpans: false,
hideKnowledgeBaseTab: false,
hideTablesTab: false,
@@ -142,6 +143,7 @@ import {
ProviderNotAllowedError,
PublicFileSharingNotAllowedError,
SkillsNotAllowedError,
+ ToolNotAllowedError,
validateBlockType,
validateMcpToolsAllowed,
validateModelProvider,
@@ -597,6 +599,69 @@ describe('assertPermissionsAllowed', () => {
})
})
+ it('throws ToolNotAllowedError when the tool is on the denylist', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }]
+
+ await expect(
+ assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ toolId: 'slack_canvas',
+ })
+ ).rejects.toBeInstanceOf(ToolNotAllowedError)
+ })
+
+ it('allows a tool that is not on the denylist', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }]
+
+ await assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ toolId: 'slack_message',
+ })
+ })
+
+ it('allows every tool when the denylist is empty', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: [] } }]
+
+ await assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ toolId: 'slack_canvas',
+ })
+ })
+
+ it('denies a tool even when its block is allowed by the integration allowlist', async () => {
+ mockWorkspaceGroups.value = [
+ { config: { allowedIntegrations: ['slack'], deniedTools: ['slack_canvas'] } },
+ ]
+
+ await expect(
+ assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ blockType: 'slack',
+ toolId: 'slack_canvas',
+ })
+ ).rejects.toBeInstanceOf(ToolNotAllowedError)
+ })
+
+ it('still enforces the tool denylist for an exempt block type', async () => {
+ mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }]
+ mockGetBlock.mockImplementation((type) =>
+ type === 'slack' ? { hideFromToolbar: true } : undefined
+ )
+
+ await expect(
+ assertPermissionsAllowed({
+ userId: 'user-123',
+ workspaceId: 'workspace-1',
+ blockType: 'slack',
+ toolId: 'slack_canvas',
+ })
+ ).rejects.toBeInstanceOf(ToolNotAllowedError)
+ })
+
it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => {
mockWorkspaceGroups.value = [{ config: { disableCustomTools: true } }]
diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts
index 03c41369ecc..611ea077b45 100644
--- a/apps/sim/ee/access-control/utils/permission-check.ts
+++ b/apps/sim/ee/access-control/utils/permission-check.ts
@@ -50,6 +50,13 @@ export class IntegrationNotAllowedError extends Error {
}
}
+export class ToolNotAllowedError extends Error {
+ constructor(toolId: string) {
+ super(`Tool "${toolId}" is not allowed based on your permission group settings`)
+ this.name = 'ToolNotAllowedError'
+ }
+}
+
export class McpToolsNotAllowedError extends Error {
constructor() {
super('MCP tools are not allowed based on your permission group settings')
@@ -566,6 +573,12 @@ interface PermissionAssertion {
workspaceId: string | undefined
model?: string
blockType?: string
+ /**
+ * Concrete tool ID being executed (e.g. `slack_canvas`). Checked against the
+ * group's `deniedTools` denylist so an admin can allow an integration but deny
+ * specific operations within it. Pass the normalized tool id.
+ */
+ toolId?: string
toolKind?: ToolKind
ctx?: ExecutionContext
}
@@ -581,11 +594,11 @@ interface PermissionAssertion {
* callsite covers every future config field.
*/
export async function assertPermissionsAllowed(req: PermissionAssertion): Promise {
- const { userId, workspaceId, model, blockType, toolKind, ctx } = req
+ const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req
const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false
- if (blockTypeExempt && !model && !toolKind) {
+ if (blockTypeExempt && !model && !toolKind && !toolId) {
return
}
@@ -634,6 +647,11 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis
}
}
+ if (toolId && config?.deniedTools?.includes(toolId)) {
+ logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId })
+ throw new ToolNotAllowedError(toolId)
+ }
+
if (toolKind && config) {
if (toolKind === 'mcp' && config.disableMcpTools) {
logger.warn('MCP tools blocked by permission group', { userId, workspaceId })
diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
index 7d48ae631ea..a72ca616a09 100644
--- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx
+++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx
@@ -18,6 +18,7 @@ import {
ChipModalHeader,
ChipSelect,
ChipSwitch,
+ ChipTag,
Search,
toast,
} from '@/components/emcn'
@@ -767,9 +768,9 @@ export function DataRetentionSettings() {
Organization
-
+
Default
-
+
{orgRowSummary()}
diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts
index 330e502c78a..3513c4c167d 100644
--- a/apps/sim/hooks/use-permission-config.ts
+++ b/apps/sim/hooks/use-permission-config.ts
@@ -23,6 +23,7 @@ export interface PermissionConfigResult {
isBlockAllowed: (blockType: string) => boolean
isProviderAllowed: (providerId: string) => boolean
isModelAllowed: (model: string) => boolean
+ isToolAllowed: (toolId: string) => boolean
isInvitationsDisabled: boolean
isPublicApiDisabled: boolean
}
@@ -113,6 +114,13 @@ export function usePermissionConfig(): PermissionConfigResult {
}
}, [config.deniedModels])
+ const isToolAllowed = useMemo(() => {
+ return (toolId: string) => {
+ if (config.deniedTools.length === 0) return true
+ return !config.deniedTools.includes(toolId)
+ }
+ }, [config.deniedTools])
+
const filterBlocks = useMemo(() => {
return (blocks: T[]): T[] => {
if (mergedAllowedIntegrations === null) return blocks
@@ -156,6 +164,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isBlockAllowed,
isProviderAllowed,
isModelAllowed,
+ isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
}),
@@ -168,6 +177,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isBlockAllowed,
isProviderAllowed,
isModelAllowed,
+ isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
]
diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts
index b2f2d7fa7fd..a1e55c3d9ea 100644
--- a/apps/sim/lib/api/contracts/permission-groups.ts
+++ b/apps/sim/lib/api/contracts/permission-groups.ts
@@ -8,6 +8,7 @@ export const permissionGroupFullConfigSchema = z.object({
allowedIntegrations: z.array(z.string()).nullable(),
allowedModelProviders: z.array(z.string()).nullable(),
deniedModels: z.array(z.string()).default([]),
+ deniedTools: z.array(z.string()).default([]),
hideTraceSpans: z.boolean(),
hideKnowledgeBaseTab: z.boolean(),
hideTablesTab: z.boolean(),
diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts
index 3269ec69459..76c18c0b9b1 100644
--- a/apps/sim/lib/permission-groups/types.ts
+++ b/apps/sim/lib/permission-groups/types.ts
@@ -21,6 +21,7 @@ export const permissionGroupConfigSchema = z.object({
allowedIntegrations: z.array(z.string()).nullable().optional(),
allowedModelProviders: z.array(z.string()).nullable().optional(),
deniedModels: z.array(z.string()).optional(),
+ deniedTools: z.array(z.string()).optional(),
hideTraceSpans: z.boolean().optional(),
hideKnowledgeBaseTab: z.boolean().optional(),
hideTablesTab: z.boolean().optional(),
@@ -52,6 +53,13 @@ export interface PermissionGroupConfig {
* group, checked after `allowedModelProviders`. Empty means nothing is blocked.
*/
deniedModels: string[]
+ /**
+ * Snake_case tool IDs (e.g. `slack_canvas`) blocked for this group, checked
+ * after the block-level `allowedIntegrations` gate. Lets an admin allow an
+ * integration but deny specific operations within it. Empty means nothing is
+ * blocked.
+ */
+ deniedTools: string[]
hideTraceSpans: boolean
hideKnowledgeBaseTab: boolean
hideTablesTab: boolean
@@ -80,6 +88,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
allowedIntegrations: null,
allowedModelProviders: null,
deniedModels: [],
+ deniedTools: [],
hideTraceSpans: false,
hideKnowledgeBaseTab: false,
hideTablesTab: false,
@@ -116,6 +125,9 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
deniedModels: Array.isArray(c.deniedModels)
? c.deniedModels.filter((m): m is string => typeof m === 'string')
: [],
+ deniedTools: Array.isArray(c.deniedTools)
+ ? c.deniedTools.filter((t): t is string => typeof t === 'string')
+ : [],
hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false,
hideKnowledgeBaseTab:
typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false,
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 1f7e554c324..2ee548a4324 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -936,10 +936,13 @@ export async function executeTool(
? 'mcp'
: undefined
- if (toolKind && scope.userId && scope.workspaceId) {
+ // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools`
+ // denylist is enforced alongside the existing mcp/custom/skill gates.
+ if (scope.userId && scope.workspaceId) {
await assertPermissionsAllowed({
userId: scope.userId,
workspaceId: scope.workspaceId,
+ toolId: normalizedToolId,
toolKind,
ctx: executionContext,
})