void accessRequests.refetch()}>Try again}
+ />
+ )
+ }
+ if (!accessRequests.data) {
+ return
+ }
+ }
+
const requestable = access === 'requestable' && accessRequests.data?.enabled === true
return (
@@ -102,7 +116,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
description={
requestable
? 'Ask an administrator for access.'
- : 'Your organization restricts this integration.'
+ : access === 'requestable' && accessRequests.data?.enabled === false
+ ? 'Access requests are paused for your organization.'
+ : 'Your organization restricts this integration.'
}
action={
requestable ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
index dc146ded97f..7817b234ae4 100644
--- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
@@ -80,6 +80,7 @@ interface IntegrationItemProps {
description?: string | null
icon: ComponentType<{ className?: string }>
unavailable?: boolean
+ restricted?: boolean
/** Set for an integration the viewer's policy restricts but lets them request. */
onRequestAccess?: () => void
}
@@ -92,9 +93,9 @@ function IntegrationItem({
description,
icon: Icon,
unavailable = false,
+ restricted = false,
onRequestAccess,
}: IntegrationItemProps) {
- const restricted = onRequestAccess !== undefined
return (
) : undefined
}
- href={restricted ? undefined : `/workspace/${workspaceId}/integrations/${slug}`}
+ href={onRequestAccess ? undefined : `/workspace/${workspaceId}/integrations/${slug}`}
onClick={onRequestAccess}
- clickLabel={restricted ? `Request access to ${name}` : `Open ${name}`}
+ clickLabel={onRequestAccess ? `Request access to ${name}` : `Open ${name}`}
navigable={!unavailable && !restricted}
disabled={unavailable}
/>
@@ -248,15 +249,15 @@ function IntegrationsContent() {
/**
* Usable and unconfigured integrations are listed, the latter as unavailable;
- * a requestable one is listed as a locked row while access requests are on.
+ * a requestable one stays listed as a locked row while requests are paused.
* Nothing is listed until the access policy has loaded.
*/
const isListed = useCallback(
(blockType: string) => {
const access = getAccess(blockType)
- return isIntegrationPermitted(access) || (access === 'requestable' && accessRequestsEnabled)
+ return isIntegrationPermitted(access) || access === 'requestable'
},
- [getAccess, accessRequestsEnabled]
+ [getAccess]
)
const listedFeatured = useMemo(
@@ -469,8 +470,9 @@ function IntegrationsContent() {
description={integration.description}
icon={Icon}
unavailable={access === 'unconfigured'}
+ restricted={access === 'requestable'}
onRequestAccess={
- access === 'requestable'
+ access === 'requestable' && accessRequestsEnabled
? () => setRequestedIntegration(integration)
: undefined
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
index a877fd6668b..da789dc10ab 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
@@ -178,7 +178,13 @@ export const groupSearchUrlKeys = {
*/
export const groupStatusParam = {
key: 'group-status',
- parser: parseAsStringLiteral(['all', 'enabled', 'disabled'] as const).withDefault('all'),
+ parser: parseAsStringLiteral([
+ 'all',
+ 'enabled',
+ 'disabled',
+ 'requestable',
+ 'unavailable',
+ ] as const).withDefault('all'),
} as const
/** Filter view-state: clean URLs, no back-stack churn. */
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx
index e0c7ddbc74b..082e16dc3fb 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx
@@ -90,6 +90,8 @@ interface SettingsResourceRowProps {
* the bleed would force a horizontal scrollbar.
*/
flush?: boolean
+ /** Compact single-line controls retain a 40px row without changing the default resource layout. */
+ density?: 'default' | 'compact'
/** Renders the row as unavailable without an activation target. */
disabled?: boolean
}
@@ -133,6 +135,7 @@ export function SettingsResourceRow({
clickLabel,
navigable = false,
flush = false,
+ density = 'default',
disabled = false,
}: SettingsResourceRowProps) {
const describedById = useId()
@@ -202,7 +205,9 @@ export function SettingsResourceRow({
// mixing clickable and static rows keeps one height and one inset.
const rowClass = cn(
'flex items-center justify-between gap-2.5',
- !flush && '-mx-2 rounded-lg p-2',
+ !flush && '-mx-2 rounded-lg',
+ !flush && (density === 'compact' ? 'px-2' : 'p-2'),
+ density === 'compact' && 'min-h-10 max-md:pointer-coarse:min-h-11',
disabled && 'opacity-50'
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
index 47d0512f5e0..a58e354c99b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
@@ -39,7 +39,6 @@ import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getCustomBlockTile } from '@/blocks/custom/custom-block-icon'
import { getCanonicalBlocksByCategory } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
-import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary'
import { RequestAccessModal } from '@/ee/access-requests/components/request-access-action'
import { useOrgBrandConfig } from '@/ee/whitelabeling/components/branding-provider'
import { useCustomBlocks } from '@/hooks/queries/custom-blocks'
@@ -123,7 +122,7 @@ const ToolbarItem = memo(function ToolbarItem({
(null)
const sandboxAllowedBlocks = useSandboxBlockConstraints()
@@ -498,10 +495,7 @@ export const Toolbar = memo(
allTools.find((item) => item.type === requestedBlockType))
: undefined
- if (
- requestedBlockType !== null &&
- (!requestedBlock || !workspaceId || !accessRequestsEnabled)
- ) {
+ if (requestedBlockType !== null && (!requestedBlock || !workspaceId)) {
setRequestedBlockType(null)
}
@@ -535,12 +529,9 @@ export const Toolbar = memo(
.sort((a, b) => a.name.localeCompare(b.name))
}, [customBlocksData, currentWorkflowId, fallbackIconUrl])
- const handleRequestItemClick = useCallback(
- (type: string) => {
- if (accessRequestsEnabled) setRequestedBlockType(type)
- },
- [accessRequestsEnabled]
- )
+ const handleRequestItemClick = useCallback((type: string) => {
+ setRequestedBlockType(type)
+ }, [])
const visibleTriggers = useMemo(() => {
if (sandboxAllowedBlocks !== null) return []
@@ -566,7 +557,6 @@ export const Toolbar = memo(
}, [filterBlocks, allTools, sandboxAllowedBlocks])
const restrictedItems = useMemo((): RestrictedBlockItem[] => {
- if (!accessRequestsEnabled) return []
const categories = [
{ section: 'triggers', items: sandboxAllowedBlocks === null ? allTriggers : [] },
{ section: 'blocks', items: allBlocks },
@@ -582,14 +572,7 @@ export const Toolbar = memo(
)
.map((item) => ({ ...item, restricted: true, section }))
)
- }, [
- accessRequestsEnabled,
- allTriggers,
- allBlocks,
- allTools,
- isBlockRequestable,
- sandboxAllowedBlocks,
- ])
+ }, [allTriggers, allBlocks, allTools, isBlockRequestable, sandboxAllowedBlocks])
const normalizedQuery = searchQuery.trim().toLowerCase()
const isSearching = normalizedQuery.length > 0
@@ -894,7 +877,7 @@ export const Toolbar = memo(
- {requestedBlock && workspaceId && accessRequestsEnabled && (
+ {requestedBlock && workspaceId && (
state.initializeData)
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
@@ -769,7 +769,9 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
// on a workflow the server declined to create.
hidden:
(!chatEnabled && !permissionsLoading && !canEdit) ||
- (chatEnabled && permissionConfig.hideCopilot && !accessRequestsEnabled),
+ (chatEnabled &&
+ permissionConfig.hideCopilot &&
+ !requestableFeatureKeys.has('hideCopilot')),
restricted: chatEnabled && permissionConfig.hideCopilot,
},
{
@@ -777,7 +779,9 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
label: 'Dashboard',
icon: Dashboard,
href: `/workspace/${workspaceId}/dashboards`,
- hidden: !dashboardsEnabled || (permissionConfig.hideFilesTab && !accessRequestsEnabled),
+ hidden:
+ !dashboardsEnabled ||
+ (permissionConfig.hideFilesTab && !requestableFeatureKeys.has('hideFilesTab')),
restricted: permissionConfig.hideFilesTab,
},
{
@@ -785,7 +789,9 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
label: 'Changelog',
icon: Rss,
href: `/workspace/${workspaceId}/changelog`,
- hidden: !changelogEnabled || (permissionConfig.hideFilesTab && !accessRequestsEnabled),
+ hidden:
+ !changelogEnabled ||
+ (permissionConfig.hideFilesTab && !requestableFeatureKeys.has('hideFilesTab')),
restricted: permissionConfig.hideFilesTab,
},
{
@@ -794,7 +800,9 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
icon: Integration,
href: `/workspace/${workspaceId}/integrations`,
additionalActivePaths: [`/workspace/${workspaceId}/skills`],
- hidden: permissionConfig.hideIntegrationsTab && !accessRequestsEnabled,
+ hidden:
+ permissionConfig.hideIntegrationsTab &&
+ !requestableFeatureKeys.has('hideIntegrationsTab'),
restricted: permissionConfig.hideIntegrationsTab,
},
].filter((item) => !item.hidden),
@@ -806,7 +814,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
permissionConfig.hideIntegrationsTab,
permissionConfig.hideCopilot,
permissionConfig.hideFilesTab,
- accessRequestsEnabled,
+ requestableFeatureKeys,
chatEnabled,
dashboardsEnabled,
changelogEnabled,
@@ -821,7 +829,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
label: 'Tables',
icon: Table,
href: `/workspace/${workspaceId}/tables`,
- hidden: permissionConfig.hideTablesTab && !accessRequestsEnabled,
+ hidden: permissionConfig.hideTablesTab && !requestableFeatureKeys.has('hideTablesTab'),
restricted: permissionConfig.hideTablesTab,
},
{
@@ -829,7 +837,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
label: 'Files',
icon: Files,
href: `/workspace/${workspaceId}/files`,
- hidden: permissionConfig.hideFilesTab && !accessRequestsEnabled,
+ hidden: permissionConfig.hideFilesTab && !requestableFeatureKeys.has('hideFilesTab'),
restricted: permissionConfig.hideFilesTab,
},
{
@@ -837,7 +845,9 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
label: 'Knowledge bases',
icon: Database,
href: `/workspace/${workspaceId}/knowledge`,
- hidden: permissionConfig.hideKnowledgeBaseTab && !accessRequestsEnabled,
+ hidden:
+ permissionConfig.hideKnowledgeBaseTab &&
+ !requestableFeatureKeys.has('hideKnowledgeBaseTab'),
restricted: permissionConfig.hideKnowledgeBaseTab,
},
{
@@ -845,7 +855,9 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
label: 'Tests',
icon: ShieldCheck,
href: `/workspace/${workspaceId}/tests`,
- hidden: !testsEnabled || (permissionConfig.hideFilesTab && !accessRequestsEnabled),
+ hidden:
+ !testsEnabled ||
+ (permissionConfig.hideFilesTab && !requestableFeatureKeys.has('hideFilesTab')),
restricted: permissionConfig.hideFilesTab,
},
{
@@ -861,7 +873,7 @@ export const Sidebar = memo(function Sidebar({ organizationHref, desktopExecutor
permissionConfig.hideFilesTab,
permissionConfig.hideKnowledgeBaseTab,
permissionConfig.hideTablesTab,
- accessRequestsEnabled,
+ requestableFeatureKeys,
testsEnabled,
]
)
diff --git a/apps/sim/ee/access-control/components/access-state-select.tsx b/apps/sim/ee/access-control/components/access-state-select.tsx
new file mode 100644
index 00000000000..ed5d99fb934
--- /dev/null
+++ b/apps/sim/ee/access-control/components/access-state-select.tsx
@@ -0,0 +1,69 @@
+import { ChipDropdown } from '@sim/emcn'
+import type { PermissionGroupAccessState } from '@/ee/access-requests/lib/targets'
+
+const ACCESS_OPTIONS = [
+ { value: 'allowed', label: 'Allowed' },
+ { value: 'requestable', label: 'Can request' },
+ { value: 'unavailable', label: 'Unavailable' },
+] as const
+
+interface AccessStateSelectProps {
+ label: string
+ value: PermissionGroupAccessState
+ onChange: (state: PermissionGroupAccessState) => void
+ disabled?: boolean
+ canRequest?: boolean
+}
+
+/** Selects a group policy state, retaining unsupported inherited values while disabled. */
+export function AccessStateSelect({
+ label,
+ value,
+ onChange,
+ disabled,
+ canRequest = true,
+}: AccessStateSelectProps) {
+ return (
+ option.value === value)?.label}`}
+ value={value}
+ onChange={(next) => onChange(next as PermissionGroupAccessState)}
+ options={
+ canRequest
+ ? ACCESS_OPTIONS
+ : ACCESS_OPTIONS.filter(
+ (option) => option.value !== 'requestable' || (disabled && value === 'requestable')
+ )
+ }
+ disabled={disabled}
+ matchTriggerWidth={false}
+ className='w-[132px] shrink-0'
+ />
+ )
+}
+
+interface BulkAccessSelectProps {
+ onChange: (state: PermissionGroupAccessState) => void
+ disabled?: boolean
+ canRequest?: boolean
+}
+
+/** Emits a bulk policy choice; the caller selects targets and whether they support requests. */
+export function BulkAccessSelect({ onChange, disabled, canRequest = true }: BulkAccessSelectProps) {
+ return (
+ onChange(next as PermissionGroupAccessState)}
+ options={
+ canRequest
+ ? ACCESS_OPTIONS
+ : ACCESS_OPTIONS.filter((option) => option.value !== 'requestable')
+ }
+ disabled={disabled}
+ matchTriggerWidth={false}
+ className='w-[140px] shrink-0 max-sm:min-w-0 max-sm:flex-1'
+ />
+ )
+}
diff --git a/apps/sim/ee/access-control/components/group-detail.test.tsx b/apps/sim/ee/access-control/components/group-detail.test.tsx
index 6f8b7e91278..7ac49f3411c 100644
--- a/apps/sim/ee/access-control/components/group-detail.test.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.test.tsx
@@ -92,14 +92,26 @@ function render(currentGroup = group) {
)
}
+function providerControl(provider: string) {
+ return container.querySelector(`button[aria-label^="${provider}: "]`)
+}
+
+function setProviderState(provider: string, state: 'Allowed' | 'Can request' | 'Unavailable') {
+ const trigger = providerControl(provider)
+ if (!trigger) throw new Error(`Missing ${provider} access control`)
+ act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
+ const option = [...document.querySelectorAll('[role="menuitem"]')].find(
+ (node) => node.textContent?.trim() === state
+ )
+ if (!option) throw new Error(`Missing ${state} option`)
+ act(() => option.click())
+}
+
function expectNoProviderControls() {
- expect(container.querySelector('[id^="provider-"]')).toBeNull()
+ for (const provider of ['openai', 'anthropic', 'google'])
+ expect(providerControl(provider)).toBeNull()
expect(container.querySelector('input[placeholder="Search providers..."]')).toBeNull()
- expect(
- [...container.querySelectorAll('button')].some((button) =>
- /^(De)?select All$/i.test(button.textContent ?? '')
- )
- ).toBe(false)
+ expect(container.querySelector('button[aria-label="Set shown permissions to"]')).toBeNull()
}
async function settle() {
@@ -135,9 +147,7 @@ describe('permission group settings availability', () => {
)
)
await settle()
- const toggle = container.querySelector('#provider-openai')
- if (!toggle) throw new Error('Authorized group editor was blocked by an unrelated read')
- act(() => toggle.click())
+ setProviderState('openai', 'Can request')
let left = false
act(() =>
useSettingsDirtyStore.getState().requestLeave(() => {
@@ -149,9 +159,7 @@ describe('permission group settings availability', () => {
await client.invalidateQueries({ queryKey: permissionGroupKeys.list('org-1') })
})
await settle()
- const refreshedToggle = container.querySelector('#provider-openai')
- if (!refreshedToggle) throw new Error('Group editor disappeared after refresh')
- expect(refreshedToggle.getAttribute('aria-checked')).toBe('false')
+ expect(providerControl('openai')?.getAttribute('aria-label')).toBe('openai: Can request')
left = false
act(() =>
useSettingsDirtyStore.getState().requestLeave(() => {
@@ -172,9 +180,7 @@ describe('provider permission policy availability', () => {
...group,
config: { ...group.config, allowedModelProviders: ['openai', 'anthropic'] },
})
- const toggle = container.querySelector('#provider-openai')
- if (!toggle) throw new Error('Missing provider toggle')
- act(() => toggle.click())
+ setProviderState('openai', 'Can request')
let left = false
act(() =>
useSettingsDirtyStore.getState().requestLeave(() => {
@@ -182,7 +188,7 @@ describe('provider permission policy availability', () => {
})
)
expect(left).toBe(false)
- act(() => toggle.click())
+ setProviderState('openai', 'Allowed')
act(() =>
useSettingsDirtyStore.getState().requestLeave(() => {
left = true
@@ -235,13 +241,12 @@ describe('provider permission policy availability', () => {
})
await settle()
expect(container.querySelector('[role="alert"]')).toBeNull()
- const anthropic = container.querySelector('#provider-anthropic')
- expect(anthropic?.getAttribute('aria-checked')).toBe('true')
- expect(Boolean(container.querySelector('#provider-openai'))).toBe(
- !blacklist.includes('openai')
+ expect(providerControl('anthropic')?.getAttribute('aria-label')).toBe('anthropic: Allowed')
+ expect(Boolean(providerControl('openai'))).toBe(!blacklist.includes('openai'))
+ setProviderState('anthropic', 'Can request')
+ expect(providerControl('anthropic')?.getAttribute('aria-label')).toBe(
+ 'anthropic: Can request'
)
- act(() => anthropic!.click())
- expect(anthropic?.getAttribute('aria-checked')).toBe('false')
}
)
})
diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx
index a061a333d9e..13e3572dfe1 100644
--- a/apps/sim/ee/access-control/components/group-detail.tsx
+++ b/apps/sim/ee/access-control/components/group-detail.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
import {
Avatar,
Checkbox,
@@ -15,16 +15,13 @@ import {
ChipModalFooter,
ChipModalHeader,
ChipModalTabs,
- ChipTag,
- cn,
Info,
OverflowText,
- Search,
Skeleton,
Switch,
toast,
} from '@sim/emcn'
-import { ArrowLeft, ChevronDown, Plus } from '@sim/emcn/icons'
+import { ArrowLeft, Plus, Search } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDate } from '@sim/utils/formatting'
@@ -36,7 +33,6 @@ import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
import { isAccessControlAllowlistRow } from '@/lib/permission-groups/block-access'
import {
isFeatureInertForGroup,
- ORGANIZATION_SCOPED_FEATURE_NOTE,
PLATFORM_CATEGORY_ORDER,
PLATFORM_FEATURES,
} from '@/lib/permission-groups/features'
@@ -60,9 +56,15 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { getAllBlocks } from '@/blocks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
-import type { BlockConfig } from '@/blocks/types'
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import { AgentDefault } from '@/ee/access-control/components/agent-default'
+import {
+ BlockToolRow,
+ PermissionBulkSelect,
+ PermissionGrid,
+ PlatformPermissionRow,
+ ProviderRow,
+} from '@/ee/access-control/components/permission-rows'
import { ProjectIssueRestrictions } from '@/ee/access-control/components/project-issue-restrictions'
import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
import {
@@ -74,25 +76,22 @@ import {
useRemovePermissionGroupMember,
useUpdatePermissionGroup,
} from '@/ee/access-control/hooks/permission-groups'
+import { withPermissionGroupAccessState } from '@/ee/access-control/utils/access-state'
import {
- allowlistRowsFromStored,
- toggleAllowlistRow,
- withAllowlistRows,
-} from '@/ee/access-control/utils/integration-allowlist-rows'
+ type AccessRequestTarget,
+ createAccessRequestCatalog,
+ describeAccessRequestTarget,
+ getAccessRequestTargetParents,
+ getPermissionGroupAccessState,
+ type PermissionGroupAccessState,
+ validateAccessRequestTarget,
+} from '@/ee/access-requests/lib/targets'
import { SettingRow } from '@/ee/components/setting-row'
import { useBlacklistedProviders } from '@/hooks/queries/allowed-providers'
import { useOrganizationRoster } from '@/hooks/queries/organization'
-import { useProviderModels } from '@/hooks/queries/providers'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
-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 { getToolMetadata } from '@/tools/metadata'
+import { getStaticProviderModels, PROVIDER_DEFINITIONS } from '@/providers/models'
+import { getAllProviderIds } from '@/providers/utils'
const logger = createLogger('AccessControlGroupDetail')
@@ -108,7 +107,6 @@ const FILE_SHARE_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] =
{ value: 'email', label: 'Email' },
{ value: 'sso', label: 'SSO' },
]
-const ALL_FILE_SHARE_AUTH_TYPES: ShareAuthType[] = FILE_SHARE_AUTH_TYPE_OPTIONS.map((o) => o.value)
/** Chat-deployment auth modes an admin can allow/disallow. `null` config = all allowed. */
const CHAT_DEPLOY_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] = [
@@ -117,9 +115,6 @@ const CHAT_DEPLOY_AUTH_TYPE_OPTIONS: { value: ShareAuthType; label: string }[] =
{ value: 'email', label: 'Email' },
{ value: 'sso', label: 'SSO' },
]
-const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTIONS.map(
- (o) => o.value
-)
/**
* Knowledge base connectors an admin can allow or disallow. `null` config = all
@@ -134,18 +129,24 @@ const KNOWLEDGE_CONNECTOR_OPTIONS: { value: string; label: string }[] = Object.v
.map((meta) => ({ value: meta.id, label: meta.name }))
.sort((a, b) => a.label.localeCompare(b.label))
-type StatusFilter = 'all' | 'enabled' | 'disabled'
-
-const ALL_KNOWLEDGE_CONNECTORS: string[] = KNOWLEDGE_CONNECTOR_OPTIONS.map((o) => o.value)
+type StatusFilter = 'all' | 'enabled' | 'disabled' | 'requestable' | 'unavailable'
const STATUS_FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: 'all', label: 'Show all' },
- { value: 'enabled', label: 'Show enabled' },
- { value: 'disabled', label: 'Show disabled' },
+ { value: 'enabled', label: 'Allowed' },
+ { value: 'requestable', label: 'Can request' },
+ { value: 'unavailable', label: 'Unavailable' },
]
-function matchesStatusFilter(filter: StatusFilter, enabled: boolean) {
- return filter === 'all' || (filter === 'enabled') === enabled
+function matchesStatusFilter(filter: StatusFilter, state: PermissionGroupAccessState) {
+ return (
+ filter === 'all' ||
+ (filter === 'enabled'
+ ? state === 'allowed'
+ : filter === 'disabled'
+ ? state !== 'allowed'
+ : state === filter)
+ )
}
interface StatusFilterChipProps {
@@ -153,62 +154,22 @@ interface StatusFilterChipProps {
onChange: (value: StatusFilter) => void
}
-/** The All/Enabled/Disabled narrowing control shared by the three list tabs. */
function StatusFilterChip({ value, onChange }: StatusFilterChipProps) {
return (
onChange(next as StatusFilter)}
- options={STATUS_FILTER_OPTIONS}
+ options={
+ value === 'disabled'
+ ? [...STATUS_FILTER_OPTIONS, { value: 'disabled', label: 'Restricted' }]
+ : STATUS_FILTER_OPTIONS
+ }
matchTriggerWidth={false}
- className='w-[140px] shrink-0'
+ className='w-[140px] shrink-0 max-sm:min-w-0 max-sm:flex-1'
/>
)
}
-interface AllowlistFieldProps {
- label: string
- value: string[]
- onChange: (values: string[]) => void
- options: { value: string; label: string }[]
- disabled: boolean
-}
-
-/**
- * The allowed-values multi-select nested under a platform toggle. Dims and
- * disables together with the toggle that owns it. The left padding lines both
- * children up with the parent's label text — row gutter (8) + checkbox (16) +
- * gap (8) = 32 — so the field reads as subordinate rather than as a sibling row.
- */
-function AllowlistField({ label, value, onChange, options, disabled }: AllowlistFieldProps) {
- const labelId = useId()
- const triggerId = useId()
- return (
-
-
- {label}
-
-
-
- )
-}
-
interface OrganizationMemberOption {
userId: string
user: {
@@ -304,7 +265,7 @@ function AddMembersModal({
) : (
-
+
boolean
- onToggle: (id: string) => void
- onSetDenied: (ids: string[], denied: boolean) => void
-}
-
-interface CheckboxGridProps extends DenylistControls {
- items: DenylistGridItem[]
- isLoading: boolean
- searchPlaceholder: string
- emptyLabel: string
-}
-
-/**
- * Searchable two-column checkbox grid over a denylist. A checked item is
- * allowed; unchecking adds it to the denylist. Shared by the model deny-list
- * (per provider) and the tool deny-list (per integration block).
- */
-function CheckboxGrid({
- items,
- isLoading,
- searchPlaceholder,
- emptyLabel,
- isAllowed,
- onToggle,
- onSetDenied,
-}: CheckboxGridProps) {
- const [search, setSearch] = useState('')
-
- const sortedItems = useMemo(
- () => [...items].sort((a, b) => a.label.localeCompare(b.label)),
- [items]
- )
-
- const filteredItems = useMemo(() => {
- if (!search.trim()) return sortedItems
- const query = search.toLowerCase()
- return sortedItems.filter(
- (item) => item.label.toLowerCase().includes(query) || item.id.toLowerCase().includes(query)
- )
- }, [sortedItems, search])
-
- if (isLoading) {
- return Loading…
- }
-
- if (items.length === 0) {
- return {emptyLabel}
- }
-
- const allFilteredAllowed = filteredItems.every((item) => isAllowed(item.id))
-
- return (
-
-
- setSearch(e.target.value)}
- className='min-w-0 flex-1'
- />
-
- onSetDenied(
- filteredItems.map((item) => item.id),
- allFilteredAllowed
- )
- }
- >
- {allFilteredAllowed ? 'Block All' : 'Allow All'}
-
-
-
- {filteredItems.map((item) => {
- const checkboxId = `denylist-${item.id}`
- return (
-
- onToggle(item.id)}
- />
-
-
- )
- })}
-
-
- )
-}
-
-interface DynamicProviderModelsProps extends DenylistControls {
- provider: ProviderName
- workspaceId?: string
-}
-
-function DynamicProviderModels({ provider, workspaceId, ...controls }: DynamicProviderModelsProps) {
- const { data, isPending } = useProviderModels(provider, workspaceId)
- const items = useMemo(
- () => (data?.models ?? []).map((model) => ({ id: model, label: model })),
- [data?.models]
- )
- return (
-
- )
-}
-
-interface StaticProviderModelsProps extends DenylistControls {
- providerId: ProviderId
-}
-
-function StaticProviderModels({ providerId, ...controls }: StaticProviderModelsProps) {
- const items = useMemo(
- () => getProviderModels(providerId).map((model) => ({ id: model, label: model })),
- [providerId]
- )
- return (
-
- )
-}
-
-interface ProviderRowProps extends DenylistControls {
- providerId: ProviderId
- isProviderAllowed: boolean
- onToggleProvider: () => void
- deniedCount: number
- workspaceId?: string
-}
-
-function ProviderRow({
- providerId,
- isProviderAllowed,
- onToggleProvider,
- deniedCount,
- workspaceId,
- ...controls
-}: ProviderRowProps) {
- const [expanded, setExpanded] = useState(false)
-
- const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon
- const providerName =
- PROVIDER_DEFINITIONS[providerId]?.name ||
- providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
- const isDynamic = (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId)
- const checkboxId = `provider-${providerId}`
-
- return (
-
-
-
onToggleProvider()}
- />
-
- isProviderAllowed && setExpanded((prev) => !prev)}
- disabled={!isProviderAllowed}
- className={cn(
- 'flex flex-1 items-center gap-2 text-left',
- isProviderAllowed ? 'cursor-pointer' : 'cursor-default opacity-60'
- )}
- >
-
- {isProviderAllowed && deniedCount > 0 && (
-
- {deniedCount} blocked
-
- )}
- {isProviderAllowed && (
-
- )}
-
-
- {expanded && isProviderAllowed && (
-
- {isDynamic ? (
-
- ) : (
-
- )}
-
- )}
-
- )
-}
-
-interface BlockToolRowProps extends DenylistControls {
- block: BlockConfig
- isBlockAllowed: boolean
- onToggleBlock: () => void
- deniedCount: number
-}
-
-/**
- * Integration/trigger block row. The checkbox drives whole-block access
- * (`allowedIntegrations`); when allowed and the block exposes more than one
- * tool, the row expands to a per-tool deny-list grid (`deniedTools`), mirroring
- * the provider → models pattern.
- */
-function BlockToolRow({
- block,
- isBlockAllowed,
- onToggleBlock,
- deniedCount,
- ...controls
-}: BlockToolRowProps) {
- const [expanded, setExpanded] = useState(false)
- const BlockIcon = block.icon
- const checkboxId = `block-${block.type}`
-
- const toolItems = useMemo(
- () => (block.tools?.access ?? []).map((id) => ({ id, label: getToolMetadata(id)?.name ?? id })),
- [block.tools?.access]
- )
- const isExpandable = toolItems.length > 1
-
- return (
-
-
-
onToggleBlock()}
- />
-
- {BlockIcon && }
-
- isBlockAllowed && isExpandable && setExpanded((prev) => !prev)}
- disabled={!isBlockAllowed || !isExpandable}
- className={cn(
- 'flex min-w-0 flex-1 items-center gap-2 text-left',
- isBlockAllowed && isExpandable ? 'cursor-pointer' : 'cursor-default',
- !isBlockAllowed && 'opacity-60'
- )}
- >
-
- {/* An org running one custom block per environment has prod/uat/sandbox copies
- sharing a name and differing only by an opaque type slug. The source workspace
- is the only thing that tells them apart, so an allowlist decision made without
- it is a guess. */}
- {block.sourceWorkspaceName && (
-
- {block.sourceWorkspaceName}
-
- )}
- {isBlockAllowed && deniedCount > 0 && (
-
- {deniedCount} blocked
-
- )}
- {isBlockAllowed && isExpandable && (
-
- )}
-
- {/* Outside the button: an Info trigger is itself a button and cannot nest. */}
- {block.description && (
-
- {block.description}
-
- )}
-
- {expanded && isBlockAllowed && isExpandable && (
-
-
-
- )}
-
- )
-}
-
interface GroupDetailProps {
group: PermissionGroup
organizationId: string
@@ -791,8 +431,7 @@ export function GroupDetail({
/**
* 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 —
+ * clone-not-remove). Materialization and the collapse-to-null comparison 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.
@@ -833,17 +472,90 @@ export function GroupDetail({
availableProviderIds: allProviderIds,
})
- /** Maps every tool id to ALL block types that expose it (some tools are shared across blocks). */
- const toolBlockTypes = useMemo(() => {
- const map: Record = {}
+ const publicTools = useMemo(() => {
+ const parentByTool = new Map()
for (const block of allBlocks) {
- for (const toolId of block.tools?.access ?? []) {
- ;(map[toolId] ??= []).push(block.type)
+ if (block.type.startsWith('custom_block_')) continue
+ for (const id of block.tools?.access ?? []) {
+ if (!parentByTool.has(id)) parentByTool.set(id, block.type)
+ else if (parentByTool.get(id) !== block.type) parentByTool.set(id, null)
}
}
- return map
+ return [...parentByTool].flatMap(([id, parent]) =>
+ parent ? [{ id, label: id, integrationId: parent }] : []
+ )
}, [allBlocks])
+ const editorCatalog = useMemo(
+ () =>
+ createAccessRequestCatalog({
+ integrations: allBlocks.map((block) => ({ id: block.type, label: block.name })),
+ providers: allProviderIds.map((id) => ({
+ id,
+ label: PROVIDER_DEFINITIONS[id]?.name ?? id,
+ })),
+ models: allProviderIds.flatMap((providerId) =>
+ getStaticProviderModels(providerId)
+ .filter((model) => model.sunset?.status !== 'deprecated')
+ .map((model) => ({ id: model.id, label: model.id, providerId }))
+ ),
+ tools: publicTools,
+ knowledgeConnectors: KNOWLEDGE_CONNECTOR_OPTIONS.map(({ value, label }) => ({
+ id: value,
+ label,
+ })),
+ }),
+ [allBlocks, allProviderIds, publicTools]
+ )
+
+ const canRequest = useCallback(
+ (target: AccessRequestTarget) => {
+ if (target.kind === 'integration' && target.id.startsWith('custom_block_')) return false
+ return validateAccessRequestTarget(target, editorCatalog) !== null
+ },
+ [editorCatalog]
+ )
+
+ const getState = useCallback(
+ (target: AccessRequestTarget) => {
+ const state = getPermissionGroupAccessState(target, editingConfig, editorCatalog)
+ return state === 'requestable' && !canRequest(target) ? 'unavailable' : state
+ },
+ [editingConfig, editorCatalog, canRequest]
+ )
+
+ const getInheritedFrom = (target: AccessRequestTarget): string | null => {
+ const parent = getAccessRequestTargetParents(target, editorCatalog).find(
+ (parent) => getState(parent) !== 'allowed'
+ )
+ if (!parent) return null
+ return (
+ getInheritedFrom(parent) ??
+ describeAccessRequestTarget(parent, editorCatalog)?.label ??
+ 'a restricted parent'
+ )
+ }
+
+ const isInherited = (target: AccessRequestTarget) => getInheritedFrom(target) !== null
+
+ const setAccess = (targets: AccessRequestTarget[], state: PermissionGroupAccessState) => {
+ setEditingConfig((previous) => {
+ const editable = targets.filter(
+ (target) =>
+ (state !== 'requestable' || canRequest(target)) &&
+ !getAccessRequestTargetParents(target, editorCatalog).some(
+ (parent) => getPermissionGroupAccessState(parent, previous, editorCatalog) !== 'allowed'
+ )
+ )
+ return editable.reduce(
+ (config, target) => withPermissionGroupAccessState(config, target, state, editorCatalog),
+ previous
+ )
+ })
+ }
+
+ const controls = { getState, isInherited, getInheritedFrom, canRequest, onSet: setAccess }
+
const searchedPlatformFeatures = useMemo(() => {
const search = searchTerm.trim().toLowerCase()
if (!search) return PLATFORM_FEATURES
@@ -856,9 +568,9 @@ export function GroupDetail({
const filteredPlatformFeatures = useMemo(() => {
if (statusFilter === 'all') return searchedPlatformFeatures
return searchedPlatformFeatures.filter((f) =>
- matchesStatusFilter(statusFilter, !editingConfig[f.configKey])
+ matchesStatusFilter(statusFilter, getState({ kind: 'feature', configKey: f.configKey }))
)
- }, [searchedPlatformFeatures, statusFilter, editingConfig])
+ }, [searchedPlatformFeatures, statusFilter, getState])
const platformCategories = useMemo(() => {
const categories: Record = {}
@@ -913,53 +625,18 @@ export function GroupDetail({
onDiscard: () => handleDiscardConfig(),
})
- const allBlockTypes = useMemo(() => allBlocks.map((b) => b.type), [allBlocks])
-
- /**
- * `null` means "everything allowed". Indexing the allow-lists once keeps the
- * per-row membership checks O(1) — they run for every one of the ~200 block
- * rows on each render, and again in the section-wide `every(...)` scans.
- */
- const allowedIntegrationSet = useMemo(
- () => allowlistRowsFromStored(allBlockTypes, editingConfig.allowedIntegrations),
- [allBlockTypes, editingConfig.allowedIntegrations]
- )
-
- const allowedProviderSet = useMemo(
- () =>
- editingConfig.allowedModelProviders === null
- ? null
- : new Set(editingConfig.allowedModelProviders),
- [editingConfig.allowedModelProviders]
- )
-
- const isIntegrationAllowed = useCallback(
- (blockType: string) => allowedIntegrationSet === null || allowedIntegrationSet.has(blockType),
- [allowedIntegrationSet]
- )
-
- const isProviderAllowed = useCallback(
- (providerId: string) => allowedProviderSet === null || allowedProviderSet.has(providerId),
- [allowedProviderSet]
- )
-
const searchedProviders = useMemo(() => {
const query = searchTerm.trim().toLowerCase()
if (!query) return allProviderIds
return allProviderIds.filter((id) => id.toLowerCase().includes(query))
}, [allProviderIds, searchTerm])
- /**
- * Split from the search pass so the common `all` case returns the searched
- * list by reference — only the status pass depends on the allow-list, so a
- * checkbox toggle no longer invalidates downstream consumers.
- */
const filteredProviders = useMemo(() => {
if (statusFilter === 'all') return searchedProviders
return searchedProviders.filter((id) =>
- matchesStatusFilter(statusFilter, isProviderAllowed(id))
+ matchesStatusFilter(statusFilter, getState({ kind: 'provider', id }))
)
- }, [searchedProviders, statusFilter, isProviderAllowed])
+ }, [searchedProviders, statusFilter, getState])
const searchedBlocks = useMemo(() => {
const query = searchTerm.trim().toLowerCase()
@@ -970,9 +647,9 @@ export function GroupDetail({
const filteredBlocks = useMemo(() => {
if (statusFilter === 'all') return searchedBlocks
return searchedBlocks.filter((b) =>
- matchesStatusFilter(statusFilter, isIntegrationAllowed(b.type))
+ matchesStatusFilter(statusFilter, getState({ kind: 'integration', id: b.type }))
)
- }, [searchedBlocks, statusFilter, isIntegrationAllowed])
+ }, [searchedBlocks, statusFilter, getState])
const filteredCoreBlocks = useMemo(
() => filteredBlocks.filter((block) => block.category === 'blocks'),
@@ -1002,287 +679,32 @@ export function GroupDetail({
return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId))
}, [organizationMembers, members])
- /**
- * 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 nextAllowed = toggleAllowlistRow(allBlockTypes, prev.allowedIntegrations, blockType)
- return {
- ...prev,
- allowedIntegrations: nextAllowed,
- deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
- }
- })
- },
- [allBlockTypes, 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 nextAllowed = withAllowlistRows(
- allBlockTypes,
- prev.allowedIntegrations,
- blocks.map((block) => block.type),
- allowed
- )
- return {
- ...prev,
- allowedIntegrations: nextAllowed,
- deniedTools: pruneDeniedTools(nextAllowed, prev.deniedTools),
- }
- })
- },
- [allBlockTypes, pruneDeniedTools]
- )
-
- const isToolAllowed = useCallback(
- (toolId: string) => !editingConfig.deniedTools.includes(toolId),
- [editingConfig.deniedTools]
- )
-
- const toggleTool = useCallback((toolId: string) => {
- setEditingConfig((prev) => {
- const denied = prev.deniedTools.includes(toolId)
- return {
- ...prev,
- deniedTools: denied
- ? prev.deniedTools.filter((t) => t !== toolId)
- : [...prev.deniedTools, toolId],
- }
- })
- }, [])
-
- const setToolsDenied = useCallback((toolIds: string[], denied: boolean) => {
- setEditingConfig((prev) => {
- if (denied) {
- const existing = new Set(prev.deniedTools)
- const additions = toolIds.filter((t) => !existing.has(t))
- if (additions.length === 0) return prev
- return { ...prev, deniedTools: [...prev.deniedTools, ...additions] }
- }
- const toRemove = new Set(toolIds)
- return { ...prev, deniedTools: prev.deniedTools.filter((t) => !toRemove.has(t)) }
- })
- }, [])
-
- const deniedCountByBlock = useMemo(() => {
- const denied = new Set(editingConfig.deniedTools)
- const counts: Record = {}
- for (const block of allBlocks) {
- let count = 0
- for (const toolId of block.tools?.access ?? []) {
- if (denied.has(toolId)) count++
- }
- if (count > 0) counts[block.type] = count
- }
- return counts
- }, [editingConfig.deniedTools, allBlocks])
-
- const toggleProvider = useCallback(
- (providerId: string) => {
- setEditingConfig((prev) => {
- const current = prev.allowedModelProviders
- if (current === null) {
- const allExcept = allProviderIds.filter((p) => p !== providerId)
- return { ...prev, allowedModelProviders: allExcept }
- }
- if (current.includes(providerId)) {
- const updated = current.filter((p) => p !== providerId)
- return {
- ...prev,
- allowedModelProviders: updated.length === allProviderIds.length ? null : updated,
- }
- }
- const updated = [...current, providerId]
- return {
- ...prev,
- allowedModelProviders: updated.length === allProviderIds.length ? null : updated,
- }
- })
- },
- [allProviderIds]
- )
-
- /** Allow or deny a set of providers at once, respecting the active search filter. */
- const setProvidersAllowed = useCallback(
- (providerIds: string[], allowed: boolean) => {
- setEditingConfig((prev) => {
- const current =
- prev.allowedModelProviders === null
- ? new Set(allProviderIds)
- : new Set(prev.allowedModelProviders)
- for (const id of providerIds) {
- if (allowed) current.add(id)
- else current.delete(id)
- }
- const nextArr = allProviderIds.filter((id) => current.has(id))
- return {
- ...prev,
- allowedModelProviders: nextArr.length === allProviderIds.length ? null : nextArr,
- }
- })
- },
- [allProviderIds]
- )
-
- const isModelAllowed = useCallback(
- (model: string) => {
- const normalized = model.toLowerCase()
- return !editingConfig.deniedModels.some((denied) => denied.toLowerCase() === normalized)
- },
- [editingConfig.deniedModels]
- )
-
- const toggleModel = useCallback((model: string) => {
- setEditingConfig((prev) => {
- const normalized = model.toLowerCase()
- const isDenied = prev.deniedModels.some((denied) => denied.toLowerCase() === normalized)
- return {
- ...prev,
- deniedModels: isDenied
- ? prev.deniedModels.filter((denied) => denied.toLowerCase() !== normalized)
- : [...prev.deniedModels, model],
- }
- })
- }, [])
-
- const setModelsDenied = useCallback((models: string[], denied: boolean) => {
- setEditingConfig((prev) => {
- if (denied) {
- const existing = new Set(prev.deniedModels.map((m) => m.toLowerCase()))
- const additions = models.filter((m) => !existing.has(m.toLowerCase()))
- if (additions.length === 0) return prev
- return { ...prev, deniedModels: [...prev.deniedModels, ...additions] }
- }
- const toRemove = new Set(models.map((m) => m.toLowerCase()))
- return {
- ...prev,
- deniedModels: prev.deniedModels.filter((m) => !toRemove.has(m.toLowerCase())),
- }
- })
- }, [])
-
- const deniedCountByProvider = useMemo(() => {
- const counts: Record = {}
- for (const model of editingConfig.deniedModels) {
- try {
- const providerId = getProviderFromModel(model)
- counts[providerId] = (counts[providerId] ?? 0) + 1
- } catch {}
- }
- return counts
- }, [editingConfig.deniedModels])
-
- const fileShareAuthValue = useMemo(
- () => editingConfig.allowedFileShareAuthTypes ?? ALL_FILE_SHARE_AUTH_TYPES,
- [editingConfig.allowedFileShareAuthTypes]
- )
-
- const setFileShareAuthTypes = useCallback((values: string[]) => {
- // At least one mode must stay allowed while public sharing is enabled — an
- // empty allow-list would silently block every share. To turn public sharing
- // off entirely, uncheck Public Sharing instead.
- if (values.length === 0) return
- setEditingConfig((prev) => ({
- ...prev,
- allowedFileShareAuthTypes:
- values.length === ALL_FILE_SHARE_AUTH_TYPES.length ? null : (values as ShareAuthType[]),
- }))
- }, [])
-
- const chatDeployAuthValue = useMemo(
- () => editingConfig.allowedChatDeployAuthTypes ?? ALL_CHAT_DEPLOY_AUTH_TYPES,
- [editingConfig.allowedChatDeployAuthTypes]
- )
-
- const setChatDeployAuthTypes = useCallback((values: string[]) => {
- // At least one mode must stay allowed while chat deploy is enabled — an empty
- // allow-list would silently block every chat deployment. To turn chat deploy
- // off entirely, uncheck Chat → Deployment instead.
- if (values.length === 0) return
- setEditingConfig((prev) => ({
- ...prev,
- allowedChatDeployAuthTypes:
- values.length === ALL_CHAT_DEPLOY_AUTH_TYPES.length ? null : (values as ShareAuthType[]),
- }))
- }, [])
-
- const knowledgeConnectorValue = useMemo(
- () => editingConfig.allowedKnowledgeConnectors ?? ALL_KNOWLEDGE_CONNECTORS,
- [editingConfig.allowedKnowledgeConnectors]
- )
-
- /**
- * At least one connector must stay allowed while the Knowledge Base module is
- * visible — an empty allow-list would silently block every connector while
- * the add-connector button still offered them. To withhold connectors along
- * with the rest of the module, uncheck Knowledge Base instead.
- */
- const setKnowledgeConnectors = useCallback((values: string[]) => {
- if (values.length === 0) return
- setEditingConfig((prev) => ({
- ...prev,
- allowedKnowledgeConnectors: values.length === ALL_KNOWLEDGE_CONNECTORS.length ? null : values,
- }))
- }, [])
-
- /**
- * Nested controls rendered under a platform feature's checkbox, keyed by
- * feature id. Kept out of `PLATFORM_FEATURES` so that array stays pure data.
- */
- const featureExtras: Partial> = {
+ const featureExtras = {
'hide-deploy-chatbot': (
- ({ id: value, label }))}
+ kind='chat_deploy_auth'
+ searchPlaceholder='Search authentication modes...'
+ emptyLabel='No modes available.'
+ {...controls}
/>
),
'disable-public-file-sharing': (
- ({ id: value, label }))}
+ kind='file_share_auth'
+ searchPlaceholder='Search authentication modes...'
+ emptyLabel='No modes available.'
+ {...controls}
/>
),
- /**
- * Nested under Knowledge Base rather than Knowledge Base Creation: a
- * connector attaches to an existing knowledge base, so the allow-list still
- * governs a group that may sync but never create. Hanging it off creation
- * would dim the picker for exactly the group it was written for.
- */
'hide-knowledge-base': (
- ({ id: value, label }))}
+ kind='knowledge_connector'
+ searchPlaceholder='Search connectors...'
+ emptyLabel='No connectors available.'
+ {...controls}
/>
),
}
@@ -1457,24 +879,12 @@ export function GroupDetail({
[]
)
- const filteredProvidersAllAllowed = filteredProviders.every((id) => isProviderAllowed(id))
- const coreBlocksAllAllowed = filteredCoreBlocks.every((b) => isIntegrationAllowed(b.type))
- const toolBlocksAllAllowed = filteredToolBlocks.every((b) => isIntegrationAllowed(b.type))
- /**
- * Rows this group cannot decide: an organization-scoped key is read from the
- * organization's *default* group, so setting it on any other group changes
- * nothing. They render inert, and every bulk action skips them — "Select All"
- * writing a value the server would never read is the same false promise as
- * the checkbox itself.
- */
- const editablePlatformFeatures = useMemo(
- () =>
- filteredPlatformFeatures.filter((f) => !isFeatureInertForGroup(f, viewingGroup.isDefault)),
- [filteredPlatformFeatures, viewingGroup.isDefault]
+ const editablePlatformFeatures = filteredPlatformFeatures.filter(
+ (feature) =>
+ !isFeatureInertForGroup(feature, viewingGroup.isDefault) &&
+ !isInherited({ kind: 'feature', configKey: feature.configKey })
)
- const platformAllAllowed = editablePlatformFeatures.every((f) => !editingConfig[f.configKey])
-
return (
<>
+ {configTab !== 'general' && (
+
+ Allowed permits use. Can request requires admin approval. Unavailable blocks both.
+ Workspace roles and deployment settings still apply.
+
+ )}
+
{configTab === 'general' && (
<>
@@ -1665,26 +1082,22 @@ export function GroupDetail({
setEditingConfig((prev) => ({ ...prev, defaultAgentModel }))
}
/>
-
+
setSearchTerm(e.target.value)}
- className='min-w-0 flex-1'
+ className='min-w-0 basis-full sm:flex-1 sm:basis-0'
/>
void setStatusFilter(next)}
/>
-
- setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)
- }
- disabled={filteredProviders.length === 0}
- >
- {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
-
+ ({ kind: 'provider', id }))}
+ {...controls}
+ />
{filteredProviders.length === 0 ? (
@@ -1696,13 +1109,8 @@ export function GroupDetail({
toggleProvider(providerId)}
- deniedCount={deniedCountByProvider[providerId] ?? 0}
workspaceId={workspaceId}
- isAllowed={isModelAllowed}
- onToggle={toggleModel}
- onSetDenied={setModelsDenied}
+ {...controls}
/>
))}
@@ -1712,13 +1120,13 @@ export function GroupDetail({
{configTab === 'blocks' && (
-
+
setSearchTerm(e.target.value)}
- className='min-w-0 flex-1'
+ className='min-w-0 basis-full sm:flex-1 sm:basis-0'
/>
setBlocksAllowed(filteredCoreBlocks, !coreBlocksAllAllowed)}>
- {coreBlocksAllAllowed ? 'Deselect All' : 'Select All'}
-
+ ({
+ kind: 'integration',
+ id: block.type,
+ }))}
+ {...controls}
+ />
}
>
-
- {filteredCoreBlocks.map((block) => {
- const BlockIcon = block.icon
- const checkboxId = `block-${block.type}`
- return (
-
-
- toggleIntegration(block.type)}
- />
-
- {BlockIcon && }
-
-
- {block.sourceWorkspaceName && (
-
- {block.sourceWorkspaceName}
-
- )}
-
- {block.description && (
-
- {block.description}
-
- )}
-
- )
- })}
+
+ {filteredCoreBlocks.map((block) => (
+
+ ))}
)}
@@ -1785,29 +1162,24 @@ export function GroupDetail({
- Allow a whole integration with its checkbox, then expand it to deny specific
- tools while keeping the rest available.
+
+ Choose access for an integration, then expand it to set individual tools. Parent
+ restrictions apply to every tool.
}
action={
- setBlocksAllowed(filteredToolBlocks, !toolBlocksAllAllowed)}>
- {toolBlocksAllAllowed ? 'Deselect All' : 'Select All'}
-
+ ({
+ kind: 'integration',
+ id: block.type,
+ }))}
+ {...controls}
+ />
}
>
{filteredToolBlocks.map((block) => (
- toggleIntegration(block.type)}
- deniedCount={deniedCountByBlock[block.type] ?? 0}
- isAllowed={isToolAllowed}
- onToggle={toggleTool}
- onSetDenied={setToolsDenied}
- />
+
))}
@@ -1817,31 +1189,25 @@ export function GroupDetail({
{configTab === 'platform' && (
-
+
setSearchTerm(e.target.value)}
- className='min-w-0 flex-1'
+ className='min-w-0 basis-full sm:flex-1 sm:basis-0'
/>
void setStatusFilter(next)}
/>
-
- setEditingConfig((prev) => ({
- ...prev,
- ...Object.fromEntries(
- editablePlatformFeatures.map((f) => [f.configKey, platformAllAllowed])
- ),
- }))
- }
- disabled={editablePlatformFeatures.length === 0}
- >
- {platformAllAllowed ? 'Deselect All' : 'Select All'}
-
+ ({
+ kind: 'feature',
+ configKey: feature.configKey,
+ }))}
+ {...controls}
+ />
{platformCategorySections.length === 0 && (
@@ -1849,48 +1215,23 @@ export function GroupDetail({
)}
{platformCategorySections.map(({ category, features }) => (
-
+ {category}}
+ >
- {features.map((feature) => {
- const inert = isFeatureInertForGroup(feature, viewingGroup.isDefault)
- return (
-
-
-
-
- setEditingConfig((prev) => ({
- ...prev,
- [feature.configKey]: checked !== true,
- }))
- }
- />
- {feature.label}
- {inert && (
-
- Organization
-
- )}
-
-
- {inert
- ? `${feature.hint} ${ORGANIZATION_SCOPED_FEATURE_NOTE}`
- : feature.hint}
-
-
- {featureExtras[feature.id]}
-
- )
- })}
+ {features.map((feature) => (
+
+ {feature.id in featureExtras
+ ? featureExtras[feature.id as keyof typeof featureExtras]
+ : undefined}
+
+ ))}
))}
diff --git a/apps/sim/ee/access-control/components/permission-rows.tsx b/apps/sim/ee/access-control/components/permission-rows.tsx
new file mode 100644
index 00000000000..92e9a235c82
--- /dev/null
+++ b/apps/sim/ee/access-control/components/permission-rows.tsx
@@ -0,0 +1,412 @@
+'use client'
+
+import { type ReactNode, useId, useMemo, useState } from 'react'
+import {
+ ChipInput,
+ ChipTag,
+ cn,
+ Info,
+ OverflowText,
+ scrollFadeAttributes,
+ scrollFadeClass,
+ useScrollEdges,
+} from '@sim/emcn'
+import { ChevronDown, Search } from '@sim/emcn/icons'
+import {
+ ORGANIZATION_SCOPED_FEATURE_NOTE,
+ type PermissionGroupPlatformFeature,
+} from '@/lib/permission-groups/features'
+import {
+ SettingsEmptyState,
+ SettingsQueryErrorState,
+} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
+import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
+import type { BlockConfig } from '@/blocks/types'
+import {
+ AccessStateSelect,
+ BulkAccessSelect,
+} from '@/ee/access-control/components/access-state-select'
+import type {
+ AccessRequestTarget,
+ PermissionGroupAccessState,
+} from '@/ee/access-requests/lib/targets'
+import { useProviderModels } from '@/hooks/queries/providers'
+import {
+ DYNAMIC_MODEL_PROVIDERS,
+ getProviderModels,
+ PROVIDER_DEFINITIONS,
+} from '@/providers/models'
+import type { ProviderId } from '@/providers/types'
+import type { ProviderName } from '@/stores/providers'
+import { getToolMetadata } from '@/tools/metadata'
+
+/** Resolves draft policy and inheritance, checks request support, and stages edits without saving. */
+export interface PermissionEditorControls {
+ getState: (target: AccessRequestTarget) => PermissionGroupAccessState
+ isInherited: (target: AccessRequestTarget) => boolean
+ getInheritedFrom: (target: AccessRequestTarget) => string | null
+ canRequest: (target: AccessRequestTarget) => boolean
+ onSet: (targets: AccessRequestTarget[], state: PermissionGroupAccessState) => void
+}
+
+function inheritedControls(
+ parent: AccessRequestTarget,
+ parentLabel: string,
+ controls: PermissionEditorControls
+): PermissionEditorControls {
+ const parentState = controls.getState(parent)
+ return {
+ ...controls,
+ getState: (target) => {
+ const state = controls.getState(target)
+ return parentState === 'unavailable' || state === 'unavailable'
+ ? 'unavailable'
+ : parentState === 'requestable'
+ ? 'requestable'
+ : state
+ },
+ isInherited: (target) => parentState !== 'allowed' || controls.isInherited(target),
+ getInheritedFrom: (target) =>
+ parentState !== 'allowed'
+ ? (controls.getInheritedFrom(parent) ?? parentLabel)
+ : controls.getInheritedFrom(target),
+ }
+}
+
+interface PermissionRowProps extends PermissionEditorControls {
+ target: AccessRequestTarget
+ label: string
+ description?: string
+ icon?: ReactNode
+ badge?: ReactNode
+ disabled?: boolean
+ nested?: boolean
+ children?: ReactNode
+}
+
+interface PermissionBulkSelectProps extends PermissionEditorControls {
+ targets: AccessRequestTarget[]
+}
+
+/** Edits non-inherited targets, offering requests only when every editable target supports them. */
+export function PermissionBulkSelect({ targets, ...controls }: PermissionBulkSelectProps) {
+ const editable = targets.filter((target) => !controls.isInherited(target))
+ return (
+ controls.onSet(editable, state)}
+ />
+ )
+}
+
+function PermissionRow({
+ target,
+ label,
+ description,
+ icon,
+ badge,
+ disabled,
+ nested,
+ children,
+ ...controls
+}: PermissionRowProps) {
+ const [expanded, setExpanded] = useState(false)
+ const detailsId = useId()
+ const inheritedFrom = controls.getInheritedFrom(target)
+ const labelContent = (
+ <>
+
+ {badge}
+ {children && (
+
+ )}
+ >
+ )
+ return (
+
+
+ {children ? (
+ setExpanded(!expanded)}
+ className='flex min-h-10 min-w-0 items-center gap-2 rounded-md text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--border)] focus-visible:ring-inset max-md:pointer-coarse:min-h-11'
+ >
+ {labelContent}
+
+ ) : (
+
+ {labelContent}
+
+ )}
+ {(description || inheritedFrom) && (
+
+
+ {description && {description} }
+ {inheritedFrom && (
+
+ Controlled by {inheritedFrom}. Set {inheritedFrom} to Allowed to edit this
+ choice.
+
+ )}
+
+
+ )}
+
+ }
+ trailing={
+ controls.onSet([target], state)}
+ />
+ }
+ />
+ {children && expanded && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+interface PlatformPermissionRowProps extends PermissionEditorControls {
+ feature: PermissionGroupPlatformFeature
+ inert: boolean
+ children?: ReactNode
+}
+
+/** Edits a platform feature with optional child controls; inert organization-only rows stay disabled. */
+export function PlatformPermissionRow({
+ feature,
+ inert,
+ children,
+ ...controls
+}: PlatformPermissionRowProps) {
+ return (
+ Organization : undefined}
+ disabled={inert}
+ {...controls}
+ >
+ {children}
+
+ )
+}
+
+interface PermissionGridProps extends PermissionEditorControls {
+ items: { id: string; label: string }[]
+ kind: 'model' | 'tool' | 'knowledge_connector' | 'file_share_auth' | 'chat_deploy_auth'
+ loading?: boolean
+ searchPlaceholder: string
+ emptyLabel: string
+}
+
+/** Searches items of one target kind and limits bulk edits to the visible, editable rows. */
+export function PermissionGrid({
+ items,
+ kind,
+ loading,
+ searchPlaceholder,
+ emptyLabel,
+ ...controls
+}: PermissionGridProps) {
+ const [search, setSearch] = useState('')
+ const [scrollElement, setScrollElement] = useState(null)
+ const scrollEdges = useScrollEdges(scrollElement)
+ const filtered = useMemo(() => {
+ const query = search.trim().toLowerCase()
+ return items
+ .filter(
+ (item) => item.label.toLowerCase().includes(query) || item.id.toLowerCase().includes(query)
+ )
+ .sort((a, b) => a.label.localeCompare(b.label))
+ }, [items, search])
+ const targetFor = (id: string): AccessRequestTarget => ({ kind, id }) as AccessRequestTarget
+ if (loading) return Loading…
+ if (!items.length) return {emptyLabel}
+ return (
+
+
+
setSearch(event.target.value)}
+ className='min-w-0 flex-1'
+ />
+ targetFor(item.id))} {...controls} />
+
+ {!filtered.length && (
+
No items match your search.
+ )}
+
+
+ {filtered.map((item) => (
+
+ ))}
+
+
+
+ )
+}
+
+interface DynamicProviderModelsProps extends PermissionEditorControls {
+ provider: ProviderName
+ workspaceId?: string
+}
+
+function DynamicProviderModels({ provider, workspaceId, ...controls }: DynamicProviderModelsProps) {
+ const { data, isPending, isError, error, isFetching, refetch } = useProviderModels(
+ provider,
+ workspaceId
+ )
+ const items = useMemo(() => (data?.models ?? []).map((id) => ({ id, label: id })), [data?.models])
+ if (isError && !data) {
+ return (
+ void refetch()}
+ variant='inline'
+ />
+ )
+ }
+ return (
+
+ )
+}
+
+interface ProviderRowProps extends PermissionEditorControls {
+ providerId: ProviderId
+ workspaceId?: string
+}
+
+/** Edits a provider and its inherited model choices, using workspace-scoped dynamic models when needed. */
+export function ProviderRow({ providerId, workspaceId, ...controls }: ProviderRowProps) {
+ const definition = PROVIDER_DEFINITIONS[providerId]
+ const ProviderIcon = definition?.icon
+ const name = definition?.name ?? providerId
+ const dynamic = (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId)
+ const childControls = inheritedControls({ kind: 'provider', id: providerId }, name, controls)
+ const items = useMemo(
+ () => getProviderModels(providerId).map((id) => ({ id, label: id })),
+ [providerId]
+ )
+ return (
+ : undefined}
+ {...controls}
+ >
+ {dynamic ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+interface BlockToolRowProps extends PermissionEditorControls {
+ block: BlockConfig
+}
+
+/** Edits a block and its tool operations, preserving child choices while a parent restricts access. */
+export function BlockToolRow({ block, ...controls }: BlockToolRowProps) {
+ const childControls = inheritedControls(
+ { kind: 'integration', id: block.type },
+ block.name,
+ controls
+ )
+ const Icon = block.icon
+ const items = useMemo(
+ () => (block.tools?.access ?? []).map((id) => ({ id, label: getToolMetadata(id)?.name ?? id })),
+ [block.tools?.access]
+ )
+ return (
+
+ {Icon && }
+
+ }
+ badge={
+ block.sourceWorkspaceName ? (
+ {block.sourceWorkspaceName}
+ ) : undefined
+ }
+ {...controls}
+ >
+ {items.length ? (
+
+ ) : null}
+
+ )
+}
diff --git a/apps/sim/ee/access-control/hooks/permission-groups.ts b/apps/sim/ee/access-control/hooks/permission-groups.ts
index b62a701faca..a6c5d91ac54 100644
--- a/apps/sim/ee/access-control/hooks/permission-groups.ts
+++ b/apps/sim/ee/access-control/hooks/permission-groups.ts
@@ -22,6 +22,7 @@ import {
type UserPermissionConfig,
updatePermissionGroupContract,
} from '@/lib/api/contracts/permission-groups'
+import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys'
import {
PERMISSION_GROUP_MEMBERS_STALE_TIME,
PERMISSION_GROUPS_STALE_TIME,
@@ -157,9 +158,11 @@ export function useCreatePermissionGroup() {
body: data,
})
},
- onSettled: (_data, _error, variables) => {
- queryClient.invalidateQueries({
- queryKey: permissionGroupKeys.list(variables.organizationId),
+ onSettled: (_data, _error, { organizationId }) => {
+ void queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() })
+ void queryClient.invalidateQueries({
+ queryKey: accessRequestKeys.organizationDetails(organizationId),
})
},
})
@@ -180,11 +183,15 @@ export function useUpdatePermissionGroup() {
body: data,
})
},
- onSettled: () => {
+ onSettled: (_data, _error, { organizationId }) => {
// `all` is the prefix of every key in the factory (list/detail/members/userConfig),
// so a single invalidation covers them — including the workspace-keyed userConfig
// entries a mutation that only knows organizationId cannot target directly.
- queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ void queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() })
+ void queryClient.invalidateQueries({
+ queryKey: accessRequestKeys.organizationDetails(organizationId),
+ })
},
})
}
@@ -203,8 +210,12 @@ export function useDeletePermissionGroup() {
params: { id: organizationId, groupId: permissionGroupId },
})
},
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ onSettled: (_data, _error, { organizationId }) => {
+ void queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() })
+ void queryClient.invalidateQueries({
+ queryKey: accessRequestKeys.organizationDetails(organizationId),
+ })
},
})
}
@@ -224,8 +235,12 @@ export function useRemovePermissionGroupMember() {
query: { memberId: data.memberId },
})
},
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ onSettled: (_data, _error, { organizationId }) => {
+ void queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() })
+ void queryClient.invalidateQueries({
+ queryKey: accessRequestKeys.organizationDetails(organizationId),
+ })
},
})
}
@@ -249,8 +264,12 @@ export function useBulkAddPermissionGroupMembers() {
body: data,
})
},
- onSettled: () => {
- queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ onSettled: (_data, _error, { organizationId }) => {
+ void queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all })
+ void queryClient.invalidateQueries({ queryKey: accessRequestKeys.discoveries() })
+ void queryClient.invalidateQueries({
+ queryKey: accessRequestKeys.organizationDetails(organizationId),
+ })
},
})
}
diff --git a/apps/sim/ee/access-control/utils/access-state.ts b/apps/sim/ee/access-control/utils/access-state.ts
new file mode 100644
index 00000000000..519a2e68487
--- /dev/null
+++ b/apps/sim/ee/access-control/utils/access-state.ts
@@ -0,0 +1,83 @@
+import type { PermissionGroupConfig } from '@/lib/permission-groups/fields'
+import { withAllowlistRows } from '@/ee/access-control/utils/integration-allowlist-rows'
+import {
+ type AccessRequestCatalog,
+ type AccessRequestTarget,
+ getAccessRequestTargetKey,
+ type PermissionGroupAccessState,
+} from '@/ee/access-requests/lib/targets'
+
+/** Changes only the selected row; parent changes preserve every child's saved choice. */
+export function withPermissionGroupAccessState(
+ config: PermissionGroupConfig,
+ target: AccessRequestTarget,
+ state: PermissionGroupAccessState,
+ catalog: AccessRequestCatalog
+): PermissionGroupConfig {
+ const next = { ...config }
+ const allowed = state === 'allowed'
+ const setMember = (stored: T[] | null, universe: T[], id: T): T[] | null => {
+ const members = new Set(stored ?? universe)
+ if (allowed) members.add(id)
+ else members.delete(id)
+ return universe.every((value) => members.has(value)) ? null : [...members]
+ }
+ switch (target.kind) {
+ case 'feature':
+ next[target.configKey] = !allowed
+ break
+ case 'integration':
+ next.allowedIntegrations = withAllowlistRows(
+ [...catalog.integrations.keys()],
+ config.allowedIntegrations,
+ [target.id],
+ allowed
+ )
+ break
+ case 'provider':
+ next.allowedModelProviders = setMember(
+ config.allowedModelProviders,
+ [...catalog.providers.keys()],
+ target.id
+ )
+ break
+ case 'model': {
+ next.deniedModels = config.deniedModels.filter(
+ (id) => id.toLowerCase() !== target.id.toLowerCase()
+ )
+ if (!allowed) next.deniedModels.push(target.id)
+ break
+ }
+ case 'tool':
+ next.deniedTools = config.deniedTools.filter((id) => id !== target.id)
+ if (!allowed) next.deniedTools.push(target.id)
+ break
+ case 'knowledge_connector':
+ next.allowedKnowledgeConnectors = setMember(
+ config.allowedKnowledgeConnectors,
+ [...catalog.knowledgeConnectors.keys()],
+ target.id
+ )
+ break
+ case 'file_share_auth':
+ next.allowedFileShareAuthTypes = setMember(
+ config.allowedFileShareAuthTypes,
+ ['public', 'password', 'email', 'sso'],
+ target.id
+ )
+ break
+ case 'chat_deploy_auth':
+ next.allowedChatDeployAuthTypes = setMember(
+ config.allowedChatDeployAuthTypes,
+ ['public', 'password', 'email', 'sso'],
+ target.id
+ )
+ break
+ case 'usage_limit':
+ return config
+ }
+ const key = getAccessRequestTargetKey(target)
+ next.deniedAccessRequests = config.deniedAccessRequests.filter((value) => value !== key)
+ if (state === 'unavailable') next.deniedAccessRequests.push(key)
+ return next
+}
diff --git a/apps/sim/ee/access-requests/README.md b/apps/sim/ee/access-requests/README.md
index 36dada4306d..9d61b2e1c96 100644
--- a/apps/sim/ee/access-requests/README.md
+++ b/apps/sim/ee/access-requests/README.md
@@ -5,11 +5,14 @@ Members request access from locked features or the block picker and track their
## Deployment
1. Apply migration `0349_permission_access_requests.sql` before deploying the application changes.
-2. Each organization starts with requests enabled. An explicit organization opt-out disables creation and approval and restores existing feature hiding. History, cancellation, and decline remain available.
+2. Each organization starts with requests enabled. An explicit organization opt-out pauses creation and approval while retaining discovery. History, cancellation, and decline remain available.
3. The existing outbox worker delivers notifications. Email links open authenticated review/history; email never applies a change.
## Policy and lifecycle
+- Permission groups choose **Allowed**, **Can request**, or **Unavailable** per item in the existing editor. Access fields retain their existing meaning; the optional `deniedAccessRequests` list stores canonical target keys for restricted items that cannot be requested. Missing lists default to empty, preserving existing groups. No database migration is needed for this field.
+- Unavailable parents also block descendant requests. Changing a parent preserves its children’s saved choices, and approvals never mutate request eligibility metadata. Existing requests remain in history and can be cancelled or declined after eligibility changes.
+- The editor represents durable group policy, not a member's effective access. Deployment availability and workspace roles are checked separately during discovery, submission, and approval; changes to those ceilings do not rewrite saved group choices.
- Requests refer to canonical public feature identifiers. Private resource names, preview blocks, deployment-disabled integrations, and unknown tenant model names are excluded from discovery.
- Fulfillment updates the current governing group. The preview lists every required change, including parent restrictions, and a conservative upper bound of affected people/workspaces. It does not create individual grants or move members between groups.
- Approval rechecks administrator authority, requester membership identity, workspace ownership, entitlement at admission, organization preference, group resolution, and the preview fingerprint. Membership, policy, or scope changes require a fresh review or request.
diff --git a/apps/sim/ee/access-requests/components/access-requests-settings.tsx b/apps/sim/ee/access-requests/components/access-requests-settings.tsx
index 60c9478888b..5077ad8fd93 100644
--- a/apps/sim/ee/access-requests/components/access-requests-settings.tsx
+++ b/apps/sim/ee/access-requests/components/access-requests-settings.tsx
@@ -71,9 +71,7 @@ export function AccessRequestsSettings({
offset: view === 'catalog' ? offset : 0,
})
const currentQuery = view === 'requests' ? requests : catalog
- const showCatalog =
- view === 'catalog' ||
- (catalog.isSuccess && catalog.data.enabled && catalog.data.entries.length > 0)
+ const showCatalog = view === 'catalog' || (catalog.isSuccess && catalog.data.entries.length > 0)
const requestsPaused =
catalog.isSuccess && !catalog.data.enabled && Boolean(catalog.data.organizationId)
return (
@@ -100,7 +98,7 @@ export function AccessRequestsSettings({
- {view === 'requests' && requestsPaused && (
+ {requestsPaused && (
Your organization has paused new requests. Your request history is still available.
@@ -149,11 +147,9 @@ export function AccessRequestsSettings({
/>
))}
- ) : !catalog.data?.enabled ? (
+ ) : !catalog.data?.organizationId ? (
- {requestsPaused
- ? 'Your organization has paused new requests. Your request history is still available.'
- : 'Access requests are available in organization workspaces.'}
+ Access requests are available in organization workspaces.
) : (
@@ -183,7 +179,8 @@ export function AccessRequestsSettings({
) : undefined
}
trailing={
- entry.state === 'requestable' ? (
+ entry.state === 'requestable' &&
+ (catalog.data.enabled || entry.pendingRequestId) ? (
+ new Set(
+ discovery.data?.entries.flatMap((entry) =>
+ entry.target.kind === 'feature' && entry.state === 'requestable'
+ ? [entry.target.configKey]
+ : []
+ )
+ ),
+ [discovery.data?.entries]
+ )
+ return { ...discovery, requestableFeatureKeys }
}
interface PermissionAccessBoundaryProps {
@@ -142,7 +154,9 @@ export function PermissionAccessBoundary({
? entry.pendingRequestId
? 'Your request is pending.'
: 'Ask an administrator for access.'
- : (entry?.reason ?? 'Your organization restricts this feature.')
+ : entry?.state === 'requestable' && !discovery.data.enabled
+ ? 'Access requests are paused for your organization.'
+ : (entry?.reason ?? 'Your organization restricts this feature.')
}
action={withNavigationAction(
discovery.data.enabled && entry?.state === 'requestable' ? (
diff --git a/apps/sim/ee/access-requests/components/request-access-action.tsx b/apps/sim/ee/access-requests/components/request-access-action.tsx
index 8de1715cf91..cb48c41de7a 100644
--- a/apps/sim/ee/access-requests/components/request-access-action.tsx
+++ b/apps/sim/ee/access-requests/components/request-access-action.tsx
@@ -137,7 +137,9 @@ export function RequestAccessModal({
const alreadyAllowed = entry?.state === 'allowed'
const unavailableReason =
discovery.isSuccess && !pendingRequestId && !canRequest && !alreadyAllowed
- ? (entry?.reason ?? 'Access requests are unavailable.')
+ ? !discovery.data.enabled && discovery.data.organizationId && entry?.state === 'requestable'
+ ? 'Access requests are paused for your organization.'
+ : (entry?.reason ?? 'Access requests are unavailable.')
: null
const submit = () => {
diff --git a/apps/sim/ee/access-requests/lib/application/flow.integration.ts b/apps/sim/ee/access-requests/lib/application/flow.integration.ts
index 3d3f883e0b4..3ca5e98d013 100644
--- a/apps/sim/ee/access-requests/lib/application/flow.integration.ts
+++ b/apps/sim/ee/access-requests/lib/application/flow.integration.ts
@@ -262,6 +262,121 @@ async function storedState() {
}
describe('access request member-to-admin flow on PostgreSQL', () => {
+ it.each([
+ {
+ name: 'feature and child feature',
+ config: { deniedAccessRequests: ['feature:hideTablesTab'] },
+ targets: [
+ { kind: 'feature', configKey: 'hideTablesTab' },
+ { kind: 'feature', configKey: 'disableTableCreation' },
+ ],
+ },
+ {
+ name: 'provider and model',
+ config: { allowedModelProviders: [], deniedAccessRequests: ['provider:openai'] },
+ targets: [
+ { kind: 'provider', id: 'openai' },
+ { kind: 'model', id: 'gpt-4.1' },
+ ],
+ },
+ {
+ name: 'integration and tool',
+ config: { allowedIntegrations: [], deniedAccessRequests: ['integration:table_v2'] },
+ targets: [
+ { kind: 'integration', id: 'table_v2' },
+ { kind: 'tool', id: 'table_insert_row' },
+ ],
+ },
+ {
+ name: 'individual model',
+ config: { deniedModels: ['gpt-4.1'], deniedAccessRequests: ['model:gpt-4.1'] },
+ targets: [{ kind: 'model', id: 'gpt-4.1' }],
+ },
+ {
+ name: 'individual tool',
+ config: {
+ deniedTools: ['table_insert_row'],
+ deniedAccessRequests: ['tool:table_insert_row'],
+ },
+ targets: [{ kind: 'tool', id: 'table_insert_row' }],
+ },
+ {
+ name: 'knowledge connector',
+ config: { allowedKnowledgeConnectors: [], deniedAccessRequests: ['knowledge_connector:s3'] },
+ targets: [{ kind: 'knowledge_connector', id: 's3' }],
+ },
+ {
+ name: 'file sharing authentication',
+ config: { allowedFileShareAuthTypes: [], deniedAccessRequests: ['file_share_auth:public'] },
+ targets: [{ kind: 'file_share_auth', id: 'public' }],
+ },
+ {
+ name: 'chat deployment authentication',
+ config: { allowedChatDeployAuthTypes: [], deniedAccessRequests: ['chat_deploy_auth:public'] },
+ targets: [{ kind: 'chat_deploy_auth', id: 'public' }],
+ },
+ ] as const)(
+ 'refuses unavailable $name without recording a request',
+ async ({ config, targets }) => {
+ await connection`
+ UPDATE permission_group SET config = config || ${JSON.stringify(config)}::jsonb
+ WHERE id = 'restricted'
+ `
+ for (const unavailableTarget of targets) {
+ const targetKey =
+ unavailableTarget.kind === 'feature'
+ ? `feature:${unavailableTarget.configKey}`
+ : `${unavailableTarget.kind}:${unavailableTarget.id}`
+ const discovery = await discoverAccessRequests.execute({
+ principal: member,
+ input: { ...scope, targetKind: unavailableTarget.kind, targetKey, ...page },
+ })
+ expect(discovery.entries).toMatchObject([
+ { target: unavailableTarget, state: 'unavailable' },
+ ])
+ await expect(
+ createAccessRequest.execute({
+ principal: member,
+ input: { scope, target: unavailableTarget },
+ })
+ ).rejects.toMatchObject({ code: 'conflict' })
+ }
+ const persisted = await storedState()
+ expect(persisted.requests).toEqual([])
+ expect(persisted.events).toEqual([])
+ await expect(authorizeTables()).rejects.toMatchObject({
+ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED',
+ })
+ }
+ )
+
+ it('rechecks request eligibility at approval while preserving history and cancellation', async () => {
+ const { request } = await create()
+ await connection`
+ UPDATE permission_group SET config = config ||
+ '{"deniedAccessRequests":["feature:hideTablesTab"]}'::jsonb
+ WHERE id = 'restricted'
+ `
+ const unavailable = await preview(request.id)
+ expect(unavailable).toMatchObject({ canApply: false })
+ const before = await storedState()
+ await expect(apply(request.id, unavailable.fingerprint)).rejects.toMatchObject({
+ code: 'conflict',
+ })
+ expect(await storedState()).toEqual(before)
+ expect(
+ await listMyAccessRequests.execute({
+ principal: member,
+ input: { scope, ...page },
+ })
+ ).toMatchObject({ requests: [{ id: request.id, status: 'pending' }] })
+ await cancelAccessRequest.execute({
+ principal: member,
+ input: { scope, requestId: request.id },
+ })
+ expect((await storedState()).requests).toMatchObject([{ status: 'cancelled' }])
+ })
+
it('refuses workspace API keys before any protected read or transaction', async () => {
const readsBefore = select.mock.calls.length
const transactionsBefore = transaction.mock.calls.length
@@ -567,9 +682,18 @@ describe('access request member-to-admin flow on PostgreSQL', () => {
})
const discovery = await discoverAccessRequests.execute({
principal: member,
- input: { ...scope, targetKind: 'feature', limit: 1, offset: 0 },
+ input: {
+ ...scope,
+ targetKind: 'feature',
+ targetKey: 'feature:hideTablesTab',
+ limit: 1,
+ offset: 0,
+ },
+ })
+ expect(discovery).toMatchObject({
+ enabled: false,
+ entries: [{ target, state: 'requestable', pendingRequestId: request.id }],
})
- expect(discovery).toMatchObject({ enabled: false, entries: [] })
await expect(create()).rejects.toMatchObject({ detailCode: 'ACCESS_REQUESTS_DISABLED' })
expect(
(await listMyAccessRequests.execute({ principal: member, input: { scope, ...page } }))
diff --git a/apps/sim/ee/access-requests/lib/application/requests.ts b/apps/sim/ee/access-requests/lib/application/requests.ts
index 2af23adb248..baffbd0fb43 100644
--- a/apps/sim/ee/access-requests/lib/application/requests.ts
+++ b/apps/sim/ee/access-requests/lib/application/requests.ts
@@ -120,8 +120,9 @@ export const discoverAccessRequests = defineAuthorizedAccessRequestUseCase({
scope: (input: DiscoverAccessRequestsInput) => input,
async execute({ input, actorUserId, context, executor }): Promise {
const organizationId = context.organizationId
- if (!organizationId || !(await isAccessRequestEnabled(organizationId, executor)))
+ if (!organizationId)
return { enabled: false, organizationId, entries: [], total: 0, hasMore: false }
+ const enabled = await isAccessRequestEnabled(organizationId, executor)
const catalog = await loadAccessRequestCatalog(
{
userId: actorUserId,
@@ -214,7 +215,7 @@ export const discoverAccessRequests = defineAuthorizedAccessRequestUseCase({
}
const page = entries.slice(offset, offset + (input.limit ?? 50))
return {
- enabled: true,
+ enabled,
organizationId,
entries: page,
total: entries.length,
diff --git a/apps/sim/ee/access-requests/lib/policy.ts b/apps/sim/ee/access-requests/lib/policy.ts
index 31bac8dc673..abf0d4f5392 100644
--- a/apps/sim/ee/access-requests/lib/policy.ts
+++ b/apps/sim/ee/access-requests/lib/policy.ts
@@ -13,7 +13,7 @@ import {
type AccessRequestCatalog,
buildAccessRequestPolicyDelta,
describeAccessRequestTarget,
- isAccessRequestTargetDenied,
+ getPermissionGroupAccessState,
isAccessRequestTargetInScope,
validateAccessRequestTarget,
} from '@/ee/access-requests/lib/targets'
@@ -105,13 +105,15 @@ export async function evaluateAccessRequestTarget(
const group = policy.group
if (!group)
return { state: 'allowed' as const, reason: null, group: null, delta: null, limit: null }
- const denied = isAccessRequestTargetDenied(canonical, group.config, catalog)
+ const state = getPermissionGroupAccessState(canonical, group.config, catalog)
+ if (state === 'unavailable')
+ return unavailable('Your organization does not allow requests for this item.')
const delta = includeDelta
? buildAccessRequestPolicyDelta(canonical, group.config, catalog)
: null
return {
- state: denied ? ('requestable' as const) : ('allowed' as const),
- reason: denied ? 'Restricted by your permission group.' : null,
+ state,
+ reason: state === 'requestable' ? 'Restricted by your permission group.' : null,
group,
delta,
limit: null,
diff --git a/apps/sim/ee/access-requests/lib/targets.test.ts b/apps/sim/ee/access-requests/lib/targets.test.ts
index 96c18204950..6c03eabd2fd 100644
--- a/apps/sim/ee/access-requests/lib/targets.test.ts
+++ b/apps/sim/ee/access-requests/lib/targets.test.ts
@@ -7,7 +7,7 @@ import {
createAccessRequestCatalog,
describeAccessRequestTarget,
getAccessRequestTargetKey,
- isAccessRequestTargetDenied,
+ getPermissionGroupAccessState,
isAccessRequestTargetInScope,
validateAccessRequestTarget,
} from '@/ee/access-requests/lib/targets'
@@ -217,8 +217,10 @@ describe('access request targets', () => {
)
expect(table.changes.map((change) => change.configKey)).toEqual(['hideTablesTab'])
for (const delta of [mcp, upload, table]) {
- expect(isAccessRequestTargetDenied(delta.target, config, moduleCatalog)).toBe(true)
- expect(isAccessRequestTargetDenied(delta.target, delta.config, moduleCatalog)).toBe(false)
+ expect(getPermissionGroupAccessState(delta.target, config, moduleCatalog)).toBe('requestable')
+ expect(getPermissionGroupAccessState(delta.target, delta.config, moduleCatalog)).toBe(
+ 'allowed'
+ )
}
})
@@ -260,7 +262,7 @@ describe('access request targets', () => {
]
for (const config of configs) {
for (const target of targets) {
- expect(isAccessRequestTargetDenied(target, config, catalog)).toBe(
+ expect(getPermissionGroupAccessState(target, config, catalog) !== 'allowed').toBe(
buildAccessRequestPolicyDelta(target, config, catalog).changes.length > 0
)
}
diff --git a/apps/sim/ee/access-requests/lib/targets.ts b/apps/sim/ee/access-requests/lib/targets.ts
index 3b42c695145..be0cf1174ff 100644
--- a/apps/sim/ee/access-requests/lib/targets.ts
+++ b/apps/sim/ee/access-requests/lib/targets.ts
@@ -308,63 +308,80 @@ function integrationDenied(config: PermissionGroupConfig, integrationId: string)
)
}
-/** Tests denial without copying full policies or their potentially large denylists. */
-export function isAccessRequestTargetDenied(
+/** Group policy state before applying deployment availability, workspace roles, and scope. */
+export type PermissionGroupAccessState = 'allowed' | 'requestable' | 'unavailable'
+
+/** Prerequisites whose restrictions an approval would also have to lift. */
+export function getAccessRequestTargetParents(
target: AccessRequestTarget,
- config: PermissionGroupConfig,
catalog: AccessRequestCatalog
-): boolean {
- const canonical = validateAccessRequestTarget(target, catalog)
- if (!canonical) throw new Error('Unknown access request target')
- if (requiredFeatures(canonical, catalog).some((key) => config[key])) return true
- switch (canonical.kind) {
+): AccessRequestTarget[] {
+ const parents: AccessRequestTarget[] = requiredFeatures(target, catalog)
+ .filter((key) => target.kind !== 'feature' || key !== target.configKey)
+ .map((configKey) => ({ kind: 'feature', configKey }))
+ if (target.kind === 'model') {
+ const providerId = catalog.models.get(target.id.toLowerCase())?.providerId
+ if (providerId) parents.push({ kind: 'provider', id: providerId })
+ }
+ if (target.kind === 'tool') {
+ const integrationId = catalog.tools.get(target.id)?.integrationId
+ if (integrationId)
+ parents.push({ kind: 'integration', id: normalizeIntegration(integrationId) })
+ }
+ return parents
+}
+
+function directlyDenied(target: AccessRequestTarget, config: PermissionGroupConfig): boolean {
+ switch (target.kind) {
case 'feature':
- return false
+ return config[target.configKey]
case 'integration':
- return integrationDenied(config, canonical.id)
+ return integrationDenied(config, target.id)
case 'provider':
return (
- config.allowedModelProviders !== null &&
- !config.allowedModelProviders.includes(canonical.id)
- )
- case 'model': {
- const providerId = catalog.models.get(canonical.id.toLowerCase())?.providerId
- return (
- config.deniedModels.some((id) => id.toLowerCase() === canonical.id.toLowerCase()) ||
- Boolean(
- providerId &&
- config.allowedModelProviders !== null &&
- !config.allowedModelProviders.includes(providerId)
- )
+ config.allowedModelProviders !== null && !config.allowedModelProviders.includes(target.id)
)
- }
- case 'tool': {
- const integrationId = catalog.tools.get(canonical.id)?.integrationId
- return (
- config.deniedTools.includes(canonical.id) ||
- Boolean(integrationId && integrationDenied(config, integrationId))
- )
- }
+ case 'model':
+ return config.deniedModels.some((id) => id.toLowerCase() === target.id.toLowerCase())
+ case 'tool':
+ return config.deniedTools.includes(target.id)
case 'knowledge_connector':
return (
config.allowedKnowledgeConnectors !== null &&
- !config.allowedKnowledgeConnectors.includes(canonical.id)
+ !config.allowedKnowledgeConnectors.includes(target.id)
)
case 'file_share_auth':
return (
config.allowedFileShareAuthTypes !== null &&
- !config.allowedFileShareAuthTypes.includes(canonical.id)
+ !config.allowedFileShareAuthTypes.includes(target.id)
)
case 'chat_deploy_auth':
return (
config.allowedChatDeployAuthTypes !== null &&
- !config.allowedChatDeployAuthTypes.includes(canonical.id)
+ !config.allowedChatDeployAuthTypes.includes(target.id)
)
case 'usage_limit':
throw new Error('Usage limit requests require a billing limit change')
}
}
+/** Group policy only; callers still enforce catalog, deployment, role and scope ceilings. */
+export function getPermissionGroupAccessState(
+ target: AccessRequestTarget,
+ config: PermissionGroupConfig,
+ catalog: AccessRequestCatalog
+): PermissionGroupAccessState {
+ const requirements = [target, ...getAccessRequestTargetParents(target, catalog)]
+ let state: PermissionGroupAccessState = 'allowed'
+ for (const requirement of requirements) {
+ if (!directlyDenied(requirement, config)) continue
+ const key = getAccessRequestTargetKey(requirement)
+ if (config.deniedAccessRequests.includes(key)) return 'unavailable'
+ state = 'requestable'
+ }
+ return state
+}
+
function allowIntegration(config: PermissionGroupConfig, id: string) {
const allowed = toAccessControlAllowlist(config.allowedIntegrations)
const canonical = normalizeIntegration(id)
diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts
index 281e0c48df8..b195c486e38 100644
--- a/apps/sim/hooks/queries/access-requests.ts
+++ b/apps/sim/hooks/queries/access-requests.ts
@@ -41,7 +41,7 @@ export function useDiscoverAccessRequests(query: DiscoverAccessRequestsQuery, en
queryKey: accessRequestKeys.discovery(query),
queryFn: async ({ signal }) => {
const result = await requestJson(discoverAccessRequestsContract, { query, signal })
- if (result.enabled && query.kind === 'workspace') {
+ if (query.kind === 'workspace') {
void queryClient.refetchQueries(
{
queryKey: permissionGroupKeys.userConfig(query.workspaceId),
@@ -74,7 +74,7 @@ export function useDiscoverAccessRequests(query: DiscoverAccessRequestsQuery, en
Boolean(query.kind === 'workspace' ? query.workspaceId : query.organizationId) && enabled,
staleTime: ACCESS_REQUESTS_STALE_TIME,
refetchInterval: (query) =>
- query.state.data?.enabled === false ? false : ACCESS_REQUESTS_POLL_INTERVAL,
+ query.state.data?.organizationId === null ? false : ACCESS_REQUESTS_POLL_INTERVAL,
})
}
diff --git a/apps/sim/hooks/use-integration-access.ts b/apps/sim/hooks/use-integration-access.ts
index 51f228b0dea..c7cb0e65d4c 100644
--- a/apps/sim/hooks/use-integration-access.ts
+++ b/apps/sim/hooks/use-integration-access.ts
@@ -79,6 +79,7 @@ export function useIntegrationAccessPolicy(
const policy = useMemo(
() =>
createIntegrationAccessPolicy({
+ groupConfig: permissionData?.config,
groupAllowlist: permissionData?.config?.allowedIntegrations ?? null,
deploymentAllowlist: availabilityData?.allowedIntegrations ?? null,
availability: availabilityData?.integrationAvailability ?? [],
diff --git a/apps/sim/lib/integrations/access.test.ts b/apps/sim/lib/integrations/access.test.ts
index 0237447beb8..8ccf2bfb0f9 100644
--- a/apps/sim/lib/integrations/access.test.ts
+++ b/apps/sim/lib/integrations/access.test.ts
@@ -5,6 +5,10 @@ import {
type IntegrationAccess,
resolveIntegrationAccess,
} from '@/lib/integrations/access'
+import {
+ DEFAULT_PERMISSION_GROUP_CONFIG,
+ type PermissionGroupConfig,
+} from '@/lib/permission-groups/fields'
function accessOf(
blockType: string,
@@ -13,6 +17,7 @@ function accessOf(
deploymentAllowlist?: string[] | null
availability?: IntegrationAvailabilityResponse[]
credentialGroupsEnabled?: boolean
+ groupConfig?: PermissionGroupConfig
}
): IntegrationAccess {
return resolveIntegrationAccess(
@@ -23,6 +28,7 @@ function accessOf(
availability: policy.availability ?? [],
visibility: null,
credentialGroupsEnabled: policy.credentialGroupsEnabled ?? true,
+ groupConfig: policy.groupConfig,
})
)
}
@@ -57,7 +63,11 @@ describe('resolveIntegrationAccess', () => {
})
it('keeps the universal entry point available under an empty allowlist', () => {
- expect(accessOf('start_trigger', { groupAllowlist: [] })).toBe('available')
+ const policy = {
+ groupAllowlist: [],
+ groupConfig: { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: [] },
+ }
+ expect(accessOf('start_trigger', policy)).toBe('available')
})
it('denies credential groups where the feature is off, even when unrestricted', () => {
diff --git a/apps/sim/lib/integrations/access.ts b/apps/sim/lib/integrations/access.ts
index 05ff186a12b..953b7352e4b 100644
--- a/apps/sim/lib/integrations/access.ts
+++ b/apps/sim/lib/integrations/access.ts
@@ -5,11 +5,16 @@ import {
resolveIntegrationAvailabilityStateForVisibility,
} from '@/lib/integrations/availability'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
+import type { PermissionGroupConfig } from '@/lib/permission-groups/fields'
import {
intersectAccessControlAllowlists,
resolveAccessControlBlockType,
toAccessControlAllowlist,
} from '@/lib/permission-groups/integration-allowlist'
+import {
+ createAccessRequestCatalog,
+ getPermissionGroupAccessState,
+} from '@/ee/access-requests/lib/targets'
/**
* What a viewer can do with an integration in one workspace — the single
@@ -47,6 +52,7 @@ export interface IntegrationAccessPolicy {
/** Deployment availability by lowercased block type, projected through the viewer's visibility. */
availability: ReadonlyMap
credentialGroupsEnabled: boolean
+ groupConfig?: PermissionGroupConfig | null
}
interface IntegrationAccessPolicyInput {
@@ -55,6 +61,7 @@ interface IntegrationAccessPolicyInput {
availability: readonly IntegrationAvailabilityResponse[]
visibility: BlockVisibilityState | null
credentialGroupsEnabled: boolean
+ groupConfig?: PermissionGroupConfig | null
}
export function createIntegrationAccessPolicy({
@@ -63,6 +70,7 @@ export function createIntegrationAccessPolicy({
availability,
visibility,
credentialGroupsEnabled,
+ groupConfig,
}: IntegrationAccessPolicyInput): IntegrationAccessPolicy {
return {
allowlist: intersectAccessControlAllowlists(groupAllowlist, deploymentAllowlist),
@@ -74,9 +82,18 @@ export function createIntegrationAccessPolicy({
])
),
credentialGroupsEnabled,
+ groupConfig,
}
}
+const INTEGRATION_REQUEST_CATALOG = createAccessRequestCatalog({
+ integrations: [],
+ providers: [],
+ models: [],
+ tools: [],
+ knowledgeConnectors: [],
+})
+
/**
* The policy is judged before deployment configuration, so an integration the
* permission group restricts is never advertised as merely unconfigured.
@@ -94,7 +111,16 @@ export function resolveIntegrationAccess(
isDeploymentGatedIntegrationType(normalizedBlockType) &&
(state === 'unavailable' || state === 'misconfigured')
- if (isPermittedBy(policy.allowlist, blockType)) {
+ const groupState =
+ policy.groupConfig && !isBlockTypeAccessControlExempt(normalizedBlockType)
+ ? getPermissionGroupAccessState(
+ { kind: 'integration', id: resolveAccessControlBlockType(normalizedBlockType) },
+ policy.groupConfig,
+ INTEGRATION_REQUEST_CATALOG
+ )
+ : 'allowed'
+ if (groupState === 'unavailable') return 'denied'
+ if (groupState === 'allowed' && isPermittedBy(policy.allowlist, blockType)) {
return unconfigured ? 'unconfigured' : 'available'
}
if (unconfigured) return 'denied'
diff --git a/apps/sim/lib/permission-groups/fields.test.ts b/apps/sim/lib/permission-groups/fields.test.ts
index 1508327a229..40248a40220 100644
--- a/apps/sim/lib/permission-groups/fields.test.ts
+++ b/apps/sim/lib/permission-groups/fields.test.ts
@@ -164,6 +164,7 @@ const fixtures: readonly CoercionFixture[] = [
disableOAuthAppAccess: true,
disableKnowledgeBaseExport: true,
deniedPartialAccessProjectIssues: ['project-a'],
+ deniedAccessRequests: ['feature:hideTablesTab'],
},
expected: {
allowedIntegrations: ['slack_v2'],
@@ -209,6 +210,7 @@ const fixtures: readonly CoercionFixture[] = [
disableOAuthAppAccess: true,
disableKnowledgeBaseExport: true,
deniedPartialAccessProjectIssues: ['project-a'],
+ deniedAccessRequests: ['feature:hideTablesTab'],
defaultAgentModel: null,
},
},
diff --git a/apps/sim/lib/permission-groups/fields.ts b/apps/sim/lib/permission-groups/fields.ts
index ee5d18c724e..c3ae2521abf 100644
--- a/apps/sim/lib/permission-groups/fields.ts
+++ b/apps/sim/lib/permission-groups/fields.ts
@@ -18,6 +18,7 @@ const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES)
* operation-level gate can express.
* - `ui-only`: the key hides a surface without withholding it, so a caller that
* skips the UI still reaches the API.
+ * - `request`: controls access-request admission and approval, without granting use.
* - `default`: a preference used when initializing a resource, without restricting access.
*
* Declared rather than inferred, because `ui-only` is the value an admin is
@@ -32,7 +33,7 @@ const shareAuthType = z.enum(FILE_SHARE_AUTH_TYPES)
* they share, and `check:permission-group-enforcement` is what keeps it honest
* in both directions.
*/
-type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' | 'default'
+type PermissionGroupEnforcement = 'capability' | 'executor' | 'ui-only' | 'default' | 'request'
/**
* Which group a key's capability is actually read from when it is enforced.
@@ -193,11 +194,11 @@ function allowlist(
}
}
-function denylist(
+function denylist(
item: TItem,
- enforcement: PermissionGroupEnforcement,
+ enforcement: TEnforcement,
phrasing: string
-): DenylistField {
+): DenylistField & { readonly enforcement: TEnforcement } {
const schema = z.array(item).describe(phrasing.replace(/effectiveConfig\.\w+/g, 'this list'))
return {
kind: 'denylist',
@@ -527,20 +528,31 @@ export const PERMISSION_GROUP_FIELDS = {
default: null,
enforcement: 'default',
},
+ deniedAccessRequests: denylist(
+ z.string().min(1).max(1024),
+ 'request',
+ 'Access requests are unavailable for the listed canonical target keys when access is restricted.'
+ ),
} satisfies Record
export type PermissionGroupFields = typeof PERMISSION_GROUP_FIELDS
export type PermissionGroupConfigKey = keyof PermissionGroupFields
export type PermissionGroupRestrictionKey = {
- [K in PermissionGroupConfigKey]: PermissionGroupFields[K]['kind'] extends 'preference' ? never : K
+ [K in PermissionGroupConfigKey]: PermissionGroupFields[K]['enforcement'] extends
+ | 'default'
+ | 'request'
+ ? never
+ : K
}[PermissionGroupConfigKey]
/** Restriction fields that access-request approvals may change. */
export const PERMISSION_GROUP_RESTRICTION_KEYS = (
Object.keys(PERMISSION_GROUP_FIELDS) as PermissionGroupConfigKey[]
).filter(
- (key): key is PermissionGroupRestrictionKey => PERMISSION_GROUP_FIELDS[key].kind !== 'preference'
+ (key): key is PermissionGroupRestrictionKey =>
+ PERMISSION_GROUP_FIELDS[key].enforcement !== 'default' &&
+ PERMISSION_GROUP_FIELDS[key].enforcement !== 'request'
)
type DerivedPermissionGroupConfig = {
diff --git a/packages/emcn/src/components/info/info.tsx b/packages/emcn/src/components/info/info.tsx
index 71b8ad16309..e534ef1c621 100644
--- a/packages/emcn/src/components/info/info.tsx
+++ b/packages/emcn/src/components/info/info.tsx
@@ -1,8 +1,9 @@
'use client'
-import type { ReactNode } from 'react'
+import { type ReactNode, useId, useState } from 'react'
import { cn } from '../../lib/cn'
-import { Tooltip } from '../tooltip/tooltip'
+import { Popover, PopoverContent, PopoverTrigger } from '../popover/popover'
+import { FloatingTooltip, Tooltip, useFloatingTooltip } from '../tooltip/tooltip'
/**
* Tooltip placement side.
@@ -17,51 +18,54 @@ type InfoAlign = 'start' | 'center' | 'end'
interface InfoProps {
/** Tooltip content rendered on hover. */
children: ReactNode
+ /** Accessible name describing the subject of the help. */
+ label?: string
+ /** Adds a larger target and click/tap-pinned help; compact badges remain the default. */
+ interactive?: boolean
/** Optional class names applied to the badge. */
className?: string
- /** Tooltip side. Defaults to `'top'`. */
+ /** Preferred side for pinned help. Hover help follows the cursor. Defaults to `'top'`. */
side?: InfoSide
- /** Tooltip alignment. Defaults to `'start'`. */
+ /** Alignment for pinned help. Hover help follows the cursor. Defaults to `'start'`. */
align?: InfoAlign
}
/**
- * Inline info badge — an outlined rounded square with a slightly
- * slanted `i` glyph that reveals a tooltip on hover. Drawn as an SVG
- * so the stroke matches the rest of the EMCN icon set.
+ * Inline info badge with hover/focus help. Set interactive for a larger trigger
+ * that pins the same content on click or tap.
*
* @example
* ```tsx
* Events that start a workflow
* ```
*/
-export function Info({ children, className, side = 'top', align = 'start' }: InfoProps) {
+export function Info({
+ children,
+ className,
+ side = 'top',
+ align = 'start',
+ label = 'More information',
+ interactive = false,
+}: InfoProps) {
+ if (interactive) {
+ return (
+
+ {children}
+
+ )
+ }
return (
-
-
-
-
-
+ {INFO_ICON}
@@ -70,3 +74,70 @@ export function Info({ children, className, side = 'top', align = 'start' }: Inf
)
}
+
+function InteractiveInfo({ children, className, side, align, label }: InfoProps) {
+ const contentId = useId()
+ const [open, setOpen] = useState(false)
+ const { state, handlers, dismiss } = useFloatingTooltip(() => !open, {
+ preferAbove: side === 'top',
+ revalidateKey: open,
+ dismissOnEscape: true,
+ })
+ return (
+ {
+ if (!nextOpen) dismiss()
+ setOpen(nextOpen)
+ }}
+ >
+
+ event.stopPropagation()}
+ >
+ {INFO_ICON}
+
+
+ {!open && (
+
+ {children}
+
+ )}
+
+ {children}
+
+
+ )
+}
+
+const INFO_ICON = (
+
+
+
+
+
+)
diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx
index 286a6c3aef1..cf11f24f488 100644
--- a/packages/emcn/src/components/tooltip/tooltip.tsx
+++ b/packages/emcn/src/components/tooltip/tooltip.tsx
@@ -98,6 +98,8 @@ export interface UseFloatingTooltipOptions {
getFocusTarget?: () => HTMLElement | null
/** Semantic value whose changes should revalidate a visible tooltip. */
revalidateKey?: unknown
+ /** Dismiss visible help with Escape until the next hover or focus gesture. Defaults to false. */
+ dismissOnEscape?: boolean
}
export function useFloatingTooltip(
@@ -106,6 +108,8 @@ export function useFloatingTooltip(
): {
state: FloatingTooltipState
handlers: FloatingTooltipHandlers
+ /** Hides help until a fresh hover or focus gesture, including while the pointer stays on its trigger. */
+ dismiss: () => void
} {
const canShowRef = React.useRef(canShow)
canShowRef.current = canShow
@@ -115,6 +119,7 @@ export function useFloatingTooltip(
const lastPointerRef = React.useRef(null)
const velocityRef = React.useRef({ x: 0, magnitude: 0 })
const triggerRef = React.useRef(null)
+ const dismissedRef = React.useRef(false)
const [state, setState] = React.useState(HIDDEN_STATE)
const reset = React.useCallback(() => {
@@ -129,6 +134,11 @@ export function useFloatingTooltip(
setState((current) => (current.visible ? HIDDEN_STATE : current))
}, [reset])
+ const dismiss = React.useCallback(() => {
+ dismissedRef.current = true
+ hide()
+ }, [hide])
+
const apply = React.useCallback((clientX: number, clientY: number, motion: TooltipMotion) => {
const next = { ...getTooltipPosition(clientX, clientY, preferAboveRef.current), ...motion }
setState((current) =>
@@ -175,12 +185,13 @@ export function useFloatingTooltip(
return {
onPointerEnter: (event) => {
if (event.pointerType === 'touch') return
+ dismissedRef.current = false
if (!canShowRef.current(event.currentTarget)) return
triggerRef.current = event.currentTarget
showFromPointer(event.clientX, event.clientY)
},
onPointerMove: (event) => {
- if (event.pointerType === 'touch') return
+ if (event.pointerType === 'touch' || dismissedRef.current) return
if (!canShowRef.current(event.currentTarget)) return
triggerRef.current = event.currentTarget
const now = performance.now()
@@ -210,6 +221,7 @@ export function useFloatingTooltip(
onPointerDown: hide,
onFocus: (event) => {
const target = event.currentTarget
+ dismissedRef.current = false
if (!canShowRef.current(target)) return
if (!isFocusVisible(target)) return
triggerRef.current = target
@@ -223,6 +235,7 @@ export function useFloatingTooltip(
const target = options.getFocusTarget?.()
if (!target) return undefined
const show = () => {
+ dismissedRef.current = false
if (!canShowRef.current(target) || !isFocusVisible(target)) return
triggerRef.current = target
showFromElement(target)
@@ -240,6 +253,18 @@ export function useFloatingTooltip(
if (state.visible && (!trigger || !canShowRef.current(trigger))) hide()
}, [hide, options.revalidateKey, state.visible])
+ React.useEffect(() => {
+ if (!options.dismissOnEscape || !state.visible) return undefined
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return
+ event.preventDefault()
+ event.stopPropagation()
+ dismiss()
+ }
+ window.addEventListener('keydown', onKeyDown, true)
+ return () => window.removeEventListener('keydown', onKeyDown, true)
+ }, [dismiss, options.dismissOnEscape, state.visible])
+
/**
* A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event —
* browsers don't re-dispatch boundary events until the pointer next moves (e.g. an editor bubble
@@ -255,7 +280,7 @@ export function useFloatingTooltip(
return () => window.clearInterval(intervalId)
}, [state.visible, hide])
- return { state, handlers }
+ return { state, handlers, dismiss }
}
const overflowMeasureByElement = new WeakMap void>()
diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts
index fc7eaabf857..73865f365d0 100644
--- a/packages/sim-cli/src/generated/v2-api.ts
+++ b/packages/sim-cli/src/generated/v2-api.ts
@@ -2978,6 +2978,7 @@ export type CreatePermissionGroupBody = {
disableKnowledgeBaseExport?: boolean
deniedPartialAccessProjectIssues?: Array
defaultAgentModel?: string | null
+ deniedAccessRequests?: Array
}
isDefault?: boolean
workspaceIds?: Array
@@ -3033,6 +3034,7 @@ type CreatePermissionGroupResponseRef0 = {
disableKnowledgeBaseExport: boolean
deniedPartialAccessProjectIssues: Array
defaultAgentModel: string | null
+ deniedAccessRequests: Array
}
isDefault: boolean
membershipMode: string
@@ -6852,6 +6854,7 @@ type GetPermissionGroupResponseRef0 = {
disableKnowledgeBaseExport: boolean
deniedPartialAccessProjectIssues: Array
defaultAgentModel: string | null
+ deniedAccessRequests: Array
}
isDefault: boolean
membershipMode: string
@@ -8217,6 +8220,7 @@ type GetWorkspacePermissionConfigResponseRef0 = {
disableKnowledgeBaseExport: boolean
deniedPartialAccessProjectIssues: Array
defaultAgentModel: string | null
+ deniedAccessRequests: Array
} | null
entitled: boolean
organizationId: string | null
@@ -9954,6 +9958,7 @@ type ListPermissionGroupsResponseRef0 = {
disableKnowledgeBaseExport: boolean
deniedPartialAccessProjectIssues: Array
defaultAgentModel: string | null
+ deniedAccessRequests: Array
}
isDefault: boolean
membershipMode: string
@@ -14786,6 +14791,7 @@ export type UpdatePermissionGroupBody = {
disableKnowledgeBaseExport?: boolean
deniedPartialAccessProjectIssues?: Array
defaultAgentModel?: string | null
+ deniedAccessRequests?: Array
}
isDefault?: boolean
workspaceIds?: Array
@@ -14841,6 +14847,7 @@ type UpdatePermissionGroupResponseRef0 = {
disableKnowledgeBaseExport: boolean
deniedPartialAccessProjectIssues: Array
defaultAgentModel: string | null
+ deniedAccessRequests: Array
}
isDefault: boolean
membershipMode: string
diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json
index ca374a5b8da..d2b8ea10511 100644
--- a/scripts/check-explicit-any.baseline.json
+++ b/scripts/check-explicit-any.baseline.json
@@ -1291,7 +1291,7 @@
"apps/sim/connectors/sftp/sftp.test.ts": 6,
"apps/sim/connectors/sharepoint/sharepoint.test.ts": 4,
"apps/sim/connectors/sharepoint/sharepoint.ts": 2,
- "apps/sim/ee/access-control/components/group-detail.test.tsx": 2,
+ "apps/sim/ee/access-control/components/group-detail.test.tsx": 1,
"apps/sim/ee/access-requests/components/policy-changes.tsx": 1,
"apps/sim/ee/access-requests/lib/application/requests.ts": 4,
"apps/sim/ee/access-requests/lib/repository.integration.ts": 1,