d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
1684 lines
59 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
)
|
|
}
|