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..edf87395914 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -14,6 +14,7 @@ import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' +import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -98,6 +99,7 @@ export const Dropdown = memo(function Dropdown({ searchable = false, }: DropdownProps) { const activeSearchTarget = useActiveSearchTarget() + const { isToolAllowed } = usePermissionConfig() const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) as [ string | string[] | null | undefined, (value: string | string[]) => void, @@ -214,19 +216,42 @@ export const Dropdown = memo(function Dropdown({ return opts }, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption]) + /** + * Operation IDs whose resolved tool is denied by the caller's permission + * group. Only the `operation` selector of a block with a tool selector is + * gated. Denied operations are hidden from the picker (still resolvable for + * label display); the server is the authoritative gate regardless. + */ + const deniedOperationIds = useMemo(() => { + const denied = new Set() + if (subBlockId !== 'operation') return denied + const selectTool = blockConfig?.tools?.config?.tool + if (!selectTool) return denied + for (const opt of allOptions) { + const optionId = typeof opt === 'string' ? opt : opt.id + try { + const toolId = selectTool({ operation: optionId }) + if (toolId && !isToolAllowed(toolId)) denied.add(optionId) + } catch { + // Unresolvable from the operation alone — leave it visible; the server still enforces. + } + } + return denied + }, [subBlockId, blockConfig, allOptions, isToolAllowed]) + const comboboxOptions = useMemo((): ComboboxOption[] => { return allOptions.map((opt) => { if (typeof opt === 'string') { - return { label: opt.toLowerCase(), value: opt } + return { label: opt.toLowerCase(), value: opt, hidden: deniedOperationIds.has(opt) } } return { label: opt.label.toLowerCase(), value: opt.id, icon: 'icon' in opt ? opt.icon : undefined, - hidden: opt.hidden, + hidden: opt.hidden || deniedOperationIds.has(opt.id), } }) - }, [allOptions]) + }, [allOptions, deniedOperationIds]) const optionMap = useMemo(() => { return new Map(comboboxOptions.map((opt) => [opt.value, opt.label])) @@ -234,16 +259,18 @@ export const Dropdown = memo(function Dropdown({ const defaultOptionValue = useMemo(() => { if (multiSelect) return undefined + + const firstSelectable = comboboxOptions.find((opt) => !opt.hidden) if (defaultValue !== undefined) { + // Don't seed a denied operation as the default; use the first allowed option. + if (deniedOperationIds.has(defaultValue)) { + return firstSelectable?.value + } return defaultValue } - if (comboboxOptions.length > 0) { - return comboboxOptions[0].value - } - - return undefined - }, [defaultValue, comboboxOptions, multiSelect]) + return firstSelectable?.value + }, [defaultValue, comboboxOptions, deniedOperationIds, multiSelect]) useEffect(() => { if (multiSelect || defaultOptionValue === undefined) { diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index 22bede4363f..6ac886fec28 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -1,18 +1,12 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { ArrowRight, ChevronDown, Plus } from 'lucide-react' +import { ArrowRight, Plus } from 'lucide-react' import { useParams } from 'next/navigation' import { - Avatar, - AvatarFallback, - AvatarImage, Checkbox, Chip, - ChipConfirmModal, - ChipDropdown, ChipInput, ChipModal, ChipModalBody, @@ -20,485 +14,42 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - ChipModalTabs, - chipVariants, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + ChipTag, Label, - MoreHorizontal, Search, - Skeleton, - Switch, - toast, } from '@/components/emcn' -import { ArrowLeft } from '@/components/emcn/icons' -import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { getEnv, isTruthy } from '@/lib/core/config/env' -import { cn } from '@/lib/core/utils/cn' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import type { PermissionGroupConfig } from '@/lib/permission-groups/types' -import { getUserColor } from '@/lib/workspaces/colors' -import { MemberRow } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -import { getAllBlocks } from '@/blocks' +import { GroupDetail } from '@/ee/access-control/components/group-detail' +import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' import { - type PermissionGroup, - useBulkAddPermissionGroupMembers, useCreatePermissionGroup, - useDeletePermissionGroup, useOrganizationWorkspaces, - usePermissionGroupMembers, usePermissionGroups, - useRemovePermissionGroupMember, - useUpdatePermissionGroup, useUserPermissionConfig, } from '@/ee/access-control/hooks/permission-groups' -import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' -import { useOrganizationRoster } from '@/hooks/queries/organization' -import { useProviderModels } from '@/hooks/queries/providers' -import { - DYNAMIC_MODEL_PROVIDERS, - getProviderModels, - PROVIDER_DEFINITIONS, -} from '@/providers/models' -import type { ProviderId } from '@/providers/types' -import { getAllProviderIds, getProviderFromModel } from '@/providers/utils' -import type { ProviderName } from '@/stores/providers' const logger = createLogger('AccessControl') -/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */ -const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [ - { value: 'public', label: 'Anyone with link' }, - { value: 'password', label: 'Password' }, - { value: 'email', label: 'Email' }, - { value: 'sso', label: 'SSO' }, -] -const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value) - -interface OrganizationMemberOption { - userId: string - user: { - name: string | null - email: string - image?: string | null - } -} - -interface AddMembersModalProps { - open: boolean - onOpenChange: (open: boolean) => void - availableMembers: OrganizationMemberOption[] - selectedMemberIds: Set - setSelectedMemberIds: React.Dispatch>> - onAddMembers: () => void - isAdding: boolean - errorMessage: string | null -} - -function AddMembersModal({ - open, - onOpenChange, - availableMembers, - selectedMemberIds, - setSelectedMemberIds, - onAddMembers, - isAdding, - errorMessage, -}: AddMembersModalProps) { - const [searchTerm, setSearchTerm] = useState('') - - const filteredMembers = useMemo(() => { - if (!searchTerm.trim()) return availableMembers - const query = searchTerm.toLowerCase() - return availableMembers.filter((m) => { - const name = m.user?.name || '' - const email = m.user?.email || '' - return name.toLowerCase().includes(query) || email.toLowerCase().includes(query) - }) - }, [availableMembers, searchTerm]) - - const allFilteredSelected = useMemo(() => { - if (filteredMembers.length === 0) return false - return filteredMembers.every((m) => selectedMemberIds.has(m.userId)) - }, [filteredMembers, selectedMemberIds]) - - const handleToggleAll = () => { - if (allFilteredSelected) { - const filteredIds = new Set(filteredMembers.map((m) => m.userId)) - setSelectedMemberIds((prev) => { - const next = new Set(prev) - filteredIds.forEach((id) => next.delete(id)) - return next - }) - } else { - setSelectedMemberIds((prev) => { - const next = new Set(prev) - filteredMembers.forEach((m) => next.add(m.userId)) - return next - }) - } - } - - const handleToggleMember = (userId: string) => { - setSelectedMemberIds((prev) => { - const next = new Set(prev) - if (next.has(userId)) { - next.delete(userId) - } else { - next.add(userId) - } - return next - }) - } - - return ( - { - if (!o) setSearchTerm('') - onOpenChange(o) - }} - size='sm' - srTitle='Add Members' - > - onOpenChange(false)}>Add Members - - {availableMembers.length === 0 ? ( -

- All organization members are already in this group. -

- ) : ( - -
-
- setSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> - - {allFilteredSelected ? 'Deselect All' : 'Select All'} - -
- -
- {filteredMembers.length === 0 ? ( -

- No members found matching "{searchTerm}" -

- ) : ( -
- {filteredMembers.map((member) => { - const name = member.user?.name || 'Unknown' - const email = member.user?.email || '' - const avatarInitial = name.charAt(0).toUpperCase() - const isSelected = selectedMemberIds.has(member.userId) - - return ( - - ) - })} -
- )} -
-
-
- )} - {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 - // Access control is governed by the workspace's OWNING organization, which may - // differ from the caller's active org (e.g. external members). Resolve the org - // id and the caller's admin status server-side from the workspace so gating is - // never keyed off the session's active org. + /** + * Access control is governed by the workspace's OWNING organization, which may + * differ from the caller's active org (e.g. external members). Resolve the org + * id and the caller's admin status server-side from the workspace so gating is + * never keyed off the session's active org. + */ const { data: userPermissionConfig, isPending: entitlementLoading } = useUserPermissionConfig(workspaceId) const organizationId = userPermissionConfig?.organizationId ?? undefined const currentUserIsOrgAdmin = userPermissionConfig?.isOrgAdmin ?? false - // Group + roster reads require org admin/owner on the host org; only fetch them - // for admins to avoid surfacing expected 403s for non-admins/external members. const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups( organizationId, !!organizationId && currentUserIsOrgAdmin ) - const { data: roster } = useOrganizationRoster(currentUserIsOrgAdmin ? organizationId : undefined) const { data: organizationWorkspaces = [], isPending: workspacesLoading } = useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin) @@ -512,277 +63,15 @@ export function AccessControl() { (!!organizationId && currentUserIsOrgAdmin && groupsLoading) const createPermissionGroup = useCreatePermissionGroup() - const updatePermissionGroup = useUpdatePermissionGroup() - const deletePermissionGroup = useDeletePermissionGroup() - const bulkAddMembers = useBulkAddPermissionGroupMembers() const [searchTerm, setSearchTerm] = useState('') + const [selectedGroupId, setSelectedGroupId] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) - const [viewingGroup, setViewingGroup] = useState(null) - // Monotonic token for scope-affecting writes (workspace select + default - // toggle, which both change the group's workspace scope). Only the most - // recent write may reconcile or revert the local viewingGroup, so rapid - // multi-select toggles can't settle on a stale, out-of-order response. - const scopeWriteSeqRef = useRef(0) const [newGroupName, setNewGroupName] = useState('') const [newGroupDescription, setNewGroupDescription] = useState('') const [newGroupIsDefault, setNewGroupIsDefault] = useState(false) const [newGroupWorkspaceIds, setNewGroupWorkspaceIds] = useState([]) const [createError, setCreateError] = useState(null) - const [deletingGroup, setDeletingGroup] = useState<{ id: string; name: string } | null>(null) - const [deletingGroupIds, setDeletingGroupIds] = useState>(() => new Set()) - - const { data: members = [], isPending: membersLoading } = usePermissionGroupMembers( - organizationId, - viewingGroup?.id - ) - const removeMember = useRemovePermissionGroupMember() - - const [showConfigModal, setShowConfigModal] = useState(false) - const [configTab, setConfigTab] = useState<'members' | 'providers' | 'blocks' | 'platform'>( - 'providers' - ) - const [editingConfig, setEditingConfig] = useState(null) - const [showAddMembersModal, setShowAddMembersModal] = useState(false) - const [addMembersError, setAddMembersError] = useState(null) - const [selectedMemberIds, setSelectedMemberIds] = useState>(() => new Set()) - const [providerSearchTerm, setProviderSearchTerm] = useState('') - const [integrationSearchTerm, setIntegrationSearchTerm] = useState('') - const [platformSearchTerm, setPlatformSearchTerm] = useState('') - const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) - - const platformFeatures = useMemo( - () => [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab' as const, - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab' as const, - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot' as const, - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab' as const, - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab' as const, - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab' as const, - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab' as const, - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi' as const, - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp' as const, - }, - { - id: 'hide-deploy-a2a', - label: 'A2A', - category: 'Deploy Tabs', - configKey: 'hideDeployA2a' as const, - }, - { - id: 'hide-deploy-chatbot', - label: 'Chat', - category: 'Deploy Tabs', - configKey: 'hideDeployChatbot' as const, - }, - { - id: 'hide-deploy-template', - label: 'Template', - category: 'Deploy Tabs', - configKey: 'hideDeployTemplate' as const, - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools' as const, - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools' as const, - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills' as const, - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans' as const, - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations' as const, - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab' as const, - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi' as const, - }, - { - id: 'disable-public-file-sharing', - label: 'Public Sharing', - category: 'Files', - configKey: 'disablePublicFileSharing' as const, - }, - ], - [] - ) - - const filteredPlatformFeatures = useMemo(() => { - if (!platformSearchTerm.trim()) return platformFeatures - const search = platformSearchTerm.toLowerCase() - return platformFeatures.filter( - (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) - ) - }, [platformFeatures, platformSearchTerm]) - - const platformCategories = useMemo(() => { - const categories: Record = {} - for (const feature of filteredPlatformFeatures) { - if (!categories[feature.category]) { - categories[feature.category] = [] - } - categories[feature.category].push(feature) - } - return categories - }, [filteredPlatformFeatures]) - - const platformCategoryColumns = useMemo(() => { - const categoryGroups = [ - ['Sidebar', 'Deploy Tabs', 'Collaboration'], - ['Workflow Panel', 'Tools', 'Features'], - ['Settings Tabs', 'Logs'], - ] - - const assignedCategories = new Set(categoryGroups.flat()) - // Files has its own section below (with the file-sharing auth modes), so it - // stays out of the feature-toggle grid. - const unassigned = Object.keys(platformCategories).filter( - (c) => c !== 'Files' && !assignedCategories.has(c) - ) - const groups = unassigned.length > 0 ? [...categoryGroups, unassigned] : categoryGroups - - return groups - .map((column) => - column - .map((category) => ({ - category, - features: platformCategories[category] ?? [], - })) - .filter((section) => section.features.length > 0) - ) - .filter((column) => column.length > 0) - }, [platformCategories]) - - const hasConfigChanges = useMemo(() => { - if (!viewingGroup || !editingConfig) return false - const original = viewingGroup.config - return JSON.stringify(original) !== JSON.stringify(editingConfig) - }, [viewingGroup, editingConfig]) - - const allBlocks = useMemo(() => { - const blocks = getAllBlocks().filter((b) => !isBlockTypeAccessControlExempt(b.type)) - return blocks.sort((a, b) => { - const categoryOrder = { triggers: 0, blocks: 1, tools: 2 } - const catA = categoryOrder[a.category] ?? 3 - const catB = categoryOrder[b.category] ?? 3 - if (catA !== catB) return catA - catB - return a.name.localeCompare(b.name) - }) - }, []) - const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: showConfigModal }) - - const allProviderIds = useMemo(() => { - const allIds = getAllProviderIds() - const blacklist = blacklistedProvidersData?.blacklistedProviders ?? [] - if (blacklist.length === 0) return allIds - return allIds.filter((id) => !blacklist.includes(id.toLowerCase())) - }, [blacklistedProvidersData]) - - const filteredProviders = useMemo(() => { - if (!providerSearchTerm.trim()) return allProviderIds - const query = providerSearchTerm.toLowerCase() - return allProviderIds.filter((id) => id.toLowerCase().includes(query)) - }, [allProviderIds, providerSearchTerm]) - - const filteredBlocks = useMemo(() => { - if (!integrationSearchTerm.trim()) return allBlocks - const query = integrationSearchTerm.toLowerCase() - return allBlocks.filter((b) => b.name.toLowerCase().includes(query)) - }, [allBlocks, integrationSearchTerm]) - - const filteredCoreBlocks = useMemo(() => { - return filteredBlocks.filter((block) => block.category === 'blocks') - }, [filteredBlocks]) - - const filteredToolBlocks = useMemo(() => { - return filteredBlocks - .filter((block) => block.category === 'tools' || block.category === 'triggers') - .sort((a, b) => a.name.localeCompare(b.name)) - }, [filteredBlocks]) - - const organizationMembers = useMemo(() => { - if (!roster?.members) return [] - return roster.members - .filter((m) => m.role !== 'external') - .map((m) => ({ - userId: m.userId, - user: { - name: m.name, - email: m.email, - image: m.image, - }, - })) - }, [roster]) const workspaceOptions = useMemo( () => organizationWorkspaces.map((ws) => ({ value: ws.id, label: ws.name })), @@ -795,6 +84,11 @@ export function AccessControl() { return permissionGroups.filter((g) => g.name.toLowerCase().includes(searchLower)) }, [permissionGroups, searchTerm]) + const selectedGroup = useMemo( + () => (selectedGroupId ? permissionGroups.find((g) => g.id === selectedGroupId) : undefined), + [permissionGroups, selectedGroupId] + ) + const handleCreatePermissionGroup = useCallback(async () => { if (!newGroupName.trim() || !organizationId) return setCreateError(null) @@ -804,8 +98,6 @@ export function AccessControl() { name: newGroupName.trim(), description: newGroupDescription.trim() || undefined, isDefault: newGroupIsDefault, - // Only the default group is organization-wide; every other group targets - // specific workspaces (omitted for the default group). workspaceIds: newGroupIsDefault ? undefined : newGroupWorkspaceIds, }) setShowCreateModal(false) @@ -815,11 +107,7 @@ export function AccessControl() { setNewGroupWorkspaceIds([]) } catch (error) { logger.error('Failed to create permission group', error) - if (error instanceof Error) { - setCreateError(error.message) - } else { - setCreateError('Failed to create permission group') - } + setCreateError(error instanceof Error ? error.message : 'Failed to create permission group') } }, [ newGroupName, @@ -839,385 +127,6 @@ export function AccessControl() { setCreateError(null) }, []) - const handleBackToList = useCallback(() => { - setViewingGroup(null) - }, []) - - const handleDeleteClick = useCallback((group: PermissionGroup) => { - setDeletingGroup({ id: group.id, name: group.name }) - }, []) - - const confirmDelete = useCallback(async () => { - if (!deletingGroup || !organizationId) return - setDeletingGroupIds((prev) => new Set(prev).add(deletingGroup.id)) - try { - await deletePermissionGroup.mutateAsync({ - permissionGroupId: deletingGroup.id, - organizationId, - }) - setDeletingGroup(null) - if (viewingGroup?.id === deletingGroup.id) { - setViewingGroup(null) - } - } catch (error) { - logger.error('Failed to delete permission group', error) - } finally { - setDeletingGroupIds((prev) => { - const next = new Set(prev) - next.delete(deletingGroup.id) - return next - }) - } - }, [deletingGroup, organizationId, deletePermissionGroup, viewingGroup?.id]) - - const handleRemoveMember = useCallback( - async (memberId: string) => { - if (!viewingGroup || !organizationId) return - try { - await removeMember.mutateAsync({ - organizationId, - permissionGroupId: viewingGroup.id, - memberId, - }) - } catch (error) { - logger.error('Failed to remove member', error) - toast.error("Couldn't remove member", { - description: getErrorMessage(error, 'Please try again in a moment.'), - }) - } - }, - [viewingGroup, organizationId, removeMember] - ) - - const handleOpenConfigModal = useCallback(() => { - if (!viewingGroup) return - setEditingConfig({ ...viewingGroup.config }) - setConfigTab('providers') - setShowConfigModal(true) - }, [viewingGroup]) - - const handleSaveConfig = useCallback(async () => { - if (!viewingGroup || !editingConfig || !organizationId) return - try { - await updatePermissionGroup.mutateAsync({ - id: viewingGroup.id, - organizationId, - config: editingConfig, - }) - setShowConfigModal(false) - setEditingConfig(null) - setProviderSearchTerm('') - setIntegrationSearchTerm('') - setPlatformSearchTerm('') - setViewingGroup((prev) => (prev ? { ...prev, config: editingConfig } : null)) - } catch (error) { - logger.error('Failed to update config', error) - } - }, [viewingGroup, editingConfig, organizationId, updatePermissionGroup]) - - const handleCloseConfigModal = useCallback(() => { - if (hasConfigChanges) { - setShowUnsavedChanges(true) - } else { - setShowConfigModal(false) - setProviderSearchTerm('') - setIntegrationSearchTerm('') - setPlatformSearchTerm('') - } - }, [hasConfigChanges]) - - const handleDiscardConfig = useCallback(() => { - setShowUnsavedChanges(false) - setShowConfigModal(false) - setEditingConfig(null) - setProviderSearchTerm('') - setIntegrationSearchTerm('') - setPlatformSearchTerm('') - }, []) - - const handleSaveConfigFromUnsaved = useCallback(() => { - setShowUnsavedChanges(false) - handleSaveConfig() - }, [handleSaveConfig]) - - const handleOpenAddMembersModal = useCallback(() => { - setSelectedMemberIds(new Set()) - setAddMembersError(null) - setShowAddMembersModal(true) - }, []) - - const handleAddSelectedMembers = useCallback(async () => { - if (!viewingGroup || !organizationId || selectedMemberIds.size === 0) return - setAddMembersError(null) - try { - // Bulk add is all-or-nothing for conflicts: a conflicting selection - // returns a 409 (no one is added) and the named error is shown inline so - // the admin can adjust the selection. - await bulkAddMembers.mutateAsync({ - organizationId, - permissionGroupId: viewingGroup.id, - userIds: Array.from(selectedMemberIds), - }) - setShowAddMembersModal(false) - setSelectedMemberIds(new Set()) - } catch (error) { - logger.error('Failed to add members', error) - setAddMembersError(getErrorMessage(error, 'Failed to add members')) - } - }, [viewingGroup, organizationId, selectedMemberIds, bulkAddMembers]) - - const handleScopeChange = useCallback( - async (workspaceIds: string[]) => { - if (!viewingGroup || !organizationId) return - // Zero workspaces is allowed: the group then governs nothing (the resolver - // inner-joins on the workspace link table, so an empty group never matches - // any workspace). Re-add a workspace to make it active again. - const previous = viewingGroup - const seq = ++scopeWriteSeqRef.current - - setViewingGroup((prev) => - prev - ? { - ...prev, - workspaces: organizationWorkspaces.filter((ws) => workspaceIds.includes(ws.id)), - } - : null - ) - try { - const result = await updatePermissionGroup.mutateAsync({ - id: viewingGroup.id, - organizationId, - workspaceIds, - }) - - if (seq !== scopeWriteSeqRef.current) return - setViewingGroup((prev) => - prev - ? { - ...prev, - workspaces: organizationWorkspaces.filter((ws) => - result.permissionGroup.workspaceIds.includes(ws.id) - ), - } - : null - ) - } catch (error) { - logger.error('Failed to update workspace scope', error) - // Only the latest write may revert, so a failed earlier request can't - // clobber a newer (successful) selection. - if (seq !== scopeWriteSeqRef.current) return - setViewingGroup(previous) - toast.error("Couldn't update workspaces", { - description: getErrorMessage(error, 'Please try again in a moment.'), - }) - } - }, - [viewingGroup, organizationId, organizationWorkspaces, updatePermissionGroup] - ) - - const handleToggleDefault = useCallback( - async (enabled: boolean) => { - if (!viewingGroup || !organizationId) return - const seq = ++scopeWriteSeqRef.current - try { - // Promoting forces all-workspaces; demoting leaves the group non-default - // with no workspaces (inert) until it is re-scoped from the selector — the - // route handles this from `isDefault: false` alone, so no workspace list - // (bounded by the per-group cap) is sent. - const result = await updatePermissionGroup.mutateAsync({ - id: viewingGroup.id, - organizationId, - isDefault: enabled, - }) - - if (seq !== scopeWriteSeqRef.current) return - setViewingGroup((prev) => - prev - ? { - ...prev, - isDefault: result.permissionGroup.isDefault, - workspaces: result.permissionGroup.isDefault - ? [] - : organizationWorkspaces.filter((ws) => - result.permissionGroup.workspaceIds.includes(ws.id) - ), - } - : null - ) - } catch (error) { - logger.error('Failed to toggle default group', error) - toast.error("Couldn't update the default group", { - description: getErrorMessage(error, 'Please try again in a moment.'), - }) - } - }, - [viewingGroup, organizationId, organizationWorkspaces, updatePermissionGroup] - ) - - const toggleIntegration = useCallback( - (blockType: string) => { - if (!editingConfig) return - const current = editingConfig.allowedIntegrations - if (current === null) { - const allExcept = allBlocks.map((b) => b.type).filter((t) => t !== blockType) - setEditingConfig({ ...editingConfig, allowedIntegrations: allExcept }) - } else if (current.includes(blockType)) { - const updated = current.filter((t) => t !== blockType) - setEditingConfig({ - ...editingConfig, - allowedIntegrations: updated.length === allBlocks.length ? null : updated, - }) - } else { - const updated = [...current, blockType] - setEditingConfig({ - ...editingConfig, - allowedIntegrations: updated.length === allBlocks.length ? null : updated, - }) - } - }, - [editingConfig, allBlocks] - ) - - const toggleProvider = useCallback( - (providerId: string) => { - if (!editingConfig) return - const current = editingConfig.allowedModelProviders - if (current === null) { - const allExcept = allProviderIds.filter((p) => p !== providerId) - setEditingConfig({ ...editingConfig, allowedModelProviders: allExcept }) - } else if (current.includes(providerId)) { - const updated = current.filter((p) => p !== providerId) - setEditingConfig({ - ...editingConfig, - allowedModelProviders: updated.length === allProviderIds.length ? null : updated, - }) - } else { - const updated = [...current, providerId] - setEditingConfig({ - ...editingConfig, - allowedModelProviders: updated.length === allProviderIds.length ? null : updated, - }) - } - }, - [editingConfig, allProviderIds] - ) - - const isFileShareAuthAllowed = useCallback( - (authType: ShareAuthType) => { - if (!editingConfig) return true - return ( - editingConfig.allowedFileShareAuthTypes === null || - editingConfig.allowedFileShareAuthTypes.includes(authType) - ) - }, - [editingConfig] - ) - - const toggleFileShareAuthType = useCallback( - (authType: ShareAuthType) => { - if (!editingConfig) return - const current = editingConfig.allowedFileShareAuthTypes - const next = - current === null - ? ALL_FILE_SHARE_AUTH_TYPES.filter((t) => t !== authType) - : current.includes(authType) - ? current.filter((t) => t !== authType) - : [...current, authType] - // A full list collapses back to `null` ("all allowed"). - setEditingConfig({ - ...editingConfig, - allowedFileShareAuthTypes: next.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : next, - }) - }, - [editingConfig] - ) - - const isIntegrationAllowed = useCallback( - (blockType: string) => { - if (!editingConfig) return true - return ( - editingConfig.allowedIntegrations === null || - editingConfig.allowedIntegrations.includes(blockType) - ) - }, - [editingConfig] - ) - - const isProviderAllowed = useCallback( - (providerId: string) => { - if (!editingConfig) return true - return ( - editingConfig.allowedModelProviders === null || - editingConfig.allowedModelProviders.includes(providerId) - ) - }, - [editingConfig] - ) - - const isModelAllowed = useCallback( - (model: string) => { - if (!editingConfig) return true - const normalized = model.toLowerCase() - return !editingConfig.deniedModels.some((denied) => denied.toLowerCase() === normalized) - }, - [editingConfig] - ) - - const toggleModel = useCallback( - (model: string) => { - if (!editingConfig) return - const normalized = model.toLowerCase() - const isDenied = editingConfig.deniedModels.some( - (denied) => denied.toLowerCase() === normalized - ) - const deniedModels = isDenied - ? editingConfig.deniedModels.filter((denied) => denied.toLowerCase() !== normalized) - : [...editingConfig.deniedModels, model] - setEditingConfig({ ...editingConfig, deniedModels }) - }, - [editingConfig] - ) - - const setModelsDenied = useCallback( - (models: string[], denied: boolean) => { - if (!editingConfig) return - if (denied) { - const existing = new Set(editingConfig.deniedModels.map((m) => m.toLowerCase())) - const additions = models.filter((m) => !existing.has(m.toLowerCase())) - if (additions.length === 0) return - setEditingConfig({ - ...editingConfig, - deniedModels: [...editingConfig.deniedModels, ...additions], - }) - } else { - const toRemove = new Set(models.map((m) => m.toLowerCase())) - setEditingConfig({ - ...editingConfig, - deniedModels: editingConfig.deniedModels.filter((m) => !toRemove.has(m.toLowerCase())), - }) - } - }, - [editingConfig] - ) - - const deniedCountByProvider = useMemo(() => { - const counts: Record = {} - for (const model of editingConfig?.deniedModels ?? []) { - try { - const providerId = getProviderFromModel(model) - counts[providerId] = (counts[providerId] ?? 0) + 1 - } catch { - // Unknown/blacklisted provider — omit from counts. - } - } - return counts - }, [editingConfig?.deniedModels]) - - const availableMembersToAdd = useMemo(() => { - const existingMemberUserIds = new Set(members.map((m) => m.userId)) - return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId)) - }, [organizationMembers, members]) - if (isLoading) { return null } @@ -1232,566 +141,18 @@ export function AccessControl() { ) } - const deleteConfirmModal = ( - setDeletingGroup(null)} - srTitle='Delete Permission Group' - title='Delete Permission Group' - text={[ - 'Are you sure you want to delete ', - { text: deletingGroup?.name ?? 'this group', bold: true }, - '? ', - { text: 'All members will be removed from this group.', error: true }, - ' This action cannot be undone.', - ]} - confirm={{ - label: 'Delete', - onClick: confirmDelete, - pending: deletePermissionGroup.isPending, - pendingLabel: 'Deleting...', - }} - /> - ) - - if (viewingGroup) { + if (selectedGroup && organizationId) { return ( - <> -
-
- - Access Control - -
- handleDeleteClick(viewingGroup)} - disabled={deletingGroupIds.has(viewingGroup.id)} - > - {deletingGroupIds.has(viewingGroup.id) ? 'Deleting...' : 'Delete'} - - Configure -
-
- -
-
-
-

{viewingGroup.name}

- {viewingGroup.description && ( -

{viewingGroup.description}

- )} - {!viewingGroup.isDefault && !membersLoading && ( -

- {viewingGroup.workspaces.length === 0 - ? 'Applies to no one yet — add workspaces below to choose who this group governs.' - : members.length === 0 - ? 'Applies to all members of its workspaces.' - : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}.`} -

- )} -
- - -
- - Applies to everyone in the organization not assigned to another group, including - external workspace members - - handleToggleDefault(checked)} - disabled={updatePermissionGroup.isPending} - /> -
-
- - - {viewingGroup.isDefault ? ( -
- - Governs every workspace in the organization - -
- ) : ( -
-
- - {viewingGroup.workspaces.length > 0 - ? `Governs ${viewingGroup.workspaces.length} workspace${ - viewingGroup.workspaces.length === 1 ? '' : 's' - }` - : 'Select the workspaces this group governs'} - - ws.id)} - onChange={handleScopeChange} - options={workspaceOptions} - isLoading={workspacesLoading} - allowAllWorkspaces={false} - className='flex-shrink-0' - /> -
- {viewingGroup.workspaces.length > 0 && ( -
- {viewingGroup.workspaces.map((ws) => ( - - ))} -
- )} -
- )} -
-
-
-
- - { - if (!open && hasConfigChanges) { - setShowUnsavedChanges(true) - } else { - setShowConfigModal(open) - if (!open) { - setProviderSearchTerm('') - setIntegrationSearchTerm('') - setPlatformSearchTerm('') - } - } - }} - srTitle='Configure Permissions' - size='xl' - className='h-[84vh]' - > - { - if (hasConfigChanges) { - setShowUnsavedChanges(true) - } else { - setShowConfigModal(false) - setProviderSearchTerm('') - setIntegrationSearchTerm('') - setPlatformSearchTerm('') - } - }} - > - Configure Permissions - - - - setConfigTab(value as 'members' | 'providers' | 'blocks' | 'platform') - } - /> - {configTab === 'members' && !viewingGroup.isDefault && ( -
-
- - {members.length === 0 - ? 'Applies to all members' - : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`} - - - Add - -
- {membersLoading ? ( -
- {[1, 2].map((i) => ( -
- - -
- ))} -
- ) : members.length === 0 ? ( -
- - This group applies to everyone in its workspaces, including external members. - Add members to restrict it to specific people. - -
- ) : ( -
- {members.map((member) => ( - - - - - - handleRemoveMember(member.id)} - > - Remove - - - - } - /> - ))} -
- )} -
- )} - {configTab === 'providers' && ( -
-
- setProviderSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> - { - const allAllowed = - editingConfig?.allowedModelProviders === null || - allProviderIds.every((id) => - editingConfig?.allowedModelProviders?.includes(id) - ) - setEditingConfig((prev) => - prev ? { ...prev, allowedModelProviders: allAllowed ? [] : null } : prev - ) - }} - > - {editingConfig?.allowedModelProviders === null || - allProviderIds.every((id) => editingConfig?.allowedModelProviders?.includes(id)) - ? 'Deselect All' - : 'Select All'} - -
-
- {filteredProviders.map((providerId) => ( - toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} - workspaceId={workspaceId} - isModelAllowed={isModelAllowed} - onToggleModel={toggleModel} - onSetModelsDenied={setModelsDenied} - /> - ))} -
-
- )} - {configTab === 'blocks' && ( -
-
- setIntegrationSearchTerm(e.target.value)} - className='min-w-0 flex-1' - /> - { - const allAllowed = - editingConfig?.allowedIntegrations === null || - allBlocks.every((b) => editingConfig?.allowedIntegrations?.includes(b.type)) - setEditingConfig((prev) => - prev - ? { - ...prev, - allowedIntegrations: allAllowed ? ['start_trigger'] : null, - } - : prev - ) - }} - > - {editingConfig?.allowedIntegrations === null || - allBlocks.every((b) => editingConfig?.allowedIntegrations?.includes(b.type)) - ? 'Deselect All' - : 'Select All'} - -
-
- {filteredCoreBlocks.length > 0 && ( -
- - Core Blocks - -
- {filteredCoreBlocks.map((block) => { - const BlockIcon = block.icon - const checkboxId = `block-${block.type}` - return ( - - ) - })} -
-
- )} - {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 +202,7 @@ export function AccessControl() {
@@ -1953,8 +312,6 @@ export function AccessControl() { }} /> - - {deleteConfirmModal} ) } diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx new file mode 100644 index 00000000000..5477c7a0fa9 --- /dev/null +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -0,0 +1,1784 @@ +'use client' + +import { useCallback, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { ChevronDown, Plus } from 'lucide-react' +import { + Checkbox, + Chip, + ChipConfirmModal, + ChipInput, + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + ChipModalTabs, + ChipTag, + chipVariants, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Info, + MoreHorizontal, + Search, + Skeleton, + Switch, + toast, +} from '@/components/emcn' +import { ArrowLeft } from '@/components/emcn/icons' +import type { ShareAuthType } from '@/lib/api/contracts/public-shares' +import { cn } from '@/lib/core/utils/cn' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { + MemberAvatar, + MemberRow, +} from '@/app/workspace/[workspaceId]/settings/components/member-list' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { getAllBlocks } from '@/blocks' +import type { BlockConfig } from '@/blocks/types' +import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select' +import { + type PermissionGroup, + type PermissionGroupWorkspaceRef, + useBulkAddPermissionGroupMembers, + useDeletePermissionGroup, + usePermissionGroupMembers, + useRemovePermissionGroupMember, + useUpdatePermissionGroup, +} from '@/ee/access-control/hooks/permission-groups' +import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers' +import { useOrganizationRoster } from '@/hooks/queries/organization' +import { useProviderModels } from '@/hooks/queries/providers' +import { + DYNAMIC_MODEL_PROVIDERS, + getProviderModels, + PROVIDER_DEFINITIONS, +} from '@/providers/models' +import type { ProviderId } from '@/providers/types' +import { getAllProviderIds, getProviderFromModel } from '@/providers/utils' +import type { ProviderName } from '@/stores/providers' +import { getTool } from '@/tools/utils' + +const logger = createLogger('AccessControlGroupDetail') + +type ConfigTab = 'general' | 'providers' | 'blocks' | 'platform' + +/** Public-file-share auth modes an admin can allow/disallow. `null` config = all allowed. */ +const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [ + { value: 'public', label: 'Anyone with link' }, + { value: 'password', label: 'Password' }, + { value: 'email', label: 'Email' }, + { value: 'sso', label: 'SSO' }, +] +const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value) + +interface OrganizationMemberOption { + userId: string + user: { + name: string | null + email: string + image?: string | null + } +} + +interface AddMembersModalProps { + open: boolean + onOpenChange: (open: boolean) => void + availableMembers: OrganizationMemberOption[] + selectedMemberIds: Set + setSelectedMemberIds: React.Dispatch>> + onAddMembers: () => void + isAdding: boolean + errorMessage: string | null +} + +function AddMembersModal({ + open, + onOpenChange, + availableMembers, + selectedMemberIds, + setSelectedMemberIds, + onAddMembers, + isAdding, + errorMessage, +}: AddMembersModalProps) { + const [searchTerm, setSearchTerm] = useState('') + + const filteredMembers = useMemo(() => { + if (!searchTerm.trim()) return availableMembers + const query = searchTerm.toLowerCase() + return availableMembers.filter((m) => { + const name = m.user?.name || '' + const email = m.user?.email || '' + return name.toLowerCase().includes(query) || email.toLowerCase().includes(query) + }) + }, [availableMembers, searchTerm]) + + const allFilteredSelected = useMemo(() => { + if (filteredMembers.length === 0) return false + return filteredMembers.every((m) => selectedMemberIds.has(m.userId)) + }, [filteredMembers, selectedMemberIds]) + + const handleToggleAll = () => { + if (allFilteredSelected) { + const filteredIds = new Set(filteredMembers.map((m) => m.userId)) + setSelectedMemberIds((prev) => { + const next = new Set(prev) + filteredIds.forEach((id) => next.delete(id)) + return next + }) + } else { + setSelectedMemberIds((prev) => { + const next = new Set(prev) + filteredMembers.forEach((m) => next.add(m.userId)) + return next + }) + } + } + + const handleToggleMember = (userId: string) => { + setSelectedMemberIds((prev) => { + const next = new Set(prev) + if (next.has(userId)) { + next.delete(userId) + } else { + next.add(userId) + } + return next + }) + } + + return ( + { + if (!o) setSearchTerm('') + onOpenChange(o) + }} + size='sm' + srTitle='Add Members' + > + onOpenChange(false)}>Add Members + + {availableMembers.length === 0 ? ( +

+ All organization members are already in this group. +

+ ) : ( + +
+
+ setSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> + + {allFilteredSelected ? 'Deselect All' : 'Select All'} + +
+ +
+ {filteredMembers.length === 0 ? ( +

+ No members found matching "{searchTerm}" +

+ ) : ( +
+ {filteredMembers.map((member) => { + const name = member.user?.name || 'Unknown' + const email = member.user?.email || '' + const isSelected = selectedMemberIds.has(member.userId) + + return ( + + ) + })} +
+ )} +
+
+
+ )} + {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 ALL block types that expose it (some tools are shared across blocks). */ + const toolBlockTypes = useMemo(() => { + const map: Record = {} + for (const block of allBlocks) { + for (const toolId of block.tools?.access ?? []) { + ;(map[toolId] ??= []).push(block.type) + } + } + return map + }, [allBlocks]) + + const platformFeatures = useMemo( + () => [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab' as const, + hint: 'Hide the Knowledge Base module from the sidebar.', + }, + { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + configKey: 'hideTablesTab' as const, + hint: 'Hide the Tables module from the sidebar.', + }, + { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + configKey: 'hideCopilot' as const, + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }, + { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + configKey: 'hideIntegrationsTab' as const, + hint: 'Hide the Integrations settings tab (OAuth connections).', + }, + { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + configKey: 'hideSecretsTab' as const, + hint: 'Hide the Secrets (environment variables) settings tab.', + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab' as const, + hint: 'Hide the API Keys settings tab.', + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab' as const, + hint: 'Hide the Files settings tab.', + }, + { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + configKey: 'hideDeployApi' as const, + hint: 'Hide the API deployment option.', + }, + { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + configKey: 'hideDeployMcp' as const, + hint: 'Hide the MCP server deployment option.', + }, + { + id: 'hide-deploy-a2a', + label: 'A2A', + category: 'Deploy Tabs', + configKey: 'hideDeployA2a' as const, + hint: 'Hide the agent-to-agent deployment option.', + }, + { + id: 'hide-deploy-chatbot', + label: 'Chat', + category: 'Deploy Tabs', + configKey: 'hideDeployChatbot' as const, + hint: 'Hide the chatbot deployment option.', + }, + { + id: 'hide-deploy-template', + label: 'Template', + category: 'Deploy Tabs', + configKey: 'hideDeployTemplate' as const, + hint: 'Hide the template publishing option.', + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools' as const, + hint: 'Block agents from calling MCP tools.', + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools' as const, + hint: 'Block agents from calling user-defined custom tools.', + }, + { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + configKey: 'disableSkills' as const, + hint: 'Block agents from loading skills.', + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans' as const, + hint: 'Hide per-block trace spans in logs.', + }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations' as const, + hint: 'Prevent users from inviting others to workspaces.', + }, + { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + configKey: 'hideInboxTab' as const, + hint: 'Hide the Sim Mailer inbox.', + }, + { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + configKey: 'disablePublicApi' as const, + hint: 'Disable public API access to deployed workflows.', + }, + ], + [] + ) + + const filteredPlatformFeatures = useMemo(() => { + if (!platformSearchTerm.trim()) return platformFeatures + const search = platformSearchTerm.toLowerCase() + return platformFeatures.filter( + (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + ) + }, [platformFeatures, platformSearchTerm]) + + const platformCategories = useMemo(() => { + const categories: Record = {} + for (const feature of filteredPlatformFeatures) { + if (!categories[feature.category]) { + categories[feature.category] = [] + } + categories[feature.category].push(feature) + } + return categories + }, [filteredPlatformFeatures]) + + const platformCategoryColumns = useMemo(() => { + const categoryGroups = [ + ['Sidebar', 'Deploy Tabs', 'Collaboration'], + ['Workflow Panel', 'Tools', 'Features'], + ['Settings Tabs', 'Logs'], + ] + + const assignedCategories = new Set(categoryGroups.flat()) + const unassigned = Object.keys(platformCategories).filter( + (c) => c !== 'Files' && !assignedCategories.has(c) + ) + const groups = unassigned.length > 0 ? [...categoryGroups, unassigned] : categoryGroups + + return groups + .map((column) => + column + .map((category) => ({ + category, + features: platformCategories[category] ?? [], + })) + .filter((section) => section.features.length > 0) + ) + .filter((column) => column.length > 0) + }, [platformCategories]) + + const hasConfigChanges = useMemo(() => { + return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig) + }, [viewingGroup.config, editingConfig]) + + const filteredProviders = useMemo(() => { + if (!providerSearchTerm.trim()) return allProviderIds + const query = providerSearchTerm.toLowerCase() + return allProviderIds.filter((id) => id.toLowerCase().includes(query)) + }, [allProviderIds, providerSearchTerm]) + + const filteredBlocks = useMemo(() => { + if (!integrationSearchTerm.trim()) return allBlocks + const query = integrationSearchTerm.toLowerCase() + return allBlocks.filter((b) => b.name.toLowerCase().includes(query)) + }, [allBlocks, integrationSearchTerm]) + + const filteredCoreBlocks = useMemo( + () => filteredBlocks.filter((block) => block.category === 'blocks'), + [filteredBlocks] + ) + + const filteredToolBlocks = useMemo( + () => + filteredBlocks + .filter((block) => block.category === 'tools' || block.category === 'triggers') + .sort((a, b) => a.name.localeCompare(b.name)), + [filteredBlocks] + ) + + const organizationMembers = useMemo(() => { + if (!roster?.members) return [] + return roster.members + .filter((m) => m.role !== 'external') + .map((m) => ({ + userId: m.userId, + user: { name: m.name, email: m.email, image: m.image }, + })) + }, [roster]) + + const availableMembersToAdd = useMemo(() => { + const existingMemberUserIds = new Set(members.map((m) => m.userId)) + return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId)) + }, [organizationMembers, members]) + + const isIntegrationAllowed = useCallback( + (blockType: string) => + editingConfig.allowedIntegrations === null || + editingConfig.allowedIntegrations.includes(blockType), + [editingConfig.allowedIntegrations] + ) + + /** + * Drops denied tools whose integration is no longer allowed, keeping the + * invariant that `deniedTools` only holds tools of currently-allowed blocks. + * Without this, disabling then re-enabling an integration would silently + * re-apply stale per-tool denials. Tools we can't attribute to a known block + * are preserved. + */ + const pruneDeniedTools = useCallback( + (allowedIntegrations: string[] | null, deniedTools: string[]) => { + if (allowedIntegrations === null) return deniedTools + const allowed = new Set(allowedIntegrations) + const pruned = deniedTools.filter((toolId) => { + const blockTypes = toolBlockTypes[toolId] + return !blockTypes || blockTypes.some((bt) => allowed.has(bt)) + }) + return pruned.length === deniedTools.length ? deniedTools : pruned + }, + [toolBlockTypes] + ) + + const toggleIntegration = useCallback( + (blockType: string) => { + setEditingConfig((prev) => { + const current = prev.allowedIntegrations + let nextAllowed: string[] | null + if (current === null) { + nextAllowed = allBlocks.map((b) => b.type).filter((t) => t !== blockType) + } else if (current.includes(blockType)) { + const updated = current.filter((t) => t !== blockType) + nextAllowed = updated.length === allBlocks.length ? null : updated + } else { + const updated = [...current, blockType] + nextAllowed = updated.length === allBlocks.length ? null : updated + } + return { + ...prev, + allowedIntegrations: nextAllowed, + deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools), + } + }) + }, + [allBlocks, pruneDeniedTools] + ) + + /** Allow or deny a whole section's blocks at once, respecting the active filter. */ + const setBlocksAllowed = useCallback( + (blocks: BlockConfig[], allowed: boolean) => { + setEditingConfig((prev) => { + const allTypes = allBlocks.map((b) => b.type) + const current = + prev.allowedIntegrations === null ? new Set(allTypes) : new Set(prev.allowedIntegrations) + for (const block of blocks) { + if (allowed) current.add(block.type) + else current.delete(block.type) + } + const nextArr = allTypes.filter((t) => current.has(t)) + const nextAllowed = nextArr.length === allTypes.length ? null : nextArr + return { + ...prev, + allowedIntegrations: nextAllowed, + deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools), + } + }) + }, + [allBlocks, pruneDeniedTools] + ) + + const isToolAllowed = useCallback( + (toolId: string) => !editingConfig.deniedTools.includes(toolId), + [editingConfig.deniedTools] + ) + + const toggleTool = useCallback((toolId: string) => { + setEditingConfig((prev) => { + const denied = prev.deniedTools.includes(toolId) + return { + ...prev, + deniedTools: denied + ? prev.deniedTools.filter((t) => t !== toolId) + : [...prev.deniedTools, toolId], + } + }) + }, []) + + const setToolsDenied = useCallback((toolIds: string[], denied: boolean) => { + setEditingConfig((prev) => { + if (denied) { + const existing = new Set(prev.deniedTools) + const additions = toolIds.filter((t) => !existing.has(t)) + if (additions.length === 0) return prev + return { ...prev, deniedTools: [...prev.deniedTools, ...additions] } + } + const toRemove = new Set(toolIds) + return { ...prev, deniedTools: prev.deniedTools.filter((t) => !toRemove.has(t)) } + }) + }, []) + + const deniedCountByBlock = useMemo(() => { + const denied = new Set(editingConfig.deniedTools) + const counts: Record = {} + for (const block of allBlocks) { + let count = 0 + for (const toolId of block.tools?.access ?? []) { + if (denied.has(toolId)) count++ + } + if (count > 0) counts[block.type] = count + } + return counts + }, [editingConfig.deniedTools, allBlocks]) + + const isProviderAllowed = useCallback( + (providerId: string) => + editingConfig.allowedModelProviders === null || + editingConfig.allowedModelProviders.includes(providerId), + [editingConfig.allowedModelProviders] + ) + + const toggleProvider = useCallback( + (providerId: string) => { + setEditingConfig((prev) => { + const current = prev.allowedModelProviders + if (current === null) { + const allExcept = allProviderIds.filter((p) => p !== providerId) + return { ...prev, allowedModelProviders: allExcept } + } + if (current.includes(providerId)) { + const updated = current.filter((p) => p !== providerId) + return { + ...prev, + allowedModelProviders: updated.length === allProviderIds.length ? null : updated, + } + } + const updated = [...current, providerId] + return { + ...prev, + allowedModelProviders: updated.length === allProviderIds.length ? null : updated, + } + }) + }, + [allProviderIds] + ) + + /** Allow or deny a set of providers at once, respecting the active search filter. */ + const setProvidersAllowed = useCallback( + (providerIds: string[], allowed: boolean) => { + setEditingConfig((prev) => { + const current = + prev.allowedModelProviders === null + ? new Set(allProviderIds) + : new Set(prev.allowedModelProviders) + for (const id of providerIds) { + if (allowed) current.add(id) + else current.delete(id) + } + const nextArr = allProviderIds.filter((id) => current.has(id)) + return { + ...prev, + allowedModelProviders: nextArr.length === allProviderIds.length ? null : nextArr, + } + }) + }, + [allProviderIds] + ) + + const isModelAllowed = useCallback( + (model: string) => { + const normalized = model.toLowerCase() + return !editingConfig.deniedModels.some((denied) => denied.toLowerCase() === normalized) + }, + [editingConfig.deniedModels] + ) + + const toggleModel = useCallback((model: string) => { + setEditingConfig((prev) => { + const normalized = model.toLowerCase() + const isDenied = prev.deniedModels.some((denied) => denied.toLowerCase() === normalized) + return { + ...prev, + deniedModels: isDenied + ? prev.deniedModels.filter((denied) => denied.toLowerCase() !== normalized) + : [...prev.deniedModels, model], + } + }) + }, []) + + const setModelsDenied = useCallback((models: string[], denied: boolean) => { + setEditingConfig((prev) => { + if (denied) { + const existing = new Set(prev.deniedModels.map((m) => m.toLowerCase())) + const additions = models.filter((m) => !existing.has(m.toLowerCase())) + if (additions.length === 0) return prev + return { ...prev, deniedModels: [...prev.deniedModels, ...additions] } + } + const toRemove = new Set(models.map((m) => m.toLowerCase())) + return { + ...prev, + deniedModels: prev.deniedModels.filter((m) => !toRemove.has(m.toLowerCase())), + } + }) + }, []) + + const deniedCountByProvider = useMemo(() => { + const counts: Record = {} + for (const model of editingConfig.deniedModels) { + try { + const providerId = getProviderFromModel(model) + counts[providerId] = (counts[providerId] ?? 0) + 1 + } catch { + // Unknown/blacklisted provider — omit from counts. + } + } + return counts + }, [editingConfig.deniedModels]) + + const isFileShareAuthAllowed = useCallback( + (authType: ShareAuthType) => + editingConfig.allowedFileShareAuthTypes === null || + editingConfig.allowedFileShareAuthTypes.includes(authType), + [editingConfig.allowedFileShareAuthTypes] + ) + + const toggleFileShareAuthType = useCallback((authType: ShareAuthType) => { + setEditingConfig((prev) => { + const current = prev.allowedFileShareAuthTypes + const next = + current === null + ? ALL_FILE_SHARE_AUTH_TYPES.filter((t) => t !== authType) + : current.includes(authType) + ? current.filter((t) => t !== authType) + : [...current, authType] + return { + ...prev, + allowedFileShareAuthTypes: next.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : next, + } + }) + }, []) + + /** Persists the editing buffer. Returns whether the save succeeded so callers can decide whether to navigate away. */ + const handleSaveConfig = useCallback(async (): Promise => { + try { + await updatePermissionGroup.mutateAsync({ + id: viewingGroup.id, + organizationId, + config: editingConfig, + }) + setViewingGroup((prev) => ({ ...prev, config: editingConfig })) + return true + } catch (error) { + logger.error('Failed to update config', error) + toast.error("Couldn't save changes", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + return false + } + }, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup]) + + const handleDiscardConfig = useCallback(() => { + setEditingConfig({ ...viewingGroup.config }) + }, [viewingGroup.config]) + + const handleBack = useCallback(() => { + if (hasConfigChanges) { + setShowUnsavedChanges(true) + return + } + onBack() + }, [hasConfigChanges, onBack]) + + const handleScopeChange = useCallback( + async (workspaceIds: string[]) => { + const previous = viewingGroup + const seq = ++scopeWriteSeqRef.current + + setViewingGroup((prev) => ({ + ...prev, + workspaces: organizationWorkspaces.filter((ws) => workspaceIds.includes(ws.id)), + })) + try { + const result = await updatePermissionGroup.mutateAsync({ + id: viewingGroup.id, + organizationId, + workspaceIds, + }) + if (seq !== scopeWriteSeqRef.current) return + setViewingGroup((prev) => ({ + ...prev, + workspaces: organizationWorkspaces.filter((ws) => + result.permissionGroup.workspaceIds.includes(ws.id) + ), + })) + } catch (error) { + logger.error('Failed to update workspace scope', error) + if (seq !== scopeWriteSeqRef.current) return + setViewingGroup(previous) + toast.error("Couldn't update workspaces", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + }, + [viewingGroup, organizationId, organizationWorkspaces, updatePermissionGroup] + ) + + const handleToggleDefault = useCallback( + async (enabled: boolean) => { + const seq = ++scopeWriteSeqRef.current + try { + const result = await updatePermissionGroup.mutateAsync({ + id: viewingGroup.id, + organizationId, + isDefault: enabled, + }) + if (seq !== scopeWriteSeqRef.current) return + setViewingGroup((prev) => ({ + ...prev, + isDefault: result.permissionGroup.isDefault, + workspaces: result.permissionGroup.isDefault + ? [] + : organizationWorkspaces.filter((ws) => + result.permissionGroup.workspaceIds.includes(ws.id) + ), + })) + } catch (error) { + logger.error('Failed to toggle default group', error) + toast.error("Couldn't update the default group", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + }, + [viewingGroup.id, organizationId, organizationWorkspaces, updatePermissionGroup] + ) + + const handleRemoveMember = useCallback( + async (memberId: string) => { + try { + await removeMember.mutateAsync({ + organizationId, + permissionGroupId: viewingGroup.id, + memberId, + }) + } catch (error) { + logger.error('Failed to remove member', error) + toast.error("Couldn't remove member", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + }, + [viewingGroup.id, organizationId, removeMember] + ) + + const handleOpenAddMembersModal = useCallback(() => { + setSelectedMemberIds(new Set()) + setAddMembersError(null) + setShowAddMembersModal(true) + }, []) + + const handleAddSelectedMembers = useCallback(async () => { + if (selectedMemberIds.size === 0) return + setAddMembersError(null) + try { + await bulkAddMembers.mutateAsync({ + organizationId, + permissionGroupId: viewingGroup.id, + userIds: Array.from(selectedMemberIds), + }) + setShowAddMembersModal(false) + setSelectedMemberIds(new Set()) + } catch (error) { + logger.error('Failed to add members', error) + setAddMembersError(getErrorMessage(error, 'Failed to add members')) + } + }, [viewingGroup.id, organizationId, selectedMemberIds, bulkAddMembers]) + + const confirmDelete = useCallback(async () => { + try { + await deletePermissionGroup.mutateAsync({ + permissionGroupId: viewingGroup.id, + organizationId, + }) + setShowDeleteConfirm(false) + onDeleted() + } catch (error) { + logger.error('Failed to delete permission group', error) + toast.error("Couldn't delete group", { + description: getErrorMessage(error, 'Please try again in a moment.'), + }) + } + }, [viewingGroup.id, organizationId, deletePermissionGroup, onDeleted]) + + const tabs = useMemo( + () => [ + { value: 'general' as const, label: 'General' }, + { value: 'providers' as const, label: 'Model Providers' }, + { value: 'blocks' as const, label: 'Blocks' }, + { value: 'platform' as const, label: 'Platform' }, + ], + [] + ) + + const filteredProvidersAllAllowed = filteredProviders.every((id) => isProviderAllowed(id)) + const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type)) + const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type)) + const platformAllVisible = filteredPlatformFeatures.every((f) => !editingConfig[f.configKey]) + + return ( + <> +
+
+ + Access Control + + setShowDeleteConfirm(true)} + disabled={deletePermissionGroup.isPending} + > + {deletePermissionGroup.isPending ? 'Deleting...' : 'Delete'} + +
+ +
+
+ setConfigTab(value as ConfigTab)} + /> +
+
+ +
+
+
+

{viewingGroup.name}

+ {viewingGroup.description && ( +

{viewingGroup.description}

+ )} +
+ + {configTab === 'general' && ( + <> + +
+ + Applies to everyone in the organization not assigned to another group, + including external workspace members + + handleToggleDefault(checked)} + disabled={updatePermissionGroup.isPending} + /> +
+
+ + + {viewingGroup.isDefault ? ( +
+ + Governs every workspace in the organization + +
+ ) : ( +
+
+ + {viewingGroup.workspaces.length > 0 + ? `Governs ${viewingGroup.workspaces.length} workspace${ + viewingGroup.workspaces.length === 1 ? '' : 's' + }` + : 'Select the workspaces this group governs'} + + ws.id)} + onChange={handleScopeChange} + options={workspaceOptions} + isLoading={workspacesLoading} + allowAllWorkspaces={false} + className='flex-shrink-0' + /> +
+ {viewingGroup.workspaces.length > 0 && ( +
+ {viewingGroup.workspaces.map((ws) => ( + + ))} +
+ )} +
+ )} +
+ + {!viewingGroup.isDefault && ( + +
+
+ + {members.length === 0 + ? 'Applies to all members of its workspaces. Add members to restrict it to specific people.' + : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`} + + + Add + +
+ {membersLoading ? ( +
+ {[1, 2].map((i) => ( +
+ + +
+ ))} +
+ ) : ( + members.length > 0 && ( +
+ {members.map((member) => ( + + + + + + handleRemoveMember(member.id)} + > + Remove + + + + } + /> + ))} +
+ ) + )} +
+
+ )} + + )} + + {configTab === 'providers' && ( +
+
+ setProviderSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> + + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed) + } + > + {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'} + +
+
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isAllowed={isModelAllowed} + onToggle={toggleModel} + onSetDenied={setModelsDenied} + /> + ))} +
+
+ )} + + {configTab === 'blocks' && ( +
+
+ setIntegrationSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> +
+
+ {filteredCoreBlocks.length > 0 && ( +
+
+ + Core Blocks + + + setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed) + } + > + {coreBlocksAllAllowed ? 'Deselect All' : 'Select All'} + +
+
+ {filteredCoreBlocks.map((block) => { + const BlockIcon = block.icon + const checkboxId = `block-${block.type}` + return ( + + ) + })} +
+
+ )} + {filteredToolBlocks.length > 0 && ( +
+
+
+ + Integrations and Triggers + + + Allow a whole integration with its checkbox, then expand it to deny + specific tools while keeping the rest available. + +
+ + setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed) + } + > + {toolBlocksAllAllowed ? 'Deselect All' : 'Select All'} + +
+
+ {filteredToolBlocks.map((block) => ( + toggleIntegration(block.type)} + deniedCount={deniedCountByBlock[block.type] ?? 0} + isAllowed={isToolAllowed} + onToggle={toggleTool} + onSetDenied={setToolsDenied} + /> + ))} +
+
+ )} +
+
+ )} + + {configTab === 'platform' && ( +
+
+ setPlatformSearchTerm(e.target.value)} + className='min-w-0 flex-1' + /> + + setEditingConfig((prev) => ({ + ...prev, + ...Object.fromEntries( + filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible]) + ), + })) + } + > + {platformAllVisible ? 'Deselect All' : 'Select All'} + +
+
+ {platformCategoryColumns.map((column, columnIndex) => ( +
+ {column.map(({ category, features }) => ( +
+ + {category} + +
+ {features.map((feature) => ( +
+ + {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 () => { + const saved = await handleSaveConfig() + if (saved) { + setShowUnsavedChanges(false) + onBack() + } + }, + disabled: updatePermissionGroup.isPending, + }} + /> +
+ + ) +} diff --git a/apps/sim/ee/access-control/components/workspace-select.tsx b/apps/sim/ee/access-control/components/workspace-select.tsx new file mode 100644 index 00000000000..f8f2496aa13 --- /dev/null +++ b/apps/sim/ee/access-control/components/workspace-select.tsx @@ -0,0 +1,58 @@ +'use client' + +import { ChipDropdown } from '@/components/emcn' + +interface WorkspaceSelectProps { + workspaceIds: string[] + onChange: (ids: string[]) => void + options: { value: string; label: string }[] + disabled?: boolean + isLoading?: boolean + fullWidth?: boolean + className?: string + /** + * When false, the "All workspaces" reset option is hidden and an empty + * selection reads as a prompt. Non-default groups must target ≥1 workspace. + */ + allowAllWorkspaces?: boolean +} + +/** + * Workspace scope multi-select. With `allowAllWorkspaces` an empty selection + * reads as "All workspaces" (the default group); otherwise it prompts for a + * selection, since non-default groups must target specific workspaces. + */ +export function WorkspaceSelect({ + workspaceIds, + onChange, + options, + disabled = false, + isLoading = false, + fullWidth = false, + className, + allowAllWorkspaces = true, +}: WorkspaceSelectProps) { + return ( + + ) +} diff --git a/apps/sim/ee/access-control/utils/permission-check.test.ts b/apps/sim/ee/access-control/utils/permission-check.test.ts index d78ba101395..8c24c396706 100644 --- a/apps/sim/ee/access-control/utils/permission-check.test.ts +++ b/apps/sim/ee/access-control/utils/permission-check.test.ts @@ -17,6 +17,7 @@ const { allowedIntegrations: null, allowedModelProviders: null, deniedModels: [], + deniedTools: [], hideTraceSpans: false, hideKnowledgeBaseTab: false, hideTablesTab: false, @@ -142,6 +143,7 @@ import { ProviderNotAllowedError, PublicFileSharingNotAllowedError, SkillsNotAllowedError, + ToolNotAllowedError, validateBlockType, validateMcpToolsAllowed, validateModelProvider, @@ -597,6 +599,69 @@ describe('assertPermissionsAllowed', () => { }) }) + it('throws ToolNotAllowedError when the tool is on the denylist', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'slack_canvas', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + }) + + it('allows a tool that is not on the denylist', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + + await assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'slack_message', + }) + }) + + it('allows every tool when the denylist is empty', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: [] } }] + + await assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + toolId: 'slack_canvas', + }) + }) + + it('denies a tool even when its block is allowed by the integration allowlist', async () => { + mockWorkspaceGroups.value = [ + { config: { allowedIntegrations: ['slack'], deniedTools: ['slack_canvas'] } }, + ] + + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + blockType: 'slack', + toolId: 'slack_canvas', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + }) + + it('still enforces the tool denylist for an exempt block type', async () => { + mockWorkspaceGroups.value = [{ config: { deniedTools: ['slack_canvas'] } }] + mockGetBlock.mockImplementation((type) => + type === 'slack' ? { hideFromToolbar: true } : undefined + ) + + await expect( + assertPermissionsAllowed({ + userId: 'user-123', + workspaceId: 'workspace-1', + blockType: 'slack', + toolId: 'slack_canvas', + }) + ).rejects.toBeInstanceOf(ToolNotAllowedError) + }) + it('throws CustomToolsNotAllowedError when custom tools are disabled', async () => { mockWorkspaceGroups.value = [{ config: { disableCustomTools: true } }] diff --git a/apps/sim/ee/access-control/utils/permission-check.ts b/apps/sim/ee/access-control/utils/permission-check.ts index 03c41369ecc..611ea077b45 100644 --- a/apps/sim/ee/access-control/utils/permission-check.ts +++ b/apps/sim/ee/access-control/utils/permission-check.ts @@ -50,6 +50,13 @@ export class IntegrationNotAllowedError extends Error { } } +export class ToolNotAllowedError extends Error { + constructor(toolId: string) { + super(`Tool "${toolId}" is not allowed based on your permission group settings`) + this.name = 'ToolNotAllowedError' + } +} + export class McpToolsNotAllowedError extends Error { constructor() { super('MCP tools are not allowed based on your permission group settings') @@ -566,6 +573,12 @@ interface PermissionAssertion { workspaceId: string | undefined model?: string blockType?: string + /** + * Concrete tool ID being executed (e.g. `slack_canvas`). Checked against the + * group's `deniedTools` denylist so an admin can allow an integration but deny + * specific operations within it. Pass the normalized tool id. + */ + toolId?: string toolKind?: ToolKind ctx?: ExecutionContext } @@ -581,11 +594,11 @@ interface PermissionAssertion { * callsite covers every future config field. */ export async function assertPermissionsAllowed(req: PermissionAssertion): Promise { - const { userId, workspaceId, model, blockType, toolKind, ctx } = req + const { userId, workspaceId, model, blockType, toolId, toolKind, ctx } = req const blockTypeExempt = blockType ? isBlockTypeAccessControlExempt(blockType) : false - if (blockTypeExempt && !model && !toolKind) { + if (blockTypeExempt && !model && !toolKind && !toolId) { return } @@ -634,6 +647,11 @@ export async function assertPermissionsAllowed(req: PermissionAssertion): Promis } } + if (toolId && config?.deniedTools?.includes(toolId)) { + logger.warn('Tool blocked by permission group', { userId, workspaceId, toolId }) + throw new ToolNotAllowedError(toolId) + } + if (toolKind && config) { if (toolKind === 'mcp' && config.disableMcpTools) { logger.warn('MCP tools blocked by permission group', { userId, workspaceId }) diff --git a/apps/sim/ee/data-retention/components/data-retention-settings.tsx b/apps/sim/ee/data-retention/components/data-retention-settings.tsx index 7d48ae631ea..a72ca616a09 100644 --- a/apps/sim/ee/data-retention/components/data-retention-settings.tsx +++ b/apps/sim/ee/data-retention/components/data-retention-settings.tsx @@ -18,6 +18,7 @@ import { ChipModalHeader, ChipSelect, ChipSwitch, + ChipTag, Search, toast, } from '@/components/emcn' @@ -767,9 +768,9 @@ export function DataRetentionSettings() { Organization - + Default - +
{orgRowSummary()} diff --git a/apps/sim/hooks/use-permission-config.ts b/apps/sim/hooks/use-permission-config.ts index 330e502c78a..3513c4c167d 100644 --- a/apps/sim/hooks/use-permission-config.ts +++ b/apps/sim/hooks/use-permission-config.ts @@ -23,6 +23,7 @@ export interface PermissionConfigResult { isBlockAllowed: (blockType: string) => boolean isProviderAllowed: (providerId: string) => boolean isModelAllowed: (model: string) => boolean + isToolAllowed: (toolId: string) => boolean isInvitationsDisabled: boolean isPublicApiDisabled: boolean } @@ -113,6 +114,13 @@ export function usePermissionConfig(): PermissionConfigResult { } }, [config.deniedModels]) + const isToolAllowed = useMemo(() => { + return (toolId: string) => { + if (config.deniedTools.length === 0) return true + return !config.deniedTools.includes(toolId) + } + }, [config.deniedTools]) + const filterBlocks = useMemo(() => { return (blocks: T[]): T[] => { if (mergedAllowedIntegrations === null) return blocks @@ -156,6 +164,7 @@ export function usePermissionConfig(): PermissionConfigResult { isBlockAllowed, isProviderAllowed, isModelAllowed, + isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, }), @@ -168,6 +177,7 @@ export function usePermissionConfig(): PermissionConfigResult { isBlockAllowed, isProviderAllowed, isModelAllowed, + isToolAllowed, isInvitationsDisabled, isPublicApiDisabled, ] diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index b2f2d7fa7fd..a1e55c3d9ea 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -8,6 +8,7 @@ export const permissionGroupFullConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable(), allowedModelProviders: z.array(z.string()).nullable(), deniedModels: z.array(z.string()).default([]), + deniedTools: z.array(z.string()).default([]), hideTraceSpans: z.boolean(), hideKnowledgeBaseTab: z.boolean(), hideTablesTab: z.boolean(), diff --git a/apps/sim/lib/permission-groups/types.ts b/apps/sim/lib/permission-groups/types.ts index 3269ec69459..76c18c0b9b1 100644 --- a/apps/sim/lib/permission-groups/types.ts +++ b/apps/sim/lib/permission-groups/types.ts @@ -21,6 +21,7 @@ export const permissionGroupConfigSchema = z.object({ allowedIntegrations: z.array(z.string()).nullable().optional(), allowedModelProviders: z.array(z.string()).nullable().optional(), deniedModels: z.array(z.string()).optional(), + deniedTools: z.array(z.string()).optional(), hideTraceSpans: z.boolean().optional(), hideKnowledgeBaseTab: z.boolean().optional(), hideTablesTab: z.boolean().optional(), @@ -52,6 +53,13 @@ export interface PermissionGroupConfig { * group, checked after `allowedModelProviders`. Empty means nothing is blocked. */ deniedModels: string[] + /** + * Snake_case tool IDs (e.g. `slack_canvas`) blocked for this group, checked + * after the block-level `allowedIntegrations` gate. Lets an admin allow an + * integration but deny specific operations within it. Empty means nothing is + * blocked. + */ + deniedTools: string[] hideTraceSpans: boolean hideKnowledgeBaseTab: boolean hideTablesTab: boolean @@ -80,6 +88,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = { allowedIntegrations: null, allowedModelProviders: null, deniedModels: [], + deniedTools: [], hideTraceSpans: false, hideKnowledgeBaseTab: false, hideTablesTab: false, @@ -116,6 +125,9 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf deniedModels: Array.isArray(c.deniedModels) ? c.deniedModels.filter((m): m is string => typeof m === 'string') : [], + deniedTools: Array.isArray(c.deniedTools) + ? c.deniedTools.filter((t): t is string => typeof t === 'string') + : [], hideTraceSpans: typeof c.hideTraceSpans === 'boolean' ? c.hideTraceSpans : false, hideKnowledgeBaseTab: typeof c.hideKnowledgeBaseTab === 'boolean' ? c.hideKnowledgeBaseTab : false, diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 1f7e554c324..2ee548a4324 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -936,10 +936,13 @@ export async function executeTool( ? 'mcp' : undefined - if (toolKind && scope.userId && scope.workspaceId) { + // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools` + // denylist is enforced alongside the existing mcp/custom/skill gates. + if (scope.userId && scope.workspaceId) { await assertPermissionsAllowed({ userId: scope.userId, workspaceId: scope.workspaceId, + toolId: normalizedToolId, toolKind, ctx: executionContext, })