'use client' import { useCallback, useMemo, useRef, useState } from 'react' import { Checkbox, Chip, ChipConfirmModal, ChipDropdown, ChipInput, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, ChipModalTabs, ChipTag, cn, Info, Search, Skeleton, Switch, toast, } from '@sim/emcn' import { ArrowLeft } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { formatDate } from '@sim/utils/formatting' import { ChevronDown, Plus } from 'lucide-react' import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { MemberAvatar, MemberRow, } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { getAllBlocks } from '@/blocks' import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay' 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 { data: members = [], isPending: membersLoading } = usePermissionGroupMembers( organizationId, viewingGroup.id ) const { data: roster } = useOrganizationRoster(organizationId) const { data: blacklistedProvidersData } = useBlacklistedProviders({ enabled: true }) // Recompute when custom (deploy-as-block) blocks or the viewer's block // visibility hydrate into the overlay. const customBlockOverlayVersion = useCustomBlockOverlayVersion() /** * The allowlist UNIVERSE: every access-controllable block, INCLUDING blocks * gated for this viewer (they arrive as clones with `hideFromToolbar: true`, * clone-not-remove). Materialization and the collapse-to-null comparison in * `toggleIntegration`/`setBlocksAllowed` must use this viewer-independent set — * otherwise a null→partial transition by a non-revealed admin would silently * drop a preview block from the stored allowlist and deny it to revealed * users already running it. */ 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) }) }, [customBlockOverlayVersion]) /** * The RENDERED list: hides blocks gated for this viewer by reading the * registry projection's effective flag off the clone (the single source of * truth — never re-derive visibility here). Revealed viewers see preview * blocks (with their " (Preview)" suffix) and can toggle them explicitly. */ const visibleBlocks = useMemo(() => allBlocks.filter((b) => !b.hideFromToolbar), [allBlocks]) 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-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 platformCategorySections = useMemo(() => { const order = [ 'Sidebar', 'Deploy Tabs', 'Collaboration', 'Workflow Panel', 'Tools', 'Features', 'Settings Tabs', 'Logs', ] const known = order.filter((c) => platformCategories[c]?.length) const extras = Object.keys(platformCategories).filter( (c) => c !== 'Files' && !order.includes(c) && platformCategories[c]?.length ) return [...known, ...extras].map((category) => ({ category, features: platformCategories[category] ?? [], })) }, [platformCategories]) const hasConfigChanges = useMemo(() => { return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig) }, [viewingGroup.config, editingConfig]) const guard = useSettingsUnsavedGuard({ isDirty: hasConfigChanges }) 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 visibleBlocks const query = integrationSearchTerm.toLowerCase() return visibleBlocks.filter((b) => b.name.toLowerCase().includes(query)) }, [visibleBlocks, 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 {} } return counts }, [editingConfig.deniedModels]) const fileShareAuthValue = useMemo( () => editingConfig.allowedFileShareAuthTypes ?? ALL_FILE_SHARE_AUTH_TYPES, [editingConfig.allowedFileShareAuthTypes] ) const setFileShareAuthTypes = useCallback((values: string[]) => { setEditingConfig((prev) => ({ ...prev, allowedFileShareAuthTypes: values.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : (values as ShareAuthType[]), })) }, []) /** Persists the editing buffer. */ const handleSaveConfig = useCallback(async () => { try { await updatePermissionGroup.mutateAsync({ id: viewingGroup.id, organizationId, config: editingConfig, }) setViewingGroup((prev) => ({ ...prev, config: editingConfig })) } catch (error) { logger.error('Failed to update config', error) toast.error("Couldn't save changes", { description: getErrorMessage(error, 'Please try again in a moment.'), }) } }, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup]) const handleDiscardConfig = useCallback(() => { setEditingConfig({ ...viewingGroup.config }) }, [viewingGroup.config]) const handleBack = useCallback(() => { guard.guardBack(onBack) }, [guard.guardBack, 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 ( <> setShowDeleteConfirm(true), disabled: deletePermissionGroup.isPending, }, ]} >
setConfigTab(value as ConfigTab)} />
{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), destructive: true, }, ]} /> } /> ))}
) )}
)} )} {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 && ( setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)} > {coreBlocksAllAllowed ? 'Deselect All' : 'Select All'} } >
{filteredCoreBlocks.map((block) => { const BlockIcon = block.icon const checkboxId = `block-${block.type}` return ( ) })}
)} {filteredToolBlocks.length > 0 && ( Allow a whole integration with its checkbox, then expand it to deny specific tools while keeping the rest available. } action={ 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'}
{platformCategorySections.map(({ category, features }) => (
{features.map((feature) => (
{feature.hint}
))}
))}
Auth modes public file-share links may use
)}
{ 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...', }} /> ) }