Files
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1684 lines
59 KiB
TypeScript

'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<string>
setSelectedMemberIds: React.Dispatch<React.SetStateAction<Set<string>>>
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 (
<ChipModal
open={open}
onOpenChange={(o) => {
if (!o) setSearchTerm('')
onOpenChange(o)
}}
size='sm'
srTitle='Add Members'
>
<ChipModalHeader onClose={() => onOpenChange(false)}>Add Members</ChipModalHeader>
<ChipModalBody>
{availableMembers.length === 0 ? (
<p className='px-2 text-[var(--text-muted)] text-sm'>
All organization members are already in this group.
</p>
) : (
<ChipModalField type='custom' title='Members'>
<div className='flex flex-col gap-3'>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search members...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
<Chip onClick={handleToggleAll}>
{allFilteredSelected ? 'Deselect All' : 'Select All'}
</Chip>
</div>
<div className='max-h-[280px] overflow-y-auto'>
{filteredMembers.length === 0 ? (
<p className='py-4 text-center text-[var(--text-muted)] text-sm'>
No members found matching "{searchTerm}"
</p>
) : (
<div className='flex flex-col'>
{filteredMembers.map((member) => {
const name = member.user?.name || 'Unknown'
const email = member.user?.email || ''
const isSelected = selectedMemberIds.has(member.userId)
return (
<button
key={member.userId}
type='button'
onClick={() => handleToggleMember(member.userId)}
className='flex items-center gap-2.5 rounded-sm p-2 text-left hover-hover:bg-[var(--surface-active)]'
>
<Checkbox checked={isSelected} />
<MemberAvatar name={name} image={member.user?.image ?? null} />
<div className='min-w-0 flex-1'>
<div className='truncate text-[var(--text-body)] text-sm'>{name}</div>
<div className='truncate text-[var(--text-muted)] text-caption'>
{email}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
</div>
</ChipModalField>
)}
<ChipModalError>{errorMessage}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => {
setSearchTerm('')
onOpenChange(false)
}}
primaryAction={{
label: isAdding ? 'Adding...' : 'Add Members',
onClick: onAddMembers,
disabled: selectedMemberIds.size === 0 || isAdding,
}}
/>
</ChipModal>
)
}
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 <div className='px-2 py-3 text-[var(--text-muted)] text-xs'>Loading</div>
}
if (items.length === 0) {
return <div className='px-2 py-3 text-[var(--text-muted)] text-xs'>{emptyLabel}</div>
}
const allFilteredAllowed = filteredItems.every((item) => isAllowed(item.id))
return (
<div className='flex flex-col gap-2'>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='min-w-0 flex-1'
/>
<Chip
onClick={() =>
onSetDenied(
filteredItems.map((item) => item.id),
allFilteredAllowed
)
}
>
{allFilteredAllowed ? 'Block All' : 'Allow All'}
</Chip>
</div>
<div className='grid grid-cols-2 gap-x-2 gap-y-0.5'>
{filteredItems.map((item) => {
const checkboxId = `denylist-${item.id}`
return (
<label
key={item.id}
htmlFor={checkboxId}
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'
>
<Checkbox
id={checkboxId}
checked={isAllowed(item.id)}
onCheckedChange={() => onToggle(item.id)}
/>
<span className='truncate text-sm'>{item.label}</span>
</label>
)
})}
</div>
</div>
)
}
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 (
<CheckboxGrid
items={items}
isLoading={isPending}
searchPlaceholder='Search models...'
emptyLabel='No models available for this provider.'
{...controls}
/>
)
}
interface StaticProviderModelsProps extends DenylistControls {
providerId: ProviderId
}
function StaticProviderModels({ providerId, ...controls }: StaticProviderModelsProps) {
const items = useMemo(
() => getProviderModels(providerId).map((model) => ({ id: model, label: model })),
[providerId]
)
return (
<CheckboxGrid
items={items}
isLoading={false}
searchPlaceholder='Search models...'
emptyLabel='No models available for this provider.'
{...controls}
/>
)
}
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 (
<div>
<div className='flex items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'>
<Checkbox
id={checkboxId}
checked={isProviderAllowed}
onCheckedChange={() => onToggleProvider()}
/>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
{ProviderIcon && <ProviderIcon className='!h-[16px] !w-[16px]' />}
</div>
<button
type='button'
onClick={() => isProviderAllowed && setExpanded((prev) => !prev)}
disabled={!isProviderAllowed}
className={cn(
'flex flex-1 items-center gap-2 text-left',
isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60'
)}
>
<span className='truncate font-medium text-sm'>{providerName}</span>
{isProviderAllowed && deniedCount > 0 && (
<ChipTag variant='gray' className='flex-shrink-0'>
{deniedCount} blocked
</ChipTag>
)}
{isProviderAllowed && (
<ChevronDown
className={cn(
'ml-auto size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
)}
</button>
</div>
{expanded && isProviderAllowed && (
<div className='border-[var(--border)] border-t px-2 pt-2 pb-3'>
{isDynamic ? (
<DynamicProviderModels
provider={providerId as ProviderName}
workspaceId={workspaceId}
{...controls}
/>
) : (
<StaticProviderModels providerId={providerId} {...controls} />
)}
</div>
)}
</div>
)
}
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<DenylistGridItem[]>(
() => (block.tools?.access ?? []).map((id) => ({ id, label: getTool(id)?.name ?? id })),
[block.tools?.access]
)
const isExpandable = toolItems.length > 1
return (
<div>
<div className='flex items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'>
<Checkbox
id={checkboxId}
checked={isBlockAllowed}
onCheckedChange={() => onToggleBlock()}
/>
<div
className='relative flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: block.bgColor }}
>
{BlockIcon && <BlockIcon className='!h-[10px] !w-[10px] text-white' />}
</div>
<button
type='button'
onClick={() => isBlockAllowed && isExpandable && setExpanded((prev) => !prev)}
disabled={!isBlockAllowed || !isExpandable}
className={cn(
'flex flex-1 items-center gap-2 text-left',
isBlockAllowed && isExpandable ? 'cursor-pointer' : 'cursor-default',
!isBlockAllowed && 'opacity-60'
)}
>
<span className='truncate font-medium text-sm'>{block.name}</span>
{isBlockAllowed && deniedCount > 0 && (
<ChipTag variant='gray' className='flex-shrink-0'>
{deniedCount} blocked
</ChipTag>
)}
{isBlockAllowed && isExpandable && (
<ChevronDown
className={cn(
'ml-auto size-[14px] flex-shrink-0 text-[var(--text-icon)] transition-transform',
expanded && 'rotate-180'
)}
/>
)}
</button>
</div>
{expanded && isBlockAllowed && isExpandable && (
<div className='border-[var(--border)] border-t px-2 pt-2 pb-3'>
<CheckboxGrid
items={toolItems}
isLoading={false}
searchPlaceholder='Search tools...'
emptyLabel='This integration exposes no configurable tools.'
{...controls}
/>
</div>
)}
</div>
)
}
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<PermissionGroup>(group)
const [editingConfig, setEditingConfig] = useState<PermissionGroupConfig>({ ...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<ConfigTab>('general')
const [providerSearchTerm, setProviderSearchTerm] = useState('')
const [integrationSearchTerm, setIntegrationSearchTerm] = useState('')
const [platformSearchTerm, setPlatformSearchTerm] = useState('')
const [showAddMembersModal, setShowAddMembersModal] = useState(false)
const [addMembersError, setAddMembersError] = useState<string | null>(null)
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(() => 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<string, string[]> = {}
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<string, typeof platformFeatures> = {}
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<OrganizationMemberOption[]>(() => {
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<string, number> = {}
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<string, number> = {}
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 (
<>
<SettingsPanel
back={{ text: 'Access control', icon: ArrowLeft, onSelect: handleBack }}
title={viewingGroup.name}
description={viewingGroup.description ?? undefined}
actions={[
...saveDiscardActions({
dirty: hasConfigChanges,
saving: updatePermissionGroup.isPending,
onSave: handleSaveConfig,
onDiscard: handleDiscardConfig,
}),
{
text: deletePermissionGroup.isPending ? 'Deleting...' : 'Delete',
variant: 'destructive',
onSelect: () => setShowDeleteConfirm(true),
disabled: deletePermissionGroup.isPending,
},
]}
>
<div className='sticky top-0 z-10 bg-[var(--bg)]'>
<ChipModalTabs
tabs={tabs}
value={configTab}
onChange={(value) => setConfigTab(value as ConfigTab)}
/>
</div>
{configTab === 'general' && (
<>
<SettingsSection label='Default group'>
<div className='flex items-center justify-between gap-3'>
<span className='text-[var(--text-muted)] text-small'>
Applies to everyone in the organization not assigned to another group, including
external workspace members
</span>
<Switch
checked={viewingGroup.isDefault}
onCheckedChange={(checked) => handleToggleDefault(checked)}
disabled={updatePermissionGroup.isPending}
/>
</div>
</SettingsSection>
<SettingsSection label='Workspaces'>
{viewingGroup.isDefault ? (
<div className='flex items-center justify-between gap-3'>
<span className='text-[var(--text-muted)] text-small'>
Governs every workspace in the organization
</span>
</div>
) : (
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-3'>
<span className='min-w-0 text-[var(--text-muted)] text-small'>
{viewingGroup.workspaces.length > 0
? `Governs ${viewingGroup.workspaces.length} workspace${
viewingGroup.workspaces.length === 1 ? '' : 's'
}`
: 'Select the workspaces this group governs'}
</span>
<WorkspaceSelect
workspaceIds={viewingGroup.workspaces.map((ws) => ws.id)}
onChange={handleScopeChange}
options={workspaceOptions}
isLoading={workspacesLoading}
allowAllWorkspaces={false}
className='flex-shrink-0'
/>
</div>
{viewingGroup.workspaces.length > 0 && (
<div className='-mx-2 flex flex-col gap-y-0.5'>
{viewingGroup.workspaces.map((ws) => (
<MemberRow
key={ws.id}
name={ws.name}
email={ws.name}
image={null}
status=''
/>
))}
</div>
)}
</div>
)}
</SettingsSection>
{!viewingGroup.isDefault && (
<SettingsSection label='Members'>
<div className='flex flex-col gap-3'>
<div className='flex items-center justify-between gap-3'>
<span className='text-[var(--text-muted)] text-small'>
{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'}`}
</span>
<Chip
variant='primary'
leftIcon={Plus}
onClick={handleOpenAddMembersModal}
className='flex-shrink-0'
>
Add
</Chip>
</div>
{membersLoading ? (
<div className='-mx-2 flex flex-col gap-y-0.5'>
{[1, 2].map((i) => (
<div key={i} className='flex items-center gap-2.5 p-2'>
<Skeleton className='size-[14px] flex-shrink-0 rounded-full' />
<Skeleton className='h-[14px] w-[180px]' />
</div>
))}
</div>
) : (
members.length > 0 && (
<div className='-mx-2 flex flex-col gap-y-0.5'>
{members.map((member) => (
<MemberRow
key={member.id}
name={member.userName || member.userEmail || 'Unknown'}
email={member.userEmail || member.userName || 'Unknown'}
image={member.userImage}
status={`Added ${formatDate(new Date(member.assignedAt))}`}
menu={
<RowActionsMenu
label='Member actions'
actions={[
{
label: 'Remove',
onSelect: () => handleRemoveMember(member.id),
destructive: true,
},
]}
/>
}
/>
))}
</div>
)
)}
</div>
</SettingsSection>
)}
</>
)}
{configTab === 'providers' && (
<div className='flex flex-col gap-7'>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search providers...'
value={providerSearchTerm}
onChange={(e) => setProviderSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
<Chip
onClick={() => setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)}
>
{filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
</Chip>
</div>
<div className='flex flex-col gap-0.5'>
{filteredProviders.map((providerId) => (
<ProviderRow
key={providerId}
providerId={providerId}
isProviderAllowed={isProviderAllowed(providerId)}
onToggleProvider={() => toggleProvider(providerId)}
deniedCount={deniedCountByProvider[providerId] ?? 0}
workspaceId={workspaceId}
isAllowed={isModelAllowed}
onToggle={toggleModel}
onSetDenied={setModelsDenied}
/>
))}
</div>
</div>
)}
{configTab === 'blocks' && (
<div className='flex flex-col gap-7'>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search blocks...'
value={integrationSearchTerm}
onChange={(e) => setIntegrationSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
</div>
{filteredCoreBlocks.length > 0 && (
<SettingsSection
label='Core Blocks'
action={
<Chip
flush
onClick={() => setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)}
>
{coreBlocksAllAllowed ? 'Deselect All' : 'Select All'}
</Chip>
}
>
<div className='grid grid-cols-3 gap-x-2 gap-y-0.5'>
{filteredCoreBlocks.map((block) => {
const BlockIcon = block.icon
const checkboxId = `block-${block.type}`
return (
<label
key={block.type}
htmlFor={checkboxId}
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'
>
<Checkbox
id={checkboxId}
checked={isIntegrationAllowed(block.type)}
onCheckedChange={() => toggleIntegration(block.type)}
/>
<div
className='relative flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: block.bgColor }}
>
{BlockIcon && <BlockIcon className='!h-[10px] !w-[10px] text-white' />}
</div>
<span className='truncate font-medium text-sm'>{block.name}</span>
</label>
)
})}
</div>
</SettingsSection>
)}
{filteredToolBlocks.length > 0 && (
<SettingsSection
label='Integrations and Triggers'
headerAccessory={
<Info side='top'>
Allow a whole integration with its checkbox, then expand it to deny specific
tools while keeping the rest available.
</Info>
}
action={
<Chip
flush
onClick={() => setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)}
>
{toolBlocksAllAllowed ? 'Deselect All' : 'Select All'}
</Chip>
}
>
<div className='flex flex-col gap-0.5'>
{filteredToolBlocks.map((block) => (
<BlockToolRow
key={block.type}
block={block}
isBlockAllowed={isIntegrationAllowed(block.type)}
onToggleBlock={() => toggleIntegration(block.type)}
deniedCount={deniedCountByBlock[block.type] ?? 0}
isAllowed={isToolAllowed}
onToggle={toggleTool}
onSetDenied={setToolsDenied}
/>
))}
</div>
</SettingsSection>
)}
</div>
)}
{configTab === 'platform' && (
<div className='flex flex-col gap-7'>
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
placeholder='Search features...'
value={platformSearchTerm}
onChange={(e) => setPlatformSearchTerm(e.target.value)}
className='min-w-0 flex-1'
/>
<Chip
onClick={() =>
setEditingConfig((prev) => ({
...prev,
...Object.fromEntries(
filteredPlatformFeatures.map((f) => [f.configKey, platformAllVisible])
),
}))
}
>
{platformAllVisible ? 'Deselect All' : 'Select All'}
</Chip>
</div>
{platformCategorySections.map(({ category, features }) => (
<SettingsSection key={category} label={category}>
<div className='flex flex-col gap-0.5'>
{features.map((feature) => (
<div key={feature.id} className='flex items-center gap-1.5'>
<label
htmlFor={feature.id}
className='flex flex-1 cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'
>
<Checkbox
id={feature.id}
checked={!editingConfig[feature.configKey]}
onCheckedChange={(checked) =>
setEditingConfig((prev) => ({
...prev,
[feature.configKey]: checked !== true,
}))
}
/>
<span className='font-normal text-sm'>{feature.label}</span>
</label>
<Info side='top'>{feature.hint}</Info>
</div>
))}
</div>
</SettingsSection>
))}
<SettingsSection label='Files'>
<div className='flex flex-col gap-1.5'>
<label
htmlFor='disable-public-file-sharing'
className='flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] transition-colors hover-hover:bg-[var(--surface-active)]'
>
<Checkbox
id='disable-public-file-sharing'
checked={!editingConfig.disablePublicFileSharing}
onCheckedChange={(checked) =>
setEditingConfig((prev) => ({
...prev,
disablePublicFileSharing: checked !== true,
}))
}
/>
<span className='font-normal text-sm'>Public Sharing</span>
</label>
<div
className={cn(
'flex flex-col gap-1.5 px-2 pt-1',
editingConfig.disablePublicFileSharing && 'opacity-50'
)}
>
<span className='text-[var(--text-secondary)] text-xs'>
Auth modes public file-share links may use
</span>
<ChipDropdown
multiple
showAllOption={false}
allLabel='None'
value={fileShareAuthValue}
onChange={setFileShareAuthTypes}
options={FILE_SHARE_AUTH_TYPE_OPTIONS}
disabled={editingConfig.disablePublicFileSharing}
matchTriggerWidth={false}
className='w-[200px]'
/>
</div>
</div>
</SettingsSection>
</div>
)}
</SettingsPanel>
<AddMembersModal
open={showAddMembersModal}
onOpenChange={(open) => {
setShowAddMembersModal(open)
if (!open) setAddMembersError(null)
}}
availableMembers={availableMembersToAdd}
selectedMemberIds={selectedMemberIds}
setSelectedMemberIds={setSelectedMemberIds}
onAddMembers={handleAddSelectedMembers}
isAdding={bulkAddMembers.isPending}
errorMessage={addMembersError}
/>
<ChipConfirmModal
open={showDeleteConfirm}
onOpenChange={() => 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...',
}}
/>
<UnsavedChangesModal
open={guard.showUnsavedModal}
onOpenChange={guard.setShowUnsavedModal}
onDiscard={guard.confirmDiscard}
/>
</>
)
}