From 44d3679408673ace9dbc8e2e0c649ba2922f66b4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 14:15:35 -0700 Subject: [PATCH 1/5] feat(access-control): govern access requests per permission item --- .../platform/enterprise/access-control.mdx | 28 +- apps/docs/openapi-v2-resources.json | 68 +- .../[block]/integration-block-detail.tsx | 4 +- .../integrations/integrations.tsx | 16 +- .../settings/[section]/search-params.ts | 8 +- .../panel/components/toolbar/toolbar.tsx | 31 +- .../components/search-modal/search-modal.tsx | 16 +- .../settings-sidebar/settings-sidebar.tsx | 36 +- .../w/components/sidebar/sidebar.tsx | 34 +- .../components/access-state-select.tsx | 67 ++ .../components/group-detail.test.tsx | 49 +- .../components/group-detail.tsx | 1066 +++-------------- .../components/permission-rows.tsx | 403 +++++++ .../access-control/hooks/permission-groups.ts | 41 +- .../ee/access-control/utils/access-state.ts | 83 ++ apps/sim/ee/access-requests/README.md | 5 +- .../components/access-requests-settings.tsx | 17 +- .../components/permission-access-boundary.tsx | 20 +- .../components/request-access-action.tsx | 4 +- .../lib/application/flow.integration.ts | 128 +- .../lib/application/requests.ts | 5 +- apps/sim/ee/access-requests/lib/policy.ts | 10 +- .../ee/access-requests/lib/targets.test.ts | 10 +- apps/sim/ee/access-requests/lib/targets.ts | 82 +- apps/sim/hooks/queries/access-requests.ts | 5 +- apps/sim/hooks/use-integration-access.ts | 1 + apps/sim/lib/integrations/access.test.ts | 12 +- apps/sim/lib/integrations/access.ts | 28 +- apps/sim/lib/permission-groups/fields.test.ts | 2 + apps/sim/lib/permission-groups/fields.ts | 24 +- packages/sim-cli/src/generated/v2-api.ts | 7 + scripts/check-explicit-any.baseline.json | 2 +- 32 files changed, 1267 insertions(+), 1045 deletions(-) create mode 100644 apps/sim/ee/access-control/components/access-state-select.tsx create mode 100644 apps/sim/ee/access-control/components/permission-rows.tsx create mode 100644 apps/sim/ee/access-control/utils/access-state.ts diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index cb77f55df4e..936bc8f8bdc 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -20,7 +20,21 @@ Organization owners and admins manage groups under **Settings → Organization Changes to workspace scope, the default-group switch, and membership take effect immediately. **Discard** only discards the buffered name, description, and permission changes. -In the provider, block, and platform permission lists, a checked box allows the item. Clear it to restrict the item. The **Project Issues** project checklist is an exception: checked projects are restricted. +Choose an access state in the provider, block, and platform permission lists: + +| State | What members can do | +| --- | --- | +| **Allowed** | Use the item, subject to their workspace role and deployment settings. | +| **Can request** | Discover the item and ask an administrator for access. Use stays blocked until approved. | +| **Unavailable** | Cannot use or request the item. It is omitted from normal discovery; direct links remain restricted. | + +Use **Unavailable** for software your organization has not procured. Existing groups retain their permissions and request behavior until you change them. **Project Issues** keeps its separate checklist: checked projects are restricted. + +Search and the state filter narrow each list. **Set shown to…** changes only the visible, editable rows and offers choices supported by all of them. Changes remain a draft until **Save**; **Discard** restores the saved configuration. + +A restricted parent also restricts its children. Expand a provider, integration, or platform feature to inspect its models, tools, connectors, or authentication modes. Allow the parent before editing its children. Child choices are preserved when a parent changes. An unavailable child stays unavailable even if its parent allows requests. Requests cannot bypass an unavailable parent. + +Requests still respect deployment availability and workspace roles. Custom blocks, private dynamically discovered models, and other items outside the public request catalog do not offer **Can request**. ### General @@ -47,7 +61,7 @@ The **Agent default** chooses the initial model for new Agent blocks. Choose an ### Blocks -**Core Blocks** contains blocks such as Agent, API, and Function; **Tools** contains integration blocks. All are allowed by default. Select a subset to restrict block types, then expand an integration to deny individual operations—for example, allow reads while refusing deletes. +**Core Blocks** contains blocks such as Agent, API, and Function; **Integrations and Triggers** contains integration blocks. All are allowed by default. Select a subset to restrict block types, then expand an integration to deny individual operations—for example, allow reads while refusing deletes. The `start_trigger` entry block is always allowed. Existing workflows are not rewritten when a restriction changes. A run fails if it reaches a blocked type, provider, model, or tool operation. @@ -104,7 +118,7 @@ Restricting **Tool Auto-Approval** prevents remembering approval for future call #### Organization-level permissions -**Workspace Creation** and **Member Directory** read only the organization default, because those actions have no workspace scope. Other groups show them as inactive with an **Organization** tag, and **Select All** skips them. A newly created workspace uses the organization default until you add it to a workspace group. +**Workspace Creation** and **Member Directory** read only the organization default, because those actions have no workspace scope. Other groups show them as inactive with an **Organization** tag, and **Set shown to…** skips them. A newly created workspace uses the organization default until you add it to a workspace group. **Integrations**, **API Keys**, **Invitations**, **Personal API Keys**, **CLI Access**, and **OAuth App Access** use the selected workspace group for workspace operations. Account-level operations use the organization default: minting a personal key, sending organization invitations, approving CLI login, authorizing or refreshing OAuth, and account-level billing or audit reads. @@ -116,6 +130,14 @@ A **workspace API key** acts as the workspace. It has workspace-role and operati Chat filters restricted blocks from its available block catalog. Generated steps can still reference a restricted MCP tool, custom tool, or skill; execution rejects the restricted operation when reached. After changing a group, verify both the person's available UI and a representative authorized run. For an existing workflow that fails, remove the restricted item or change the governing policy. +## Access requests + +Members can request items marked **Can request** from a locked feature or **Settings → Requests → Browse access**. Administrators review the request and its impact before approving. Approval updates the governing permission group, so it affects everyone governed by that group; it is not a personal exception. + +An organization administrator can pause requests in **Settings → Requests → Review requests**. Requestable features remain discoverable, but new requests and approvals are disabled. The group's saved choices, request history, and cancellation remain available. Changing an item to **Unavailable** also prevents approval of an older pending request for that item or a dependent child. Administrators can still decline those requests. + +The API keeps the existing access fields. The optional `deniedAccessRequests` list stores canonical target keys such as `integration:salesforce` and `feature:hideTablesTab`. An absent or empty list preserves the previous request behavior. Partial configuration updates retain unspecified choices. Request approval never changes this list. Member credit-limit requests remain separate from permission-group access settings. + ## Self-hosted setup On billing-disabled deployments, enable permission groups through the enterprise feature configuration or explicitly set: diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 2bc8f684e62..9f882a7f233 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -6953,7 +6953,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -7113,7 +7114,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -7273,7 +7275,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -7441,7 +7444,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -21256,6 +21260,16 @@ } ], "description": "Default model for new Agent blocks. Null uses the platform default." + }, + "deniedAccessRequests": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." } }, "required": [ @@ -21302,7 +21316,8 @@ "disableOAuthAppAccess", "disableKnowledgeBaseExport", "deniedPartialAccessProjectIssues", - "defaultAgentModel" + "defaultAgentModel", + "deniedAccessRequests" ], "additionalProperties": false, "description": "Resolved group settings. True disables a boolean capability; null allowlists permit every value and empty allowlists permit none. defaultAgentModel selects the default for new Agent blocks without restricting model selection." @@ -21434,7 +21449,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -21511,7 +21527,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -21786,6 +21803,15 @@ } ], "description": "Default model for new Agent blocks. Null uses the platform default." + }, + "deniedAccessRequests": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." } }, "additionalProperties": false, @@ -21879,7 +21905,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -21954,7 +21981,8 @@ "disableOAuthAppAccess": false, "disableKnowledgeBaseExport": false, "deniedPartialAccessProjectIssues": [], - "defaultAgentModel": null + "defaultAgentModel": null, + "deniedAccessRequests": [] }, "isDefault": false, "membershipMode": "inherit", @@ -22236,6 +22264,15 @@ } ], "description": "Default model for new Agent blocks. Null uses the platform default." + }, + "deniedAccessRequests": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." } }, "additionalProperties": false, @@ -24600,6 +24637,16 @@ } ], "description": "Default model for new Agent blocks. Null uses the platform default." + }, + "deniedAccessRequests": { + "default": [], + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "description": "Access requests are unavailable for the listed canonical target keys when access is restricted." } }, "required": [ @@ -24646,7 +24693,8 @@ "disableOAuthAppAccess", "disableKnowledgeBaseExport", "deniedPartialAccessProjectIssues", - "defaultAgentModel" + "defaultAgentModel", + "deniedAccessRequests" ], "additionalProperties": false }, diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 2393fbd0e29..716dbc7a5bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -102,7 +102,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]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 47d0512f5e0..7ce2c57531f 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..412f75a6842 --- /dev/null +++ b/apps/sim/ee/access-control/components/access-state-select.tsx @@ -0,0 +1,67 @@ +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' }, +] + +interface AccessStateSelectProps { + label: string + value: PermissionGroupAccessState + onChange: (state: PermissionGroupAccessState) => void + disabled?: boolean + canRequest?: boolean +} + +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 +} + +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..71a38939443 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,21 @@ 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, + 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 +106,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 +114,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 +128,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 +153,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 +264,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 ( - - ) - })} -
-
- ) -} - -interface DynamicProviderModelsProps extends DenylistControls { - provider: ProviderName - workspaceId?: string -} - -function DynamicProviderModels({ provider, workspaceId, ...controls }: DynamicProviderModelsProps) { - const { data, isPending } = useProviderModels(provider, workspaceId) - const items = useMemo( - () => (data?.models ?? []).map((model) => ({ id: model, label: model })), - [data?.models] - ) - return ( - - ) -} - -interface StaticProviderModelsProps extends DenylistControls { - providerId: ProviderId -} - -function StaticProviderModels({ providerId, ...controls }: StaticProviderModelsProps) { - const items = useMemo( - () => getProviderModels(providerId).map((model) => ({ id: model, label: model })), - [providerId] - ) - return ( - - ) -} - -interface ProviderRowProps extends DenylistControls { - providerId: ProviderId - isProviderAllowed: boolean - onToggleProvider: () => void - deniedCount: number - workspaceId?: string -} - -function ProviderRow({ - providerId, - isProviderAllowed, - onToggleProvider, - deniedCount, - workspaceId, - ...controls -}: ProviderRowProps) { - const [expanded, setExpanded] = useState(false) - - const ProviderIcon = PROVIDER_DEFINITIONS[providerId]?.icon - const providerName = - PROVIDER_DEFINITIONS[providerId]?.name || - providerId.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) - const isDynamic = (DYNAMIC_MODEL_PROVIDERS as readonly string[]).includes(providerId) - const checkboxId = `provider-${providerId}` - - return ( -
-
- onToggleProvider()} - /> -
- {ProviderIcon && } -
- -
- {expanded && isProviderAllowed && ( -
- {isDynamic ? ( - - ) : ( - - )} -
- )} -
- ) -} - -interface BlockToolRowProps extends DenylistControls { - block: BlockConfig - isBlockAllowed: boolean - onToggleBlock: () => void - deniedCount: number -} - -/** - * Integration/trigger block row. The checkbox drives whole-block access - * (`allowedIntegrations`); when allowed and the block exposes more than one - * tool, the row expands to a per-tool deny-list grid (`deniedTools`), mirroring - * the provider → models pattern. - */ -function BlockToolRow({ - block, - isBlockAllowed, - onToggleBlock, - deniedCount, - ...controls -}: BlockToolRowProps) { - const [expanded, setExpanded] = useState(false) - const BlockIcon = block.icon - const checkboxId = `block-${block.type}` - - const toolItems = useMemo( - () => (block.tools?.access ?? []).map((id) => ({ id, label: getToolMetadata(id)?.name ?? id })), - [block.tools?.access] - ) - const isExpandable = toolItems.length > 1 - - return ( -
-
- onToggleBlock()} - /> -
- {BlockIcon && } -
- - {/* 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 +430,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 +471,81 @@ 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 isInherited = (target: AccessRequestTarget) => + getAccessRequestTargetParents(target, editorCatalog).some( + (parent) => getState(parent) !== 'allowed' + ) + + 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, canRequest, onSet: setAccess } + const searchedPlatformFeatures = useMemo(() => { const search = searchTerm.trim().toLowerCase() if (!search) return PLATFORM_FEATURES @@ -856,9 +558,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 +615,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 +637,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 +669,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 +869,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 gives access. Can request requires admin approval. Unavailable prevents use and + requests. +

+ )} + {configTab === 'general' && ( <> @@ -1665,26 +1072,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 +1099,8 @@ export function GroupDetail({ toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} workspaceId={workspaceId} - isAllowed={isModelAllowed} - onToggle={toggleModel} - onSetDenied={setModelsDenied} + {...controls} /> ))}
@@ -1712,13 +1110,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 ( -
- - {block.description && ( - - {block.description} - - )} -
- ) - })} +
+ {filteredCoreBlocks.map((block) => ( + + ))}
)} @@ -1786,28 +1153,23 @@ export function GroupDetail({ label='Integrations and Triggers' headerAccessory={ - 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 +1179,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 && ( @@ -1851,46 +1207,18 @@ export function GroupDetail({ {platformCategorySections.map(({ category, features }) => (
- {features.map((feature) => { - const inert = isFeatureInertForGroup(feature, viewingGroup.isDefault) - return ( -
-
- - - {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..1945a913388 --- /dev/null +++ b/apps/sim/ee/access-control/components/permission-rows.tsx @@ -0,0 +1,403 @@ +'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 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' + +export interface PermissionEditorControls { + getState: (target: AccessRequestTarget) => PermissionGroupAccessState + isInherited: (target: AccessRequestTarget) => boolean + canRequest: (target: AccessRequestTarget) => boolean + onSet: (targets: AccessRequestTarget[], state: PermissionGroupAccessState) => void +} + +function inheritedControls( + parent: AccessRequestTarget, + 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), + } +} + +interface PermissionRowSelectProps extends PermissionEditorControls { + target: AccessRequestTarget + label: string + disabled?: boolean +} + +interface PermissionBulkSelectProps extends PermissionEditorControls { + targets: AccessRequestTarget[] +} + +export function PermissionBulkSelect({ targets, ...controls }: PermissionBulkSelectProps) { + const editable = targets.filter((target) => !controls.isInherited(target)) + return ( + controls.onSet(editable, state)} + /> + ) +} + +function PermissionRowSelect({ + target, + label, + disabled, + getState, + isInherited, + canRequest, + onSet, +}: PermissionRowSelectProps) { + const inherited = isInherited(target) + return ( +
+ {inherited && ( + Inherited from a restricted parent. Allow the parent to edit this choice. + )} + onSet([target], state)} + /> +
+ ) +} + +interface PlatformPermissionRowProps extends PermissionEditorControls { + feature: PermissionGroupPlatformFeature + inert: boolean + children?: ReactNode +} + +export function PlatformPermissionRow({ + feature, + inert, + children, + ...controls +}: PlatformPermissionRowProps) { + const [expanded, setExpanded] = useState(false) + const detailsId = useId() + const label = ( + <> + {feature.label} + {inert && Organization} + + ) + const labelClass = cn('flex min-w-0 flex-1 items-center gap-2 py-1 pl-2', inert && 'opacity-60') + return ( +
+
+ {children ? ( + + ) : ( +
{label}
+ )} + + + {inert ? `${feature.hint} ${ORGANIZATION_SCOPED_FEATURE_NOTE}` : feature.hint} + +
+ {children && expanded && ( +
+ {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 +} + +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 +} + +export function ProviderRow({ providerId, workspaceId, ...controls }: ProviderRowProps) { + const [expanded, setExpanded] = useState(false) + 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 }, controls) + const items = useMemo( + () => getProviderModels(providerId).map((id) => ({ id, label: id })), + [providerId] + ) + return ( +
+
+ {ProviderIcon && } + + +
+ {expanded && ( +
+ {dynamic ? ( + + ) : ( + + )} +
+ )} +
+ ) +} + +interface BlockToolRowProps extends PermissionEditorControls { + block: BlockConfig +} + +export function BlockToolRow({ block, ...controls }: BlockToolRowProps) { + const [expanded, setExpanded] = useState(false) + const childControls = inheritedControls({ kind: 'integration', id: block.type }, controls) + const Icon = block.icon + const items = useMemo( + () => (block.tools?.access ?? []).map((id) => ({ id, label: getToolMetadata(id)?.name ?? id })), + [block.tools?.access] + ) + return ( +
+
+
+ {Icon && } +
+ + {block.description && {block.description}} + +
+ {expanded && !!items.length && ( +
+ +
+ )} +
+ ) +} 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..cf4ae7c994a 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. + - 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..a30db98e095 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 + ? '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..507cf021e94 100644 --- a/apps/sim/ee/access-requests/lib/targets.ts +++ b/apps/sim/ee/access-requests/lib/targets.ts @@ -308,63 +308,79 @@ function integrationDenied(config: PermissionGroupConfig, integrationId: string) ) } -/** Tests denial without copying full policies or their potentially large denylists. */ -export function isAccessRequestTargetDenied( +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..06c683a63ac 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), @@ -73,8 +73,7 @@ export function useDiscoverAccessRequests(query: DiscoverAccessRequestsQuery, en enabled: 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, + refetchInterval: 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/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, From 491274254c9d711140dea03940727d4bc36ad2da Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 14:35:50 -0700 Subject: [PATCH 2/5] fix(access-control): clarify request availability states --- .../platform/enterprise/access-control.mdx | 4 ++-- .../[block]/integration-block-detail.tsx | 24 +++++++++++++++---- .../panel/components/toolbar/toolbar.tsx | 2 +- .../components/group-detail.tsx | 4 ++-- apps/sim/ee/access-requests/README.md | 2 +- .../components/request-access-action.tsx | 2 +- apps/sim/hooks/queries/access-requests.ts | 3 ++- 7 files changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index 936bc8f8bdc..8d5371b8c26 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -25,7 +25,7 @@ Choose an access state in the provider, block, and platform permission lists: | State | What members can do | | --- | --- | | **Allowed** | Use the item, subject to their workspace role and deployment settings. | -| **Can request** | Discover the item and ask an administrator for access. Use stays blocked until approved. | +| **Can request** | Discover the item and ask an administrator for access when their role and deployment settings permit it. Use stays blocked until approved. | | **Unavailable** | Cannot use or request the item. It is omitted from normal discovery; direct links remain restricted. | Use **Unavailable** for software your organization has not procured. Existing groups retain their permissions and request behavior until you change them. **Project Issues** keeps its separate checklist: checked projects are restricted. @@ -34,7 +34,7 @@ Search and the state filter narrow each list. **Set shown to…** changes only t A restricted parent also restricts its children. Expand a provider, integration, or platform feature to inspect its models, tools, connectors, or authentication modes. Allow the parent before editing its children. Child choices are preserved when a parent changes. An unavailable child stays unavailable even if its parent allows requests. Requests cannot bypass an unavailable parent. -Requests still respect deployment availability and workspace roles. Custom blocks, private dynamically discovered models, and other items outside the public request catalog do not offer **Can request**. +These choices configure the group's policy, independently of the deployment's current availability. **Allowed** and **Can request** cannot enable a deployment-disabled feature or bypass a workspace role. Members only discover and request items available to their deployment and role. Custom blocks, private dynamically discovered models, and other items outside the public request catalog do not offer **Can request**. ### General diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 716dbc7a5bf..747d3b1fb60 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -67,11 +67,9 @@ interface IntegrationBlockDetailProps { } /** - * Integration detail page, shown only when the viewer's access policy permits - * the integration (an unconfigured one included, which the page presents as - * unavailable). A direct link to a restricted one renders an access-required - * state instead of the connect actions, and nothing renders until the policy - * has loaded. + * Shows integration content only when the viewer's resolved policy permits it. + * Restricted integrations show access and request status, with loading and retry + * states while that status is being checked. */ export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) { const { getAccess, availabilityError, refetchAvailability } = useIntegrationAccess(workspaceId) @@ -93,6 +91,22 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration return } + if (access === 'requestable') { + if (accessRequests.isError) { + return ( + void accessRequests.refetch()}>Try again} + /> + ) + } + if (!accessRequests.data) { + return + } + } + const requestable = access === 'requestable' && accessRequests.data?.enabled === true return ( 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 7ce2c57531f..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 @@ -122,7 +122,7 @@ const ToolbarItem = memo(function ToolbarItem({
- Allowed gives access. Can request requires admin approval. Unavailable prevents use and - requests. + Allowed permits use. Can request requires admin approval. Unavailable blocks both. + Workspace roles and deployment settings still apply.

)} diff --git a/apps/sim/ee/access-requests/README.md b/apps/sim/ee/access-requests/README.md index cf4ae7c994a..9d61b2e1c96 100644 --- a/apps/sim/ee/access-requests/README.md +++ b/apps/sim/ee/access-requests/README.md @@ -12,7 +12,7 @@ Members request access from locked features or the block picker and track their - 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/request-access-action.tsx b/apps/sim/ee/access-requests/components/request-access-action.tsx index a30db98e095..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,7 @@ export function RequestAccessModal({ const alreadyAllowed = entry?.state === 'allowed' const unavailableReason = discovery.isSuccess && !pendingRequestId && !canRequest && !alreadyAllowed - ? !discovery.data.enabled && discovery.data.organizationId + ? !discovery.data.enabled && discovery.data.organizationId && entry?.state === 'requestable' ? 'Access requests are paused for your organization.' : (entry?.reason ?? 'Access requests are unavailable.') : null diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts index 06c683a63ac..b195c486e38 100644 --- a/apps/sim/hooks/queries/access-requests.ts +++ b/apps/sim/hooks/queries/access-requests.ts @@ -73,7 +73,8 @@ export function useDiscoverAccessRequests(query: DiscoverAccessRequestsQuery, en enabled: Boolean(query.kind === 'workspace' ? query.workspaceId : query.organizationId) && enabled, staleTime: ACCESS_REQUESTS_STALE_TIME, - refetchInterval: ACCESS_REQUESTS_POLL_INTERVAL, + refetchInterval: (query) => + query.state.data?.organizationId === null ? false : ACCESS_REQUESTS_POLL_INTERVAL, }) } From 005ca14db22b0d29acf8bf84f04f4c61172f1deb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 14:56:37 -0700 Subject: [PATCH 3/5] chore(access-control): document and type permission controls --- .../ee/access-control/components/access-state-select.tsx | 4 +++- apps/sim/ee/access-control/components/permission-rows.tsx | 6 ++++++ apps/sim/ee/access-requests/lib/targets.ts | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/sim/ee/access-control/components/access-state-select.tsx b/apps/sim/ee/access-control/components/access-state-select.tsx index 412f75a6842..ed5d99fb934 100644 --- a/apps/sim/ee/access-control/components/access-state-select.tsx +++ b/apps/sim/ee/access-control/components/access-state-select.tsx @@ -5,7 +5,7 @@ const ACCESS_OPTIONS = [ { value: 'allowed', label: 'Allowed' }, { value: 'requestable', label: 'Can request' }, { value: 'unavailable', label: 'Unavailable' }, -] +] as const interface AccessStateSelectProps { label: string @@ -15,6 +15,7 @@ interface AccessStateSelectProps { canRequest?: boolean } +/** Selects a group policy state, retaining unsupported inherited values while disabled. */ export function AccessStateSelect({ label, value, @@ -47,6 +48,7 @@ interface BulkAccessSelectProps { 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 ( PermissionGroupAccessState isInherited: (target: AccessRequestTarget) => boolean @@ -75,6 +76,7 @@ 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 ( @@ -118,6 +120,7 @@ interface PlatformPermissionRowProps extends PermissionEditorControls { children?: ReactNode } +/** Edits a platform feature with optional child controls; inert organization-only rows stay disabled. */ export function PlatformPermissionRow({ feature, inert, @@ -182,6 +185,7 @@ interface PermissionGridProps extends PermissionEditorControls { emptyLabel: string } +/** Searches items of one target kind and limits bulk edits to the visible, editable rows. */ export function PermissionGrid({ items, kind, @@ -279,6 +283,7 @@ interface ProviderRowProps extends PermissionEditorControls { 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 [expanded, setExpanded] = useState(false) const definition = PROVIDER_DEFINITIONS[providerId] @@ -341,6 +346,7 @@ 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 [expanded, setExpanded] = useState(false) const childControls = inheritedControls({ kind: 'integration', id: block.type }, controls) diff --git a/apps/sim/ee/access-requests/lib/targets.ts b/apps/sim/ee/access-requests/lib/targets.ts index 507cf021e94..be0cf1174ff 100644 --- a/apps/sim/ee/access-requests/lib/targets.ts +++ b/apps/sim/ee/access-requests/lib/targets.ts @@ -308,6 +308,7 @@ function integrationDenied(config: PermissionGroupConfig, integrationId: string) ) } +/** 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. */ From db76a84ebfd6e3eb7fae28a0f04b02a65a582652 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 16:33:46 -0700 Subject: [PATCH 4/5] improvement(access-control): align permission rows and contextual help --- .../platform/enterprise/access-control.mdx | 2 + .../settings-resource-row.tsx | 7 +- .../components/group-detail.tsx | 23 +- .../components/permission-rows.tsx | 325 +++++++++--------- packages/emcn/src/components/info/info.tsx | 110 ++++-- 5 files changed, 277 insertions(+), 190 deletions(-) diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index 8d5371b8c26..f3a32bbf21c 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -34,6 +34,8 @@ Search and the state filter narrow each list. **Set shown to…** changes only t A restricted parent also restricts its children. Expand a provider, integration, or platform feature to inspect its models, tools, connectors, or authentication modes. Allow the parent before editing its children. Child choices are preserved when a parent changes. An unavailable child stays unavailable even if its parent allows requests. Requests cannot bypass an unavailable parent. +The info icon beside an item explains its restriction and, for inherited choices, names the parent to change. Hover or focus the icon to read the help, or click or tap to keep it open. Press Escape or click outside to dismiss it. + These choices configure the group's policy, independently of the deployment's current availability. **Allowed** and **Can request** cannot enable a deployment-disabled feature or bypass a workspace role. Members only discover and request items available to their deployment and role. Custom blocks, private dynamically discovered models, and other items outside the public request catalog do not offer **Can request**. ### General 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/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 5eced977dbc..13e3572dfe1 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -80,6 +80,7 @@ import { withPermissionGroupAccessState } from '@/ee/access-control/utils/access import { type AccessRequestTarget, createAccessRequestCatalog, + describeAccessRequestTarget, getAccessRequestTargetParents, getPermissionGroupAccessState, type PermissionGroupAccessState, @@ -523,10 +524,19 @@ export function GroupDetail({ [editingConfig, editorCatalog, canRequest] ) - const isInherited = (target: AccessRequestTarget) => - getAccessRequestTargetParents(target, editorCatalog).some( + 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) => { @@ -544,7 +554,7 @@ export function GroupDetail({ }) } - const controls = { getState, isInherited, canRequest, onSet: setAccess } + const controls = { getState, isInherited, getInheritedFrom, canRequest, onSet: setAccess } const searchedPlatformFeatures = useMemo(() => { const search = searchTerm.trim().toLowerCase() @@ -1152,7 +1162,7 @@ export function GroupDetail({ + Choose access for an integration, then expand it to set individual tools. Parent restrictions apply to every tool. @@ -1205,7 +1215,10 @@ export function GroupDetail({ )} {platformCategorySections.map(({ category, features }) => ( - + {category}} + >
{features.map((feature) => ( 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) @@ -63,13 +66,22 @@ function inheritedControls( : state }, isInherited: (target) => parentState !== 'allowed' || controls.isInherited(target), + getInheritedFrom: (target) => + parentState !== 'allowed' + ? (controls.getInheritedFrom(parent) ?? parentLabel) + : controls.getInheritedFrom(target), } } -interface PermissionRowSelectProps extends PermissionEditorControls { +interface PermissionRowProps extends PermissionEditorControls { target: AccessRequestTarget label: string + description?: string + icon?: ReactNode + badge?: ReactNode disabled?: boolean + nested?: boolean + children?: ReactNode } interface PermissionBulkSelectProps extends PermissionEditorControls { @@ -88,28 +100,92 @@ export function PermissionBulkSelect({ targets, ...controls }: PermissionBulkSel ) } -function PermissionRowSelect({ +function PermissionRow({ target, label, + description, + icon, + badge, disabled, - getState, - isInherited, - canRequest, - onSet, -}: PermissionRowSelectProps) { - const inherited = isInherited(target) - return ( -
- {inherited && ( - Inherited from a restricted parent. Allow the parent to edit this choice. - )} - + onSet([target], state)} + className='text-sm' + focusTarget={children ? 'nearest-interactive' : undefined} /> + {badge} + {children && ( + + )} + + ) + return ( +
+ + {children ? ( + + ) : ( + + {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} +
+ )}
) } @@ -127,53 +203,17 @@ export function PlatformPermissionRow({ children, ...controls }: PlatformPermissionRowProps) { - const [expanded, setExpanded] = useState(false) - const detailsId = useId() - const label = ( - <> - {feature.label} - {inert && Organization} - - ) - const labelClass = cn('flex min-w-0 flex-1 items-center gap-2 py-1 pl-2', inert && 'opacity-60') return ( -
-
- {children ? ( - - ) : ( -
{label}
- )} - - - {inert ? `${feature.hint} ${ORGANIZATION_SCOPED_FEATURE_NOTE}` : feature.hint} - -
- {children && expanded && ( -
- {children} -
- )} -
+ Organization : undefined} + disabled={inert} + {...controls} + > + {children} + ) } @@ -225,18 +265,20 @@ export function PermissionGrid({ )}
{filtered.map((item) => ( -
- - -
+ target={targetFor(item.id)} + label={item.label} + nested + {...controls} + /> ))}
@@ -285,60 +327,38 @@ interface ProviderRowProps extends PermissionEditorControls { /** Edits a provider and its inherited model choices, using workspace-scoped dynamic models when needed. */ export function ProviderRow({ providerId, workspaceId, ...controls }: ProviderRowProps) { - const [expanded, setExpanded] = useState(false) 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 }, controls) + const childControls = inheritedControls({ kind: 'provider', id: providerId }, name, controls) const items = useMemo( () => getProviderModels(providerId).map((id) => ({ id, label: id })), [providerId] ) return ( -
-
- {ProviderIcon && } - - : undefined} + {...controls} + > + {dynamic ? ( + + ) : ( + -
- {expanded && ( -
- {dynamic ? ( - - ) : ( - - )} -
)} -
+ ) } @@ -348,62 +368,45 @@ interface BlockToolRowProps extends PermissionEditorControls { /** Edits a block and its tool operations, preserving child choices while a parent restricts access. */ export function BlockToolRow({ block, ...controls }: BlockToolRowProps) { - const [expanded, setExpanded] = useState(false) - const childControls = inheritedControls({ kind: 'integration', id: block.type }, controls) + 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 && } -
- - {block.description && {block.description}} - + } + badge={ + block.sourceWorkspaceName ? ( + {block.sourceWorkspaceName} + ) : undefined + } + {...controls} + > + {items.length ? ( + -
- {expanded && !!items.length && ( -
- -
- )} -
+ ) : null} + ) } diff --git a/packages/emcn/src/components/info/info.tsx b/packages/emcn/src/components/info/info.tsx index 71b8ad16309..0cf9df9b41c 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,6 +18,10 @@ 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'`. */ @@ -26,47 +31,106 @@ interface InfoProps { } /** - * 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 ( -

{children}

+ {children}
) } + +function InteractiveInfo({ children, className, side, align, label }: InfoProps) { + const contentId = useId() + const [open, setOpen] = useState(false) + const { state, handlers } = useFloatingTooltip(() => !open, { + preferAbove: side === 'top', + revalidateKey: open, + }) + return ( + + + + + {!open && ( + + {children} + + )} + + {children} + + + ) +} + +const INFO_ICON = ( + +) From 9c9bca13437593b9fa50ec2c43e507f555e74b68 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 16:57:12 -0700 Subject: [PATCH 5/5] fix(access-control): dismiss floating permission help with Escape --- packages/emcn/src/components/info/info.tsx | 17 +++++++---- .../emcn/src/components/tooltip/tooltip.tsx | 29 +++++++++++++++++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/emcn/src/components/info/info.tsx b/packages/emcn/src/components/info/info.tsx index 0cf9df9b41c..e534ef1c621 100644 --- a/packages/emcn/src/components/info/info.tsx +++ b/packages/emcn/src/components/info/info.tsx @@ -24,9 +24,9 @@ interface InfoProps { 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 } @@ -69,7 +69,7 @@ export function Info({ - {children} +

{children}

) @@ -78,12 +78,19 @@ export function Info({ function InteractiveInfo({ children, className, side, align, label }: InfoProps) { const contentId = useId() const [open, setOpen] = useState(false) - const { state, handlers } = useFloatingTooltip(() => !open, { + const { state, handlers, dismiss } = useFloatingTooltip(() => !open, { preferAbove: side === 'top', revalidateKey: open, + dismissOnEscape: true, }) return ( - + { + if (!nextOpen) dismiss() + setOpen(nextOpen) + }} + >