From 7ba0e238cb19c9b850e5ee46d62f5201130e161f Mon Sep 17 00:00:00 2001
From: Waleed
Date: Thu, 25 Jun 2026 16:32:51 -0700
Subject: [PATCH] feat(access-control): page-based permission groups,
tool-level deny-list, settings row-action consistency (#5216)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(access-control): page-based permission groups, tool-level deny-list, settings row-action consistency
- Replace the cramped configure modal with a full-surface tabbed Access Control page (General/Model Providers/Blocks/Platform) with a sticky save bar
- Add deniedTools denylist to permission groups: deny individual tools within an allowed integration; enforced at the universal executeTool chokepoint via ToolNotAllowedError, and hidden from the operation dropdown for governed users
- Add per-section Select/Deselect All on the Blocks tab and expandable per-tool deny rows (mirrors Providers->Models)
- Standardize every settings list row on the canonical "..." DropdownMenu (custom-tools, mcp, workflow-mcp-servers, api-keys, secrets, credential-sets) and align badges (ChipTag), avatars (MemberAvatar), inputs (ChipInput), and the mothership env picker (ChipSelect)
* fix(access-control): don't leave the detail view when an unsaved-changes save fails
The unsaved-changes dialog's Save action navigated back unconditionally after handleSaveConfig, but that helper swallows mutation errors — so a failed save still exited the view and silently dropped the edits. handleSaveConfig now returns success, and the dialog only closes + navigates back when the save actually succeeded.
* fix(access-control): prune deniedTools for blocks that get disabled
deniedTools only matters while a block is allowed, but toggleIntegration/setBlocksAllowed left a disabled block's denied tools in the config. Disabling then re-enabling an integration would silently re-apply the old per-tool denials. Both handlers now prune deniedTools to the set of allowed blocks, keeping the invariant that deniedTools only holds tools of currently-allowed integrations.
* fix(access-control): attribute denied tools to all exposing blocks when pruning
A tool id can appear in more than one block's tools.access. The single tool->block map meant pruneDeniedTools (and the per-block denied count) attributed a shared tool to only one block, so disabling that block could drop a denial while the tool was still exposed by another allowed block. Tools now map to all exposing block types; a denial is pruned only when no allowed block exposes the tool, and the per-block count is derived from each block's own tool list.
* fix(access-control): scope Platform Select/Deselect All to the search filter
The Platform tab's bulk Select/Deselect All toggled every feature regardless of the active search, unlike the Blocks tab which scopes its per-section toggle to the filtered view. Both the all-visible check and the bulk update now operate on filteredPlatformFeatures for consistent behavior while searching.
* fix(access-control): scope Model Providers Select/Deselect All to the search filter
Like the Platform fix, the Providers tab's bulk action toggled every provider via allProviderIds regardless of the active search. Added setProvidersAllowed (mirroring setBlocksAllowed) so the bulk toggle and its label operate on filteredProviders, keeping all three tabs (Blocks/Platform/Providers) consistent while searching.
* fix(access-control): don't seed a denied operation as a block's default
The operation dropdown hides denied tools from the picker, but defaultOptionValue returned the block's defaultValue without checking deniedOperationIds, so a new block could start on an operation the user isn't allowed to run. It now falls back to the first allowed option when the configured default is denied. Existing stored operation values are intentionally left untouched (auto-rewriting a user's saved block would be destructive; the server remains the authoritative gate).
* chore(access-control): prefer TSDoc over inline comments
Convert declaration-level rationale comments to TSDoc (/** */) and trim redundant/verbose inline comments added during review, per the project's TSDoc convention.
---
.../settings/components/admin/admin.tsx | 6 +-
.../settings/components/api-keys/api-keys.tsx | 95 +-
.../credential-sets/credential-sets.tsx | 119 +-
.../components/custom-tools/custom-tools.tsx | 45 +-
.../settings/components/mcp/mcp.tsx | 31 +-
.../components/mothership/mothership.tsx | 53 +-
.../secrets-manager/secrets-manager.tsx | 134 +-
.../workflow-mcp-servers.tsx | 63 +-
.../components/dropdown/dropdown.tsx | 45 +-
.../components/access-control.tsx | 1711 +---------------
.../components/group-detail.tsx | 1784 +++++++++++++++++
.../components/workspace-select.tsx | 58 +
.../utils/permission-check.test.ts | 65 +
.../access-control/utils/permission-check.ts | 22 +-
.../components/data-retention-settings.tsx | 5 +-
apps/sim/hooks/use-permission-config.ts | 10 +
.../lib/api/contracts/permission-groups.ts | 1 +
apps/sim/lib/permission-groups/types.ts | 12 +
apps/sim/tools/index.ts | 5 +-
19 files changed, 2412 insertions(+), 1852 deletions(-)
create mode 100644 apps/sim/ee/access-control/components/group-detail.tsx
create mode 100644 apps/sim/ee/access-control/components/workspace-select.tsx
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 62393f2d47..444d6f573b 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 f41d180ed6..6f5f05477d 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 797a86fafe..86260a8fa5 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 987ef148f6..c468f1e8e4 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 19865911dd..4cdf68f9e5 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 516ffdab35..edf8739591 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 22bede4363..6ac886fec2 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 0000000000..5477c7a0fa
--- /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 0000000000..f8f2496aa1
--- /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 d78ba10139..8c24c39670 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 03c41369ec..611ea077b4 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 7d48ae631e..a72ca616a0 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 330e502c78..3513c4c167 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 b2f2d7fa7f..a1e55c3d9e 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 3269ec6945..76c18c0b9b 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 1f7e554c32..2ee548a432 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,
})