From 33ee7da6b912833f9b1919eb8f149d9184cbc317 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 14:53:29 -0700 Subject: [PATCH 1/8] 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) --- .../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 | 37 +- .../components/access-control.tsx | 1701 +--------------- .../components/group-detail.tsx | 1738 +++++++++++++++++ .../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 | 7 +- 19 files changed, 2358 insertions(+), 1844 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 62393f2d479..444d6f573bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -196,7 +196,7 @@ export function Admin() { )} -
+

@@ -231,10 +231,10 @@ export function Admin() { )}

-
+
-

User Management

+

User Management

void + /** When false, the Delete item is disabled (e.g. non-admins on workspace keys). */ + canDelete?: boolean +} + +/** + * Trailing `...` actions menu for an API key row. Mirrors the Secrets / + * Teammates row menu so the settings experience is consistent. + */ +function ApiKeyRowMenu({ keyName, onDelete, canDelete = true }: ApiKeyRowMenuProps) { + return ( +
+ + + + + + copyKeyName(keyName)}>Copy name + + Delete + + + +
+ ) +} + export function ApiKeys() { const { data: session } = useSession() const userId = session?.user?.id @@ -164,16 +223,14 @@ export function ApiKeys() { {key.displayKey || key.key}

- { + { setDeleteKey(key) setShowDeleteDialog(true) }} - disabled={!canManageWorkspaceKeys} - > - Delete - + canDelete={canManageWorkspaceKeys} + />
))}
@@ -197,16 +254,14 @@ export function ApiKeys() { {key.displayKey || key.key}

- { + { setDeleteKey(key) setShowDeleteDialog(true) }} - disabled={!canManageWorkspaceKeys} - > - Delete - + canDelete={canManageWorkspaceKeys} + />
))}
@@ -235,15 +290,13 @@ export function ApiKeys() { {key.displayKey || key.key}

- { + { setDeleteKey(key) setShowDeleteDialog(true) }} - > - Delete - + /> {isConflict && (
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx index f41d180ed6c..6f5f05477d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx @@ -20,7 +20,13 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, + chipVariants, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, type FileInputOptions, + MoreHorizontal, Search, TagInput, type TagItem, @@ -516,13 +522,26 @@ export function CredentialSets() {
- handleRemoveMember(member.id)} - disabled={removeMember.isPending} - > - Remove - + + + + + + handleRemoveMember(member.id)} + disabled={removeMember.isPending} + > + Remove + + +
) @@ -561,25 +580,41 @@ export function CredentialSets() {
- handleResendInvitation(invitation.id, email)} - disabled={ - resendingInvitations.has(invitation.id) || - (resendCooldowns[invitation.id] ?? 0) > 0 - } - > - {resendingInvitations.has(invitation.id) - ? 'Sending...' - : resendCooldowns[invitation.id] - ? `Resend (${resendCooldowns[invitation.id]}s)` - : 'Resend'} - - handleCancelInvitation(invitation.id)} - disabled={cancellingInvitations.has(invitation.id)} - > - {cancellingInvitations.has(invitation.id) ? 'Cancelling...' : 'Cancel'} - + + + + + + handleResendInvitation(invitation.id, email)} + disabled={ + resendingInvitations.has(invitation.id) || + (resendCooldowns[invitation.id] ?? 0) > 0 + } + > + {resendingInvitations.has(invitation.id) + ? 'Sending...' + : resendCooldowns[invitation.id] + ? `Resend (${resendCooldowns[invitation.id]}s)` + : 'Resend'} + + handleCancelInvitation(invitation.id)} + disabled={cancellingInvitations.has(invitation.id)} + > + {cancellingInvitations.has(invitation.id) + ? 'Cancelling...' + : 'Cancel'} + + +
) @@ -729,14 +764,30 @@ export function CredentialSets() { -
- setViewingSet(set)}>Details - handleDeleteClick(set)} - disabled={deletingSetIds.has(set.id)} - > - {deletingSetIds.has(set.id) ? 'Deleting...' : 'Delete'} - +
+ + + + + + setViewingSet(set)}> + Details + + handleDeleteClick(set)} + disabled={deletingSetIds.has(set.id)} + > + Delete + + +
))} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx index 797a86fafe2..86260a8fa51 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx @@ -5,7 +5,18 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { Plus } from 'lucide-react' import { useParams } from 'next/navigation' -import { Chip, ChipConfirmModal, ChipInput, Search } from '@/components/emcn' +import { + Chip, + ChipConfirmModal, + ChipInput, + chipVariants, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + MoreHorizontal, + Search, +} from '@/components/emcn' import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools' @@ -134,14 +145,30 @@ export function CustomTools() {

)} -
- setEditingTool(tool.id)}>Edit - handleDeleteClick(tool.id)} - disabled={deletingTools.has(tool.id)} - > - {deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'} - +
+ + + + + + setEditingTool(tool.id)}> + Edit + + handleDeleteClick(tool.id)} + disabled={deletingTools.has(tool.id)} + > + Delete + + +
))} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx index 987ef148f6f..c468f1e8e4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mcp/mcp.tsx @@ -12,6 +12,12 @@ import { Chip, ChipConfirmModal, ChipInput, + chipVariants, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + MoreHorizontal, Search, Tooltip, } from '@/components/emcn' @@ -120,10 +126,27 @@ function ServerListItem({

- Details - - {isDeleting ? 'Deleting...' : 'Delete'} - + + + + + + Details + + Delete + + +
) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx index 19865911ddd..4cdf68f9e54 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/mothership/mothership.tsx @@ -3,7 +3,7 @@ import { useCallback, useMemo, useState } from 'react' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn' +import { Badge, Button, ChipInput, ChipSelect, Label, Skeleton } from '@/components/emcn' import { AnthropicIcon, OpenAIIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' import { @@ -50,10 +50,10 @@ const TABS: { id: MothershipTab; label: string }[] = [ { id: 'byok', label: 'BYOK' }, ] -const ENV_OPTIONS: { id: MothershipEnv; label: string }[] = [ - { id: 'dev', label: 'Dev' }, - { id: 'staging', label: 'Staging' }, - { id: 'prod', label: 'Prod' }, +const ENV_OPTIONS: { value: MothershipEnv; label: string }[] = [ + { value: 'dev', label: 'Dev' }, + { value: 'staging', label: 'Staging' }, + { value: 'prod', label: 'Prod' }, ] function defaultTimeRange() { @@ -81,11 +81,11 @@ function formatDate(d: string | null | undefined) { } function Divider() { - return
+ return
} function SectionLabel({ children }: { children: React.ReactNode }) { - return

{children}

+ return

{children}

} export function Mothership() { @@ -104,23 +104,14 @@ export function Mothership() { {/* Environment selector */}
-
- {ENV_OPTIONS.map((opt) => ( - - ))} -
+ setMothershipParams({ env: value as MothershipEnv })} + placeholder='Select environment' + options={ENV_OPTIONS} + />
{/* Tab bar */} @@ -149,20 +140,18 @@ export function Mothership() {
- setStart(e.target.value)} - className='h-[30px] text-caption' />
- setEnd(e.target.value)} - className='h-[30px] text-caption' />
@@ -431,23 +420,23 @@ function LicensesTab({ environment }: { environment: MothershipEnv }) {
- { setNewName(e.target.value) setGeneratedKey(null) }} placeholder='e.g. Acme Corp' - className='h-[32px] w-[200px]' + className='w-[200px]' />
- setNewExpiry(e.target.value)} - className='h-[32px] w-[160px]' + className='w-[160px]' />
+ + + {onViewDetails && ( + View details + )} + Copy name + {onDelete && ( + + Delete + + )} + + + ) +} const generateRowId = (() => { let counter = 0 @@ -201,23 +259,11 @@ function WorkspaceVariableRow({ canEdit={canEdit} name={`workspace_env_value_${envKey}_${generateShortId()}`} /> - onViewDetails(envKey)} - disabled={!hasCredential} - className={cn('ml-2', !hasCredential && 'opacity-40')} - > - Details - - {canEdit ? ( - - - onDelete(envKey)} aria-label='Delete secret' /> - - Delete secret - - ) : ( -
- )} + copyName(envKey)} + onViewDetails={hasCredential ? () => onViewDetails(envKey) : undefined} + onDelete={canEdit ? () => onDelete(envKey) : undefined} + />
) } @@ -262,22 +308,19 @@ function NewWorkspaceVariableRow({ onPaste={onPaste ? (e) => onPaste(e, index) : undefined} placeholder='Enter value' name={`new_workspace_value_${envVar.id || index}_${generateShortId()}`} - className='col-span-2 ml-0' + className='ml-0' /> - - - { - onUpdate(index, 'key', '') - onUpdate(index, 'value', '') - }} - disabled={!hasContent} - aria-label='Delete secret' - /> - - {hasContent && Delete secret} - + {hasContent ? ( + copyName(envVar.key)} + onDelete={() => { + onUpdate(index, 'key', '') + onUpdate(index, 'value', '') + }} + /> + ) : ( +
+ )} {keyError && (
- - - removeEnvVar(originalIndex)} - disabled={!hasContent} - aria-label='Delete secret' - /> - - {hasContent && Delete secret} - + {hasContent ? ( + copyName(envVar.key)} + onDelete={() => removeEnvVar(originalIndex)} + /> + ) : ( +
+ )} {keyError && (
- setToolToView(tool)}>Edit - setToolToDelete(tool)} - disabled={deleteToolMutation.isPending} - > - Remove - + + + + + + setToolToView(tool)}> + Edit + + setToolToDelete(tool)} + disabled={deleteToolMutation.isPending} + > + Remove + + +
))} @@ -1021,10 +1043,29 @@ export function WorkflowMcpServers() {

- setSelectedServerId(server.id)}>Details - setServerToDelete(server)} disabled={isDeleting}> - {isDeleting ? 'Deleting...' : 'Delete'} - + + + + + + setSelectedServerId(server.id)}> + Details + + setServerToDelete(server)} + disabled={isDeleting} + > + Delete + + +
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 516ffdab35b..1aad573c91c 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,43 @@ 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 { + // Selector couldn't resolve a tool from the operation alone — leave the + // option visible; runtime enforcement still applies. + } + } + 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])) @@ -238,8 +264,9 @@ export const Dropdown = memo(function Dropdown({ return defaultValue } - if (comboboxOptions.length > 0) { - return comboboxOptions[0].value + const firstSelectable = comboboxOptions.find((opt) => !opt.hidden) + if (firstSelectable) { + return firstSelectable.value } return undefined diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 22bede4363f..91be65dfdb4 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,465 +14,23 @@ 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 ( - - ) - })} -
- )} -
-
-
- )} - {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 ( - - ) - })} -
-
- ) -} - -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()} - /> -
- {ProviderIcon && } -
- -
- {expanded && isProviderAllowed && ( -
- {isDynamic ? ( - - ) : ( - - )} -
- )} -
- ) -} - export function AccessControl() { const params = useParams() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined @@ -492,13 +44,10 @@ export function AccessControl() { 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 +61,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 +82,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 +96,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 +105,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 +125,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 +139,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 ( - - ) - })} -
-
- )} - {filteredToolBlocks.length > 0 && ( -
- - Integrations and Triggers - -
- {filteredToolBlocks.map((block) => { - const BlockIcon = block.icon - const checkboxId = `block-${block.type}` - return ( - - ) - })} -
-
- )} -
-
- )} - {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) => ( - - ))} -
-
- ))} -
- ))} -
-
- - Files - - -
- - Auth modes public file-share links may use - -
- {FILE_SHARE_AUTH_TYPE_OPTIONS.map(({ value, 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 +200,7 @@ export function AccessControl() {
@@ -1953,8 +310,6 @@ export function AccessControl() { }} /> - - {deleteConfirmModal} ) } diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx new file mode 100644 index 00000000000..19ebea7f71c --- /dev/null +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -0,0 +1,1738 @@ +'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 ( + + ) + })} +
+ )} +
+
+
+ )} + {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 ( + + ) + })} +
+
+ ) +} + +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()} + /> +
+ {ProviderIcon && } +
+ +
+ {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 && } +
+ +
+ {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 the block type that exposes it (for denied-count grouping). */ + const toolToBlockType = useMemo(() => { + const map: Record = {} + for (const block of allBlocks) { + for (const toolId of block.tools?.access ?? []) { + map[toolId] = 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] + ) + + const toggleIntegration = useCallback( + (blockType: string) => { + setEditingConfig((prev) => { + const current = prev.allowedIntegrations + if (current === null) { + const allExcept = allBlocks.map((b) => b.type).filter((t) => t !== blockType) + return { ...prev, allowedIntegrations: allExcept } + } + if (current.includes(blockType)) { + const updated = current.filter((t) => t !== blockType) + return { + ...prev, + allowedIntegrations: updated.length === allBlocks.length ? null : updated, + } + } + const updated = [...current, blockType] + return { + ...prev, + allowedIntegrations: updated.length === allBlocks.length ? null : updated, + } + }) + }, + [allBlocks] + ) + + /** 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)) + return { + ...prev, + allowedIntegrations: nextArr.length === allTypes.length ? null : nextArr, + } + }) + }, + [allBlocks] + ) + + 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 counts: Record = {} + for (const toolId of editingConfig.deniedTools) { + const blockType = toolToBlockType[toolId] + if (blockType) counts[blockType] = (counts[blockType] ?? 0) + 1 + } + return counts + }, [editingConfig.deniedTools, toolToBlockType]) + + 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] + ) + + 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, + } + }) + }, []) + + const handleSaveConfig = useCallback(async () => { + try { + await updatePermissionGroup.mutateAsync({ + id: viewingGroup.id, + organizationId, + config: editingConfig, + }) + setViewingGroup((prev) => ({ ...prev, config: editingConfig })) + } catch (error) { + logger.error('Failed to update config', error) + toast.error("Couldn't save changes", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + }, [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 coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type)) + const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type)) + const platformAllVisible = platformFeatures.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' + /> + { + const allAllowed = + editingConfig.allowedModelProviders === null || + allProviderIds.every((id) => + editingConfig.allowedModelProviders?.includes(id) + ) + setEditingConfig((prev) => ({ + ...prev, + allowedModelProviders: allAllowed ? [] : null, + })) + }} + > + {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} + 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 ( + + ) + })} +
+
+ )} + {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( + platformFeatures.map((f) => [f.configKey, platformAllVisible]) + ), + })) + } + > + {platformAllVisible ? 'Deselect All' : 'Select All'} + +
+
+ {platformCategoryColumns.map((column, columnIndex) => ( +
+ {column.map(({ category, features }) => ( +
+ + {category} + +
+ {features.map((feature) => ( +
+ + {feature.hint} +
+ ))} +
+
+ ))} +
+ ))} +
+
+ Files + +
+ + Auth modes public file-share links may use + +
+ {FILE_SHARE_AUTH_TYPE_OPTIONS.map(({ value, 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 () => { + await handleSaveConfig() + setShowUnsavedChanges(false) + onBack() + }, + disabled: updatePermissionGroup.isPending, + }} + /> +
+ + ) +} diff --git a/apps/sim/ee/access-control/components/workspace-select.tsx b/apps/sim/ee/access-control/components/workspace-select.tsx new file mode 100644 index 00000000000..f8f2496aa13 --- /dev/null +++ b/apps/sim/ee/access-control/components/workspace-select.tsx @@ -0,0 +1,58 @@ +'use client' + +import { ChipDropdown } from '@/components/emcn' + +interface WorkspaceSelectProps { + workspaceIds: string[] + onChange: (ids: string[]) => void + options: { value: string; label: string }[] + disabled?: boolean + isLoading?: boolean + fullWidth?: boolean + className?: string + /** + * When false, the "All workspaces" reset option is hidden and an empty + * selection reads as a prompt. Non-default groups must target ≥1 workspace. + */ + allowAllWorkspaces?: boolean +} + +/** + * Workspace scope multi-select. With `allowAllWorkspaces` an empty selection + * reads as "All workspaces" (the default group); otherwise it prompts for a + * selection, since non-default groups must target specific workspaces. + */ +export function WorkspaceSelect({ + workspaceIds, + onChange, + options, + disabled = false, + isLoading = false, + fullWidth = false, + className, + allowAllWorkspaces = true, +}: WorkspaceSelectProps) { + return ( + + ) +} diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index d78ba101395..8c24c396706 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -17,6 +17,7 @@ const { allowedIntegrations: null, allowedModelProviders: null, deniedModels: [], + deniedTools: [], hideTraceSpans: false, hideKnowledgeBaseTab: false, hideTablesTab: false, @@ -142,6 +143,7 @@ import { ProviderNotAllowedError, PublicFileSharingNotAllowedError, SkillsNotAllowedError, + ToolNotAllowedError, validateBlockType, validateMcpToolsAllowed, validateModelProvider, @@ -597,6 +599,69 @@ describe('assertPermissionsAllowed', () => { }) }) + it('throws ToolNotAllowedError when the tool is on the denylist', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'slack_canvas', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + }) + + it('allows a tool that is not on the denylist', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + + await assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'slack_message', + }) + }) + + it('allows every tool when the denylist is empty', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: [] } }] + + await assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'slack_canvas', + }) + }) + + it('denies a tool even when its block is allowed by the integration allowlist', async () => { + mockWorkspaceGroups.value = [ + { config: { allowedIntegrations: ['slack'], deniedTools: ['slack_canvas'] } }, + ] + + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + blockType: 'slack', + toolId: 'slack_canvas', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + }) + + it('still enforces the tool denylist for an exempt block type', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + mockGetBlock.mockImplementation((type) => + type === 'slack' ? { hideFromToolbar: true } : undefined + ) + + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + blockType: 'slack', + toolId: 'slack_canvas', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + }) + it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => { mockWorkspaceGroups.value = [{ config: { disableCustomTools: true } }] diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 03c41369ecc..611ea077b45 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -50,6 +50,13 @@ export class IntegrationNotAllowedError extends Error { } } +export class ToolNotAllowedError extends Error { + constructor(toolId: string) { + super(`Tool "${toolId}" is not allowed based on your permission group settings`) + this.name = 'ToolNotAllowedError' + } +} + export class McpToolsNotAllowedError extends Error { constructor() { super('MCP tools are not allowed based on your permission group settings') @@ -566,6 +573,12 @@ interface PermissionAssertion { workspaceId: string | undefined model?: string blockType?: string + /** + * Concrete tool ID being executed (e.g. `slack_canvas`). Checked against the + * group's `deniedTools` denylist so an admin can allow an integration but deny + * specific operations within it. Pass the normalized tool id. + */ + toolId?: string toolKind?: ToolKind ctx?: ExecutionContext } @@ -581,11 +594,11 @@ interface PermissionAssertion { * callsite covers every future config field. */ export async function assertPermissionsAllowed(req: PermissionAssertion): Promise { - const { userId, workspaceId, model, blockType, toolKind, ctx } = req + const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false - if (blockTypeExempt && !model && !toolKind) { + if (blockTypeExempt && !model && !toolKind && !toolId) { return } @@ -634,6 +647,11 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis } } + if (toolId && config?.deniedTools?.includes(toolId)) { + logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId }) + throw new ToolNotAllowedError(toolId) + } + if (toolKind && config) { if (toolKind === 'mcp' && config.disableMcpTools) { logger.warn('MCP tools blocked by permission group', { userId, workspaceId }) diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 7d48ae631ea..a72ca616a09 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -18,6 +18,7 @@ import { ChipModalHeader, ChipSelect, ChipSwitch, + ChipTag, Search, toast, } from '@/components/emcn' @@ -767,9 +768,9 @@ export function DataRetentionSettings() { Organization - + Default - +
{orgRowSummary()} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 330e502c78a..3513c4c167d 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -23,6 +23,7 @@ export interface PermissionConfigResult { isBlockAllowed: (blockType: string) => boolean isProviderAllowed: (providerId: string) => boolean isModelAllowed: (model: string) => boolean + isToolAllowed: (toolId: string) => boolean isInvitationsDisabled: boolean isPublicApiDisabled: boolean } @@ -113,6 +114,13 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [config.deniedModels]) + const isToolAllowed = useMemo(() => { + return (toolId: string) => { + if (config.deniedTools.length === 0) return true + return !config.deniedTools.includes(toolId) + } + }, [config.deniedTools]) + const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { if (mergedAllowedIntegrations === null) return blocks @@ -156,6 +164,7 @@ export function usePermissionConfig(): PermissionConfigResult { isBlockAllowed, isProviderAllowed, isModelAllowed, + isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, }), @@ -168,6 +177,7 @@ export function usePermissionConfig(): PermissionConfigResult { isBlockAllowed, isProviderAllowed, isModelAllowed, + isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, ] diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index b2f2d7fa7fd..a1e55c3d9ea 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -8,6 +8,7 @@ export const permissionGroupFullConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable(), allowedModelProviders: z.array(z.string()).nullable(), deniedModels: z.array(z.string()).default([]), + deniedTools: z.array(z.string()).default([]), hideTraceSpans: z.boolean(), hideKnowledgeBaseTab: z.boolean(), hideTablesTab: z.boolean(), diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 3269ec69459..76c18c0b9b1 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -21,6 +21,7 @@ export const permissionGroupConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable().optional(), allowedModelProviders: z.array(z.string()).nullable().optional(), deniedModels: z.array(z.string()).optional(), + deniedTools: z.array(z.string()).optional(), hideTraceSpans: z.boolean().optional(), hideKnowledgeBaseTab: z.boolean().optional(), hideTablesTab: z.boolean().optional(), @@ -52,6 +53,13 @@ export interface PermissionGroupConfig { * group, checked after `allowedModelProviders`. Empty means nothing is blocked. */ deniedModels: string[] + /** + * Snake_case tool IDs (e.g. `slack_canvas`) blocked for this group, checked + * after the block-level `allowedIntegrations` gate. Lets an admin allow an + * integration but deny specific operations within it. Empty means nothing is + * blocked. + */ + deniedTools: string[] hideTraceSpans: boolean hideKnowledgeBaseTab: boolean hideTablesTab: boolean @@ -80,6 +88,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = { allowedIntegrations: null, allowedModelProviders: null, deniedModels: [], + deniedTools: [], hideTraceSpans: false, hideKnowledgeBaseTab: false, hideTablesTab: false, @@ -116,6 +125,9 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf deniedModels: Array.isArray(c.deniedModels) ? c.deniedModels.filter((m): m is string => typeof m === 'string') : [], + deniedTools: Array.isArray(c.deniedTools) + ? c.deniedTools.filter((t): t is string => typeof t === 'string') + : [], hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false, hideKnowledgeBaseTab: typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1f7e554c324..7f3cdc1b56e 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -936,10 +936,15 @@ export async function executeTool( ? 'mcp' : undefined - if (toolKind && scope.userId && scope.workspaceId) { + // Single chokepoint for every tool execution. Asserts both the tool-kind + // gates (mcp/custom/skill) and the per-tool denylist (`deniedTools`), so an + // admin can allow an integration block yet deny specific operations within + // it. Runs for all tools, not just kinded ones. + if (scope.userId && scope.workspaceId) { await assertPermissionsAllowed({ userId: scope.userId, workspaceId: scope.workspaceId, + toolId: normalizedToolId, toolKind, ctx: executionContext, }) From 166e764be940cc834161d2455e3636a57f093e19 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:04:24 -0700 Subject: [PATCH 2/8] fix(access-control): don't leave the detail view when an unsaved-changes save fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../ee/access-control/components/group-detail.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 19ebea7f71c..1ccb732d278 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1065,7 +1065,8 @@ export function GroupDetail({ }) }, []) - const handleSaveConfig = useCallback(async () => { + /** 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, @@ -1073,11 +1074,13 @@ export function GroupDetail({ 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]) @@ -1725,9 +1728,13 @@ export function GroupDetail({ primaryAction={{ label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes', onClick: async () => { - await handleSaveConfig() - setShowUnsavedChanges(false) - onBack() + // Only leave once the save actually succeeds; a failed save keeps + // the dialog open with the edits intact (error surfaced via toast). + const saved = await handleSaveConfig() + if (saved) { + setShowUnsavedChanges(false) + onBack() + } }, disabled: updatePermissionGroup.isPending, }} From b161dd35bccb41c34462372869b003020f677b59 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:08:24 -0700 Subject: [PATCH 3/8] 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. --- .../components/group-detail.tsx | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 1ccb732d278..90134c748f7 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -875,29 +875,48 @@ export function GroupDetail({ [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 blockType = toolToBlockType[toolId] + return !blockType || allowed.has(blockType) + }) + return pruned.length === deniedTools.length ? deniedTools : pruned + }, + [toolToBlockType] + ) + const toggleIntegration = useCallback( (blockType: string) => { setEditingConfig((prev) => { const current = prev.allowedIntegrations + let nextAllowed: string[] | null if (current === null) { - const allExcept = allBlocks.map((b) => b.type).filter((t) => t !== blockType) - return { ...prev, allowedIntegrations: allExcept } - } - if (current.includes(blockType)) { + nextAllowed = allBlocks.map((b) => b.type).filter((t) => t !== blockType) + } else if (current.includes(blockType)) { const updated = current.filter((t) => t !== blockType) - return { - ...prev, - allowedIntegrations: updated.length === allBlocks.length ? null : updated, - } + nextAllowed = updated.length === allBlocks.length ? null : updated + } else { + const updated = [...current, blockType] + nextAllowed = updated.length === allBlocks.length ? null : updated } - const updated = [...current, blockType] return { ...prev, - allowedIntegrations: updated.length === allBlocks.length ? null : updated, + allowedIntegrations: nextAllowed, + deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools), } }) }, - [allBlocks] + [allBlocks, pruneDeniedTools] ) /** Allow or deny a whole section's blocks at once, respecting the active filter. */ @@ -912,13 +931,15 @@ export function GroupDetail({ else current.delete(block.type) } const nextArr = allTypes.filter((t) => current.has(t)) + const nextAllowed = nextArr.length === allTypes.length ? null : nextArr return { ...prev, - allowedIntegrations: nextArr.length === allTypes.length ? null : nextArr, + allowedIntegrations: nextAllowed, + deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools), } }) }, - [allBlocks] + [allBlocks, pruneDeniedTools] ) const isToolAllowed = useCallback( From ae1abfa308c6793df90cb331b2172244d3a52da6 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:16:00 -0700 Subject: [PATCH 4/8] 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. --- .../components/group-detail.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 90134c748f7..d6b9c67b0fd 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -630,12 +630,12 @@ export function GroupDetail({ return allIds.filter((id) => !blacklist.includes(id.toLowerCase())) }, [blacklistedProvidersData]) - /** Maps every tool id to the block type that exposes it (for denied-count grouping). */ - const toolToBlockType = useMemo(() => { - const map: Record = {} + /** 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] = block.type + ;(map[toolId] ??= []).push(block.type) } } return map @@ -887,12 +887,14 @@ export function GroupDetail({ if (allowedIntegrations === null) return deniedTools const allowed = new Set(allowedIntegrations) const pruned = deniedTools.filter((toolId) => { - const blockType = toolToBlockType[toolId] - return !blockType || allowed.has(blockType) + const blockTypes = toolBlockTypes[toolId] + // Keep the denial while ANY block exposing the tool is still allowed; + // preserve tools we can't attribute to a known block. + return !blockTypes || blockTypes.some((bt) => allowed.has(bt)) }) return pruned.length === deniedTools.length ? deniedTools : pruned }, - [toolToBlockType] + [toolBlockTypes] ) const toggleIntegration = useCallback( @@ -973,13 +975,17 @@ export function GroupDetail({ }, []) const deniedCountByBlock = useMemo(() => { + const denied = new Set(editingConfig.deniedTools) const counts: Record = {} - for (const toolId of editingConfig.deniedTools) { - const blockType = toolToBlockType[toolId] - if (blockType) counts[blockType] = (counts[blockType] ?? 0) + 1 + 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, toolToBlockType]) + }, [editingConfig.deniedTools, allBlocks]) const isProviderAllowed = useCallback( (providerId: string) => From a0fae05a63473283d2bd881ba3167e5862c7fe73 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:24:49 -0700 Subject: [PATCH 5/8] 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. --- apps/sim/ee/access-control/components/group-detail.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index d6b9c67b0fd..80dc73ef0d8 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1255,7 +1255,7 @@ export function GroupDetail({ const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type)) const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type)) - const platformAllVisible = platformFeatures.every((f) => !editingConfig[f.configKey]) + const platformAllVisible = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey]) return ( <> @@ -1580,7 +1580,7 @@ export function GroupDetail({ setEditingConfig((prev) => ({ ...prev, ...Object.fromEntries( - platformFeatures.map((f) => [f.configKey, platformAllVisible]) + filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible]) ), })) } From 58e8895d5ed3bed7629475adf61dbf682d4db781 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:32:54 -0700 Subject: [PATCH 6/8] 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. --- .../components/group-detail.tsx | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 80dc73ef0d8..de05b670185 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1019,6 +1019,28 @@ export function GroupDetail({ [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() @@ -1253,6 +1275,7 @@ export function GroupDetail({ [] ) + 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]) @@ -1431,22 +1454,11 @@ export function GroupDetail({ className='min-w-0 flex-1' /> { - const allAllowed = - editingConfig.allowedModelProviders === null || - allProviderIds.every((id) => - editingConfig.allowedModelProviders?.includes(id) - ) - setEditingConfig((prev) => ({ - ...prev, - allowedModelProviders: allAllowed ? [] : null, - })) - }} + onClick={() => + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed) + } > - {editingConfig.allowedModelProviders === null || - allProviderIds.every((id) => editingConfig.allowedModelProviders?.includes(id)) - ? 'Deselect All' - : 'Select All'} + {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
From 887e0097227440580037b9945db3d9b64b935a42 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:44:14 -0700 Subject: [PATCH 7/8] 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). --- .../sub-block/components/dropdown/dropdown.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 1aad573c91c..0e212c45609 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 @@ -260,17 +260,19 @@ export const Dropdown = memo(function Dropdown({ const defaultOptionValue = useMemo(() => { if (multiSelect) return undefined - if (defaultValue !== undefined) { - return defaultValue - } const firstSelectable = comboboxOptions.find((opt) => !opt.hidden) - if (firstSelectable) { - return firstSelectable.value + if (defaultValue !== undefined) { + // Never seed a permission-denied operation as the default; fall back to the + // first allowed option so a new block doesn't start on a disallowed value. + if (deniedOperationIds.has(defaultValue)) { + return firstSelectable?.value + } + return defaultValue } - return undefined - }, [defaultValue, comboboxOptions, multiSelect]) + return firstSelectable?.value + }, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect]) useEffect(() => { if (multiSelect || defaultOptionValue === undefined) { From f9627ede6a34937e44c3b4b4a1430aaefa9fd976 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 25 Jun 2026 15:54:26 -0700 Subject: [PATCH 8/8] 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. --- .../components/dropdown/dropdown.tsx | 6 ++---- .../components/access-control.tsx | 10 ++++++---- .../components/group-detail.tsx | 20 +++++++++---------- apps/sim/tools/index.ts | 6 ++---- 4 files changed, 20 insertions(+), 22 deletions(-) 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 0e212c45609..edf87395914 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -233,8 +233,7 @@ export const Dropdown = memo(function Dropdown({ const toolId = selectTool({ operation: optionId }) if (toolId && !isToolAllowed(toolId)) denied.add(optionId) } catch { - // Selector couldn't resolve a tool from the operation alone — leave the - // option visible; runtime enforcement still applies. + // Unresolvable from the operation alone — leave it visible; the server still enforces. } } return denied @@ -263,8 +262,7 @@ export const Dropdown = memo(function Dropdown({ const firstSelectable = comboboxOptions.find((opt) => !opt.hidden) if (defaultValue !== undefined) { - // Never seed a permission-denied operation as the default; fall back to the - // first allowed option so a new block doesn't start on a disallowed value. + // Don't seed a denied operation as the default; use the first allowed option. if (deniedOperationIds.has(defaultValue)) { return firstSelectable?.value } diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 91be65dfdb4..6ac886fec28 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -35,10 +35,12 @@ 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 diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index de05b670185..5477c7a0fa9 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -577,9 +577,11 @@ export function GroupDetail({ 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. + /** + * 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) @@ -589,9 +591,11 @@ export function GroupDetail({ 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. + /** + * 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') @@ -888,8 +892,6 @@ export function GroupDetail({ const allowed = new Set(allowedIntegrations) const pruned = deniedTools.filter((toolId) => { const blockTypes = toolBlockTypes[toolId] - // Keep the denial while ANY block exposing the tool is still allowed; - // preserve tools we can't attribute to a known block. return !blockTypes || blockTypes.some((bt) => allowed.has(bt)) }) return pruned.length === deniedTools.length ? deniedTools : pruned @@ -1767,8 +1769,6 @@ export function GroupDetail({ primaryAction={{ label: updatePermissionGroup.isPending ? 'Saving...' : 'Save Changes', onClick: async () => { - // Only leave once the save actually succeeds; a failed save keeps - // the dialog open with the edits intact (error surfaced via toast). const saved = await handleSaveConfig() if (saved) { setShowUnsavedChanges(false) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 7f3cdc1b56e..2ee548a4324 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -936,10 +936,8 @@ export async function executeTool( ? 'mcp' : undefined - // Single chokepoint for every tool execution. Asserts both the tool-kind - // gates (mcp/custom/skill) and the per-tool denylist (`deniedTools`), so an - // admin can allow an integration block yet deny specific operations within - // it. Runs for all tools, not just kinded ones. + // 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,