diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index 47a258f5f9a..1b70ad89a26 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 { useCallback, useMemo, useRef, useState } from 'react' +import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from 'react' import { Checkbox, Chip, @@ -37,6 +37,7 @@ import { } from '@/app/workspace/[workspaceId]/settings/components/member-list' import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/components/save-discard-actions/save-discard-actions' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' @@ -53,6 +54,7 @@ import { useRemovePermissionGroupMember, useUpdatePermissionGroup, } from '@/ee/access-control/hooks/permission-groups' +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' @@ -90,6 +92,227 @@ const ALL_CHAT_DEPLOY_AUTH_TYPES: ShareAuthType[] = CHAT_DEPLOY_AUTH_TYPE_OPTION (o) => o.value ) +type StatusFilter = 'all' | 'enabled' | 'disabled' + +const STATUS_FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ + { value: 'all', label: 'Show all' }, + { value: 'enabled', label: 'Show enabled' }, + { value: 'disabled', label: 'Show disabled' }, +] + +function matchesStatusFilter(filter: StatusFilter, enabled: boolean) { + return filter === 'all' || (filter === 'enabled') === enabled +} + +interface StatusFilterChipProps { + value: StatusFilter + onChange: (value: StatusFilter) => void + /** Set when the chip is the last control in its row, so it sits flush to the edge. */ + flush?: boolean +} + +/** The All/Enabled/Disabled narrowing control shared by the three list tabs. */ +function StatusFilterChip({ value, onChange, flush }: StatusFilterChipProps) { + return ( + onChange(next as StatusFilter)} + options={STATUS_FILTER_OPTIONS} + matchTriggerWidth={false} + flush={flush} + className='w-[140px] flex-shrink-0' + /> + ) +} + +interface AuthModeFieldProps { + label: string + value: ShareAuthType[] + onChange: (values: string[]) => void + options: { value: ShareAuthType; label: string }[] + disabled: boolean +} + +/** + * The allowed-auth-modes 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. + * The dropdown is `flush` so its own `mx-0.5` doesn't push it 2px past the + * caption above it. + */ +function AuthModeField({ label, value, onChange, options, disabled }: AuthModeFieldProps) { + const labelId = useId() + const triggerId = useId() + return ( +
+ + {label} + + +
+ ) +} + +/** Render order for the platform-feature category sections; unlisted ones follow. */ +const PLATFORM_CATEGORY_ORDER = [ + 'Sidebar', + 'Deploy Tabs', + 'Chat', + 'Collaboration', + 'Workflow Panel', + 'Tools', + 'Features', + 'Settings Tabs', + 'Logs', + 'Files', +] + +const PLATFORM_FEATURES = [ + { + id: 'hide-knowledge-base', + label: 'Knowledge Base', + category: 'Sidebar', + configKey: 'hideKnowledgeBaseTab' as const, + hint: 'Hide the Knowledge Base module from the sidebar.', + }, + { + id: 'hide-tables', + label: 'Tables', + category: 'Sidebar', + configKey: 'hideTablesTab' as const, + hint: 'Hide the Tables module from the sidebar.', + }, + { + id: 'hide-copilot', + label: 'Chat', + category: 'Workflow Panel', + configKey: 'hideCopilot' as const, + hint: 'Hide the Chat panel so users cannot build or edit with natural language.', + }, + { + id: 'hide-integrations', + label: 'Integrations', + category: 'Settings Tabs', + configKey: 'hideIntegrationsTab' as const, + hint: 'Hide the Integrations settings tab (OAuth connections).', + }, + { + id: 'hide-secrets', + label: 'Secrets', + category: 'Settings Tabs', + configKey: 'hideSecretsTab' as const, + hint: 'Hide the Secrets (environment variables) settings tab.', + }, + { + id: 'hide-api-keys', + label: 'API Keys', + category: 'Settings Tabs', + configKey: 'hideApiKeysTab' as const, + hint: 'Hide the API Keys settings tab.', + }, + { + id: 'hide-files', + label: 'Files', + category: 'Settings Tabs', + configKey: 'hideFilesTab' as const, + hint: 'Hide the Files settings tab.', + }, + { + id: 'hide-deploy-api', + label: 'API', + category: 'Deploy Tabs', + configKey: 'hideDeployApi' as const, + hint: 'Hide the API deployment option.', + }, + { + id: 'hide-deploy-mcp', + label: 'MCP', + category: 'Deploy Tabs', + configKey: 'hideDeployMcp' as const, + hint: 'Hide the MCP server deployment option.', + }, + { + id: 'disable-mcp', + label: 'MCP Tools', + category: 'Tools', + configKey: 'disableMcpTools' as const, + hint: 'Block agents from calling MCP tools.', + }, + { + id: 'disable-custom-tools', + label: 'Custom Tools', + category: 'Tools', + configKey: 'disableCustomTools' as const, + hint: 'Block agents from calling user-defined custom tools.', + }, + { + id: 'disable-skills', + label: 'Skills', + category: 'Tools', + configKey: 'disableSkills' as const, + hint: 'Block agents from loading skills.', + }, + { + id: 'hide-trace-spans', + label: 'Trace Spans', + category: 'Logs', + configKey: 'hideTraceSpans' as const, + hint: 'Hide per-block trace spans in logs.', + }, + { + id: 'disable-invitations', + label: 'Invitations', + category: 'Collaboration', + configKey: 'disableInvitations' as const, + hint: 'Prevent users from inviting others to workspaces.', + }, + { + id: 'hide-inbox', + label: 'Sim Mailer', + category: 'Features', + configKey: 'hideInboxTab' as const, + hint: 'Hide the Sim Mailer inbox.', + }, + { + id: 'disable-public-api', + label: 'Public API', + category: 'Features', + configKey: 'disablePublicApi' as const, + hint: 'Disable public API access to deployed workflows.', + }, + // Chat and Files get a category of their own so their nested auth-mode + // dropdown (see `featureExtras`) reads as part of the toggle it qualifies. + { + id: 'hide-deploy-chatbot', + label: 'Deployment', + category: 'Chat', + configKey: 'hideDeployChatbot' as const, + hint: 'Hide the chat deployment option.', + }, + { + id: 'disable-public-file-sharing', + label: 'Public Sharing', + category: 'Files', + configKey: 'disablePublicFileSharing' as const, + hint: 'Disable public file-share links.', + }, +] + interface OrganizationMemberOption { userId: string user: { @@ -521,7 +744,7 @@ function BlockToolRow({ onClick={() => isBlockAllowed && isExpandable && setExpanded((prev) => !prev)} disabled={!isBlockAllowed || !isExpandable} className={cn( - 'flex flex-1 items-center gap-2 text-left', + 'flex min-w-0 flex-1 items-center gap-2 text-left', isBlockAllowed && isExpandable ? 'cursor-pointer' : 'cursor-default', !isBlockAllowed && 'opacity-60' )} @@ -541,6 +764,12 @@ function BlockToolRow({ /> )} + {/* Outside the button: an Info trigger is itself a button and cannot nest. */} + {block.description && ( + + {block.description} + + )} {expanded && isBlockAllowed && isExpandable && (
@@ -595,11 +824,15 @@ export function GroupDetail({ */ const [viewingGroup, setViewingGroup] = useState(group) const [editingConfig, setEditingConfig] = useState({ ...group.config }) + const [editingName, setEditingName] = useState(group.name.trim()) + const [editingDescription, setEditingDescription] = useState((group.description ?? '').trim()) const prevGroupIdRef = useRef(group.id) if (prevGroupIdRef.current !== group.id) { prevGroupIdRef.current = group.id setViewingGroup(group) setEditingConfig({ ...group.config }) + setEditingName(group.name.trim()) + setEditingDescription((group.description ?? '').trim()) } /** @@ -613,6 +846,9 @@ export function GroupDetail({ const [providerSearchTerm, setProviderSearchTerm] = useState('') const [integrationSearchTerm, setIntegrationSearchTerm] = useState('') const [platformSearchTerm, setPlatformSearchTerm] = useState('') + const [providerStatusFilter, setProviderStatusFilter] = useState('all') + const [blockStatusFilter, setBlockStatusFilter] = useState('all') + const [platformStatusFilter, setPlatformStatusFilter] = useState('all') const [showAddMembersModal, setShowAddMembersModal] = useState(false) const [addMembersError, setAddMembersError] = useState(null) @@ -676,141 +912,24 @@ export function GroupDetail({ return map }, [allBlocks]) - const platformFeatures = useMemo( - () => [ - { - id: 'hide-knowledge-base', - label: 'Knowledge Base', - category: 'Sidebar', - configKey: 'hideKnowledgeBaseTab' as const, - hint: 'Hide the Knowledge Base module from the sidebar.', - }, - { - id: 'hide-tables', - label: 'Tables', - category: 'Sidebar', - configKey: 'hideTablesTab' as const, - hint: 'Hide the Tables module from the sidebar.', - }, - { - id: 'hide-copilot', - label: 'Chat', - category: 'Workflow Panel', - configKey: 'hideCopilot' as const, - hint: 'Hide the Chat panel so users cannot build or edit with natural language.', - }, - { - id: 'hide-integrations', - label: 'Integrations', - category: 'Settings Tabs', - configKey: 'hideIntegrationsTab' as const, - hint: 'Hide the Integrations settings tab (OAuth connections).', - }, - { - id: 'hide-secrets', - label: 'Secrets', - category: 'Settings Tabs', - configKey: 'hideSecretsTab' as const, - hint: 'Hide the Secrets (environment variables) settings tab.', - }, - { - id: 'hide-api-keys', - label: 'API Keys', - category: 'Settings Tabs', - configKey: 'hideApiKeysTab' as const, - hint: 'Hide the API Keys settings tab.', - }, - { - id: 'hide-files', - label: 'Files', - category: 'Settings Tabs', - configKey: 'hideFilesTab' as const, - hint: 'Hide the Files settings tab.', - }, - { - id: 'hide-deploy-api', - label: 'API', - category: 'Deploy Tabs', - configKey: 'hideDeployApi' as const, - hint: 'Hide the API deployment option.', - }, - { - id: 'hide-deploy-mcp', - label: 'MCP', - category: 'Deploy Tabs', - configKey: 'hideDeployMcp' as const, - hint: 'Hide the MCP server deployment option.', - }, - { - id: 'hide-deploy-chatbot', - label: 'Chat', - category: 'Deploy Tabs', - configKey: 'hideDeployChatbot' as const, - hint: 'Hide the chatbot deployment option.', - }, - { - id: 'disable-mcp', - label: 'MCP Tools', - category: 'Tools', - configKey: 'disableMcpTools' as const, - hint: 'Block agents from calling MCP tools.', - }, - { - id: 'disable-custom-tools', - label: 'Custom Tools', - category: 'Tools', - configKey: 'disableCustomTools' as const, - hint: 'Block agents from calling user-defined custom tools.', - }, - { - id: 'disable-skills', - label: 'Skills', - category: 'Tools', - configKey: 'disableSkills' as const, - hint: 'Block agents from loading skills.', - }, - { - id: 'hide-trace-spans', - label: 'Trace Spans', - category: 'Logs', - configKey: 'hideTraceSpans' as const, - hint: 'Hide per-block trace spans in logs.', - }, - { - id: 'disable-invitations', - label: 'Invitations', - category: 'Collaboration', - configKey: 'disableInvitations' as const, - hint: 'Prevent users from inviting others to workspaces.', - }, - { - id: 'hide-inbox', - label: 'Sim Mailer', - category: 'Features', - configKey: 'hideInboxTab' as const, - hint: 'Hide the Sim Mailer inbox.', - }, - { - id: 'disable-public-api', - label: 'Public API', - category: 'Features', - configKey: 'disablePublicApi' as const, - hint: 'Disable public API access to deployed workflows.', - }, - ], - [] - ) + const searchedPlatformFeatures = useMemo(() => { + const search = platformSearchTerm.trim().toLowerCase() + if (!search) return PLATFORM_FEATURES + return PLATFORM_FEATURES.filter( + (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + ) + }, [platformSearchTerm]) + /** Split from the search pass for the same reason as the provider and block lists. */ const filteredPlatformFeatures = useMemo(() => { - if (!platformSearchTerm.trim()) return platformFeatures - const search = platformSearchTerm.toLowerCase() - return platformFeatures.filter( - (f) => f.label.toLowerCase().includes(search) || f.category.toLowerCase().includes(search) + if (platformStatusFilter === 'all') return searchedPlatformFeatures + return searchedPlatformFeatures.filter((f) => + matchesStatusFilter(platformStatusFilter, !editingConfig[f.configKey]) ) - }, [platformFeatures, platformSearchTerm]) + }, [searchedPlatformFeatures, platformStatusFilter, editingConfig]) const platformCategories = useMemo(() => { - const categories: Record = {} + const categories: Record = {} for (const feature of filteredPlatformFeatures) { if (!categories[feature.category]) { categories[feature.category] = [] @@ -821,19 +940,9 @@ export function GroupDetail({ }, [filteredPlatformFeatures]) const platformCategorySections = useMemo(() => { - const order = [ - 'Sidebar', - 'Deploy Tabs', - 'Collaboration', - 'Workflow Panel', - 'Tools', - 'Features', - 'Settings Tabs', - 'Logs', - ] - const known = order.filter((c) => platformCategories[c]?.length) + const known = PLATFORM_CATEGORY_ORDER.filter((c) => platformCategories[c]?.length) const extras = Object.keys(platformCategories).filter( - (c) => c !== 'Files' && !order.includes(c) && platformCategories[c]?.length + (c) => !PLATFORM_CATEGORY_ORDER.includes(c) && platformCategories[c]?.length ) return [...known, ...extras].map((category) => ({ category, @@ -844,20 +953,82 @@ export function GroupDetail({ const hasConfigChanges = useMemo(() => { return JSON.stringify(viewingGroup.config) !== JSON.stringify(editingConfig) }, [viewingGroup.config, editingConfig]) - const guard = useSettingsUnsavedGuard({ isDirty: hasConfigChanges }) - const filteredProviders = useMemo(() => { - if (!providerSearchTerm.trim()) return allProviderIds - const query = providerSearchTerm.toLowerCase() + // Both buffers are seeded trimmed and compared against a trimmed baseline. The + // contract trims name and description on write, but a row stored before those + // schemas gained `.trim()` (or written straight to the API) can still carry + // padding — compared raw it would open dirty with no way to clear it, since + // Discard restores the same padded value. + const trimmedName = editingName.trim() + const trimmedDescription = editingDescription.trim() + const nameChanged = trimmedName !== viewingGroup.name.trim() + const descriptionChanged = trimmedDescription !== (viewingGroup.description ?? '').trim() + const hasChanges = hasConfigChanges || nameChanged || descriptionChanged + + const guard = useSettingsUnsavedGuard({ isDirty: hasChanges }) + + /** + * `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( + () => + editingConfig.allowedIntegrations === null + ? null + : new Set(editingConfig.allowedIntegrations), + [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 = providerSearchTerm.trim().toLowerCase() + if (!query) return allProviderIds return allProviderIds.filter((id) => id.toLowerCase().includes(query)) }, [allProviderIds, providerSearchTerm]) - const filteredBlocks = useMemo(() => { - if (!integrationSearchTerm.trim()) return visibleBlocks - const query = integrationSearchTerm.toLowerCase() + /** + * 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 (providerStatusFilter === 'all') return searchedProviders + return searchedProviders.filter((id) => + matchesStatusFilter(providerStatusFilter, isProviderAllowed(id)) + ) + }, [searchedProviders, providerStatusFilter, isProviderAllowed]) + + const searchedBlocks = useMemo(() => { + const query = integrationSearchTerm.trim().toLowerCase() + if (!query) return visibleBlocks return visibleBlocks.filter((b) => b.name.toLowerCase().includes(query)) }, [visibleBlocks, integrationSearchTerm]) + const filteredBlocks = useMemo(() => { + if (blockStatusFilter === 'all') return searchedBlocks + return searchedBlocks.filter((b) => + matchesStatusFilter(blockStatusFilter, isIntegrationAllowed(b.type)) + ) + }, [searchedBlocks, blockStatusFilter, isIntegrationAllowed]) + const filteredCoreBlocks = useMemo( () => filteredBlocks.filter((block) => block.category === 'blocks'), [filteredBlocks] @@ -886,13 +1057,6 @@ export function GroupDetail({ return organizationMembers.filter((m) => !existingMemberUserIds.has(m.userId)) }, [organizationMembers, members]) - const isIntegrationAllowed = useCallback( - (blockType: string) => - editingConfig.allowedIntegrations === null || - editingConfig.allowedIntegrations.includes(blockType), - [editingConfig.allowedIntegrations] - ) - /** * Drops denied tools whose integration is no longer allowed, keeping the * invariant that `deniedTools` only holds tools of currently-allowed blocks. @@ -1003,13 +1167,6 @@ export function GroupDetail({ return counts }, [editingConfig.deniedTools, allBlocks]) - const isProviderAllowed = useCallback( - (providerId: string) => - editingConfig.allowedModelProviders === null || - editingConfig.allowedModelProviders.includes(providerId), - [editingConfig.allowedModelProviders] - ) - const toggleProvider = useCallback( (providerId: string) => { setEditingConfig((prev) => { @@ -1130,7 +1287,7 @@ export function GroupDetail({ 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, use the Hide Chat toggle instead. + // off entirely, uncheck Chat → Deployment instead. if (values.length === 0) return setEditingConfig((prev) => ({ ...prev, @@ -1139,26 +1296,66 @@ export function GroupDetail({ })) }, []) - /** Persists the editing buffer. */ - const handleSaveConfig = useCallback(async () => { + /** + * 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> = { + 'hide-deploy-chatbot': ( + + ), + 'disable-public-file-sharing': ( + + ), + } + + /** Persists the editing buffer — name/description are only sent when they changed. */ + const handleSaveConfig = async () => { + if (!trimmedName) return try { - await updatePermissionGroup.mutateAsync({ + const result = await updatePermissionGroup.mutateAsync({ id: viewingGroup.id, organizationId, - config: editingConfig, + ...(hasConfigChanges && { config: editingConfig }), + ...(nameChanged && { name: trimmedName }), + ...(descriptionChanged && { description: trimmedDescription || null }), }) - setViewingGroup((prev) => ({ ...prev, config: editingConfig })) + // Reconcile from the server's copy, like the scope/default writes do, so a + // server-side normalization can't leave the dirty check comparing against a + // baseline that was never persisted. Editing buffers are left alone so + // in-flight edits survive and correctly re-mark the form dirty. + const saved = result.permissionGroup + setViewingGroup((prev) => ({ + ...prev, + config: saved.config, + name: saved.name, + description: saved.description, + })) } catch (error) { - logger.error('Failed to update config', error) + logger.error('Failed to save permission group', error) toast.error("Couldn't save changes", { description: getErrorMessage(error, 'Please try again in a moment.'), }) } - }, [viewingGroup.id, editingConfig, organizationId, updatePermissionGroup]) + } - const handleDiscardConfig = useCallback(() => { + const handleDiscardConfig = () => { setEditingConfig({ ...viewingGroup.config }) - }, [viewingGroup.config]) + setEditingName(viewingGroup.name.trim()) + setEditingDescription((viewingGroup.description ?? '').trim()) + } const handleBack = useCallback(() => { guard.guardBack(onBack) @@ -1307,10 +1504,11 @@ export function GroupDetail({ description={viewingGroup.description ?? undefined} actions={[ ...saveDiscardActions({ - dirty: hasConfigChanges, + dirty: hasChanges, saving: updatePermissionGroup.isPending, onSave: handleSaveConfig, onDiscard: handleDiscardConfig, + saveDisabled: !trimmedName, }), { text: deletePermissionGroup.isPending ? 'Deleting...' : 'Delete', @@ -1330,6 +1528,31 @@ export function GroupDetail({ {configTab === 'general' && ( <> + +
+ + setEditingName(e.target.value)} + placeholder='e.g., Marketing Team' + maxLength={100} + error={!trimmedName} + /> + {!trimmedName && ( +

Name is required.

+ )} +
+ + setEditingDescription(e.target.value)} + placeholder='e.g., Limited access for marketing users' + maxLength={500} + /> + +
+
+
@@ -1457,27 +1680,36 @@ export function GroupDetail({ onChange={(e) => setProviderSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + setProvidersAllowed(filteredProviders, !filteredProvidersAllAllowed)} + disabled={filteredProviders.length === 0} > {filteredProvidersAllAllowed ? 'Deselect All' : 'Select All'}
-
- {filteredProviders.map((providerId) => ( - toggleProvider(providerId)} - deniedCount={deniedCountByProvider[providerId] ?? 0} - workspaceId={workspaceId} - isAllowed={isModelAllowed} - onToggle={toggleModel} - onSetDenied={setModelsDenied} - /> - ))} -
+ {filteredProviders.length === 0 ? ( + + No providers match your filters. + + ) : ( +
+ {filteredProviders.map((providerId) => ( + toggleProvider(providerId)} + deniedCount={deniedCountByProvider[providerId] ?? 0} + workspaceId={workspaceId} + isAllowed={isModelAllowed} + onToggle={toggleModel} + onSetDenied={setModelsDenied} + /> + ))} +
+ )}
)} @@ -1491,7 +1723,13 @@ export function GroupDetail({ onChange={(e) => setIntegrationSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + + {filteredCoreBlocks.length === 0 && filteredToolBlocks.length === 0 && ( + + No blocks match your filters. + + )} {filteredCoreBlocks.length > 0 && ( - toggleIntegration(block.type)} - /> -
- {BlockIcon && } -
- {block.name} - + toggleIntegration(block.type)} + /> +
+ {BlockIcon && } +
+ {block.name} + + {block.description && ( + + {block.description} + + )} + ) })} @@ -1579,6 +1826,7 @@ export function GroupDetail({ onChange={(e) => setPlatformSearchTerm(e.target.value)} className='min-w-0 flex-1' /> + setEditingConfig((prev) => ({ @@ -1588,101 +1836,49 @@ export function GroupDetail({ ), })) } + flush + disabled={filteredPlatformFeatures.length === 0} > {platformAllVisible ? 'Deselect All' : 'Select All'} + {platformCategorySections.length === 0 && ( + + No features match your filters. + + )} {platformCategorySections.map(({ category, features }) => (
{features.map((feature) => ( -
- - {feature.hint} +
+
+ + + {feature.hint} + +
+ {featureExtras[feature.id]}
))}
))} - -
- - Auth modes chat deployments may use - - -
-
- -
- -
- - Auth modes public file-share links may use - - -
-
-
)} diff --git a/apps/sim/lib/api/contracts/permission-groups.test.ts b/apps/sim/lib/api/contracts/permission-groups.test.ts index 0b3bf3fb10f..e3460557529 100644 --- a/apps/sim/lib/api/contracts/permission-groups.test.ts +++ b/apps/sim/lib/api/contracts/permission-groups.test.ts @@ -4,8 +4,13 @@ import { describe, expect, it } from 'vitest' import { createPermissionGroupBodySchema, + permissionGroupFullConfigSchema, updatePermissionGroupBodySchema, } from '@/lib/api/contracts/permission-groups' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + parsePermissionGroupConfig, +} from '@/lib/permission-groups/types' describe('createPermissionGroupBodySchema', () => { it('accepts a name-only body (scope is resolved and validated server-side)', () => { @@ -80,3 +85,55 @@ describe('updatePermissionGroupBodySchema', () => { expect(result.success).toBe(false) }) }) + +/** + * The access-control group detail view decides whether its config buffer is + * dirty by comparing `JSON.stringify(savedConfig)` against + * `JSON.stringify(editingConfig)`, and reconciles the saved baseline from the + * update response. That only works while every config that reaches the client — + * from the list route and from the update route alike — carries the same key + * order, which holds because both pass through `parsePermissionGroupConfig` and + * then `permissionGroupFullConfigSchema`. If the two ever drift, the detail view + * would report unsaved changes forever after a successful save. + */ +describe('permissionGroupFullConfigSchema key order', () => { + it('matches parsePermissionGroupConfig so a saved config compares equal', () => { + const stored = parsePermissionGroupConfig({ + allowedIntegrations: ['slack'], + hideCopilot: true, + }) + const overWire = permissionGroupFullConfigSchema.parse(structuredClone(stored)) + expect(JSON.stringify(overWire)).toBe(JSON.stringify(stored)) + }) + + it('keeps an edited client buffer comparable to the server echo', () => { + const fromList = permissionGroupFullConfigSchema.parse( + structuredClone(parsePermissionGroupConfig(DEFAULT_PERMISSION_GROUP_CONFIG)) + ) + const edited = { ...fromList, hideDeployChatbot: true, deniedTools: ['slack_canvas'] } + const serverEcho = permissionGroupFullConfigSchema.parse( + structuredClone(parsePermissionGroupConfig(edited)) + ) + expect(JSON.stringify(serverEcho)).toBe(JSON.stringify(edited)) + }) +}) + +describe('permission group description trimming', () => { + it('trims a padded description on create', () => { + const result = createPermissionGroupBodySchema.safeParse({ + name: 'Engineering', + description: ' padded ', + }) + expect(result.success && result.data.description).toBe('padded') + }) + + it('trims a padded description on update', () => { + const result = updatePermissionGroupBodySchema.safeParse({ description: ' padded ' }) + expect(result.success && result.data.description).toBe('padded') + }) + + it('still accepts a null description on update', () => { + const result = updatePermissionGroupBodySchema.safeParse({ description: null }) + expect(result.success && result.data.description).toBeNull() + }) +}) diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index 273c47f874d..e3a531d8b43 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -148,7 +148,7 @@ function refineWorkspaceScope( export const createPermissionGroupBodySchema = z .object({ name: z.string().trim().min(1).max(100), - description: z.string().max(500).optional(), + description: z.string().trim().max(500).optional(), config: permissionGroupConfigSchema.optional(), isDefault: z.boolean().optional(), workspaceIds: workspaceIdsSchema.optional(), @@ -158,7 +158,7 @@ export const createPermissionGroupBodySchema = z export const updatePermissionGroupBodySchema = z .object({ name: z.string().trim().min(1).max(100).optional(), - description: z.string().max(500).nullable().optional(), + description: z.string().trim().max(500).nullable().optional(), config: permissionGroupConfigSchema.optional(), isDefault: z.boolean().optional(), workspaceIds: workspaceIdsSchema.optional(), diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index 244b09b6585..67c4b7a8b02 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -71,6 +71,20 @@ interface ChipDropdownBaseProps extends VariantProps { leftIcon?: ChipIcon /** Forwarded class for the trigger button. */ className?: string + /** + * Accessible name for the trigger. Use when the visible label sits outside + * the component (a field label above it) rather than in the selected value. + */ + 'aria-label'?: string + /** + * Ids of the elements naming the trigger. Because `aria-labelledby` REPLACES + * the name derived from the trigger's contents, include the trigger's own id + * alongside the external label's — `\`${labelId} ${triggerId}\`` — or the + * selected value is dropped from the accessible name. + */ + 'aria-labelledby'?: string + /** Id for the trigger button. Needed to reference it from `aria-labelledby`. */ + id?: string } /** @@ -168,6 +182,9 @@ const ChipDropdown = forwardRef( active, fullWidth, flush, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + id, } = props const isMultiple = props.multiple === true @@ -285,8 +302,11 @@ const ChipDropdown = forwardRef(