Skip to content

Commit 7ba0e23

Browse files
authored
feat(access-control): page-based permission groups, tool-level deny-list, settings row-action consistency (#5216)
* feat(access-control): page-based permission groups, tool-level deny-list, settings row-action consistency - Replace the cramped configure modal with a full-surface tabbed Access Control page (General/Model Providers/Blocks/Platform) with a sticky save bar - Add deniedTools denylist to permission groups: deny individual tools within an allowed integration; enforced at the universal executeTool chokepoint via ToolNotAllowedError, and hidden from the operation dropdown for governed users - Add per-section Select/Deselect All on the Blocks tab and expandable per-tool deny rows (mirrors Providers->Models) - Standardize every settings list row on the canonical "..." DropdownMenu (custom-tools, mcp, workflow-mcp-servers, api-keys, secrets, credential-sets) and align badges (ChipTag), avatars (MemberAvatar), inputs (ChipInput), and the mothership env picker (ChipSelect) * fix(access-control): don't leave the detail view when an unsaved-changes save fails The unsaved-changes dialog's Save action navigated back unconditionally after handleSaveConfig, but that helper swallows mutation errors — so a failed save still exited the view and silently dropped the edits. handleSaveConfig now returns success, and the dialog only closes + navigates back when the save actually succeeded. * fix(access-control): prune deniedTools for blocks that get disabled deniedTools only matters while a block is allowed, but toggleIntegration/setBlocksAllowed left a disabled block's denied tools in the config. Disabling then re-enabling an integration would silently re-apply the old per-tool denials. Both handlers now prune deniedTools to the set of allowed blocks, keeping the invariant that deniedTools only holds tools of currently-allowed integrations. * fix(access-control): attribute denied tools to all exposing blocks when pruning A tool id can appear in more than one block's tools.access. The single tool->block map meant pruneDeniedTools (and the per-block denied count) attributed a shared tool to only one block, so disabling that block could drop a denial while the tool was still exposed by another allowed block. Tools now map to all exposing block types; a denial is pruned only when no allowed block exposes the tool, and the per-block count is derived from each block's own tool list. * fix(access-control): scope Platform Select/Deselect All to the search filter The Platform tab's bulk Select/Deselect All toggled every feature regardless of the active search, unlike the Blocks tab which scopes its per-section toggle to the filtered view. Both the all-visible check and the bulk update now operate on filteredPlatformFeatures for consistent behavior while searching. * fix(access-control): scope Model Providers Select/Deselect All to the search filter Like the Platform fix, the Providers tab's bulk action toggled every provider via allProviderIds regardless of the active search. Added setProvidersAllowed (mirroring setBlocksAllowed) so the bulk toggle and its label operate on filteredProviders, keeping all three tabs (Blocks/Platform/Providers) consistent while searching. * fix(access-control): don't seed a denied operation as a block's default The operation dropdown hides denied tools from the picker, but defaultOptionValue returned the block's defaultValue without checking deniedOperationIds, so a new block could start on an operation the user isn't allowed to run. It now falls back to the first allowed option when the configured default is denied. Existing stored operation values are intentionally left untouched (auto-rewriting a user's saved block would be destructive; the server remains the authoritative gate). * chore(access-control): prefer TSDoc over inline comments Convert declaration-level rationale comments to TSDoc (/** */) and trim redundant/verbose inline comments added during review, per the project's TSDoc convention.
1 parent 6355c8e commit 7ba0e23

19 files changed

Lines changed: 2412 additions & 1852 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ export function Admin() {
196196
)}
197197
</div>
198198

199-
<div className='h-px bg-[var(--border-secondary)]' />
199+
<div className='h-px bg-[var(--border)]' />
200200

201201
<div className='flex flex-col gap-2'>
202202
<p className='text-[var(--text-secondary)] text-sm'>
@@ -231,10 +231,10 @@ export function Admin() {
231231
)}
232232
</div>
233233

234-
<div className='h-px bg-[var(--border-secondary)]' />
234+
<div className='h-px bg-[var(--border)]' />
235235

236236
<div className='flex flex-col gap-3'>
237-
<p className='font-medium text-[var(--text-primary)] text-sm'>User Management</p>
237+
<p className='font-medium text-[var(--text-muted)] text-small'>User Management</p>
238238
<div className='flex gap-2'>
239239
<ChipInput
240240
icon={Search}

apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,21 @@ import { createLogger } from '@sim/logger'
55
import { formatDate } from '@sim/utils/formatting'
66
import { Info, Plus } from 'lucide-react'
77
import { useParams } from 'next/navigation'
8-
import { Chip, ChipConfirmModal, ChipInput, Search, Switch, Tooltip } from '@/components/emcn'
8+
import {
9+
Chip,
10+
ChipConfirmModal,
11+
ChipInput,
12+
chipVariants,
13+
DropdownMenu,
14+
DropdownMenuContent,
15+
DropdownMenuItem,
16+
DropdownMenuTrigger,
17+
MoreHorizontal,
18+
Search,
19+
Switch,
20+
Tooltip,
21+
toast,
22+
} from '@/components/emcn'
923
import { useSession } from '@/lib/auth/auth-client'
1024
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1125
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
@@ -20,6 +34,51 @@ import { CreateApiKeyModal } from './components'
2034

2135
const logger = createLogger('ApiKeys')
2236

37+
/** Copies an API key's name and confirms with a toast. */
38+
function copyKeyName(name: string) {
39+
void navigator.clipboard.writeText(name)
40+
toast.success('Copied name to clipboard')
41+
}
42+
43+
interface ApiKeyRowMenuProps {
44+
keyName: string
45+
onDelete: () => void
46+
/** When false, the Delete item is disabled (e.g. non-admins on workspace keys). */
47+
canDelete?: boolean
48+
}
49+
50+
/**
51+
* Trailing `...` actions menu for an API key row. Mirrors the Secrets /
52+
* Teammates row menu so the settings experience is consistent.
53+
*/
54+
function ApiKeyRowMenu({ keyName, onDelete, canDelete = true }: ApiKeyRowMenuProps) {
55+
return (
56+
<div className='flex-shrink-0'>
57+
<DropdownMenu>
58+
<DropdownMenuTrigger asChild>
59+
<button
60+
type='button'
61+
aria-label='API key actions'
62+
className={chipVariants({ flush: true })}
63+
>
64+
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
65+
</button>
66+
</DropdownMenuTrigger>
67+
<DropdownMenuContent align='end'>
68+
<DropdownMenuItem onSelect={() => copyKeyName(keyName)}>Copy name</DropdownMenuItem>
69+
<DropdownMenuItem
70+
className='text-[var(--text-error)]'
71+
onSelect={onDelete}
72+
disabled={!canDelete}
73+
>
74+
Delete
75+
</DropdownMenuItem>
76+
</DropdownMenuContent>
77+
</DropdownMenu>
78+
</div>
79+
)
80+
}
81+
2382
export function ApiKeys() {
2483
const { data: session } = useSession()
2584
const userId = session?.user?.id
@@ -164,16 +223,14 @@ export function ApiKeys() {
164223
{key.displayKey || key.key}
165224
</p>
166225
</div>
167-
<Chip
168-
className='flex-shrink-0'
169-
onClick={() => {
226+
<ApiKeyRowMenu
227+
keyName={key.name}
228+
onDelete={() => {
170229
setDeleteKey(key)
171230
setShowDeleteDialog(true)
172231
}}
173-
disabled={!canManageWorkspaceKeys}
174-
>
175-
Delete
176-
</Chip>
232+
canDelete={canManageWorkspaceKeys}
233+
/>
177234
</div>
178235
))}
179236
</div>
@@ -197,16 +254,14 @@ export function ApiKeys() {
197254
{key.displayKey || key.key}
198255
</p>
199256
</div>
200-
<Chip
201-
className='flex-shrink-0'
202-
onClick={() => {
257+
<ApiKeyRowMenu
258+
keyName={key.name}
259+
onDelete={() => {
203260
setDeleteKey(key)
204261
setShowDeleteDialog(true)
205262
}}
206-
disabled={!canManageWorkspaceKeys}
207-
>
208-
Delete
209-
</Chip>
263+
canDelete={canManageWorkspaceKeys}
264+
/>
210265
</div>
211266
))}
212267
</div>
@@ -235,15 +290,13 @@ export function ApiKeys() {
235290
{key.displayKey || key.key}
236291
</p>
237292
</div>
238-
<Chip
239-
className='flex-shrink-0'
240-
onClick={() => {
293+
<ApiKeyRowMenu
294+
keyName={key.name}
295+
onDelete={() => {
241296
setDeleteKey(key)
242297
setShowDeleteDialog(true)
243298
}}
244-
>
245-
Delete
246-
</Chip>
299+
/>
247300
</div>
248301
{isConflict && (
249302
<div className='text-[var(--text-error)] text-small leading-tight'>

apps/sim/app/workspace/[workspaceId]/settings/components/credential-sets/credential-sets.tsx

Lines changed: 85 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@ import {
2020
ChipModalField,
2121
ChipModalFooter,
2222
ChipModalHeader,
23+
chipVariants,
24+
DropdownMenu,
25+
DropdownMenuContent,
26+
DropdownMenuItem,
27+
DropdownMenuTrigger,
2328
type FileInputOptions,
29+
MoreHorizontal,
2430
Search,
2531
TagInput,
2632
type TagItem,
@@ -516,13 +522,26 @@ export function CredentialSets() {
516522
</div>
517523

518524
<div className='ml-4 flex items-center gap-1'>
519-
<Chip
520-
variant='destructive'
521-
onClick={() => handleRemoveMember(member.id)}
522-
disabled={removeMember.isPending}
523-
>
524-
Remove
525-
</Chip>
525+
<DropdownMenu>
526+
<DropdownMenuTrigger asChild>
527+
<button
528+
type='button'
529+
aria-label='Member actions'
530+
className={chipVariants({ flush: true })}
531+
>
532+
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
533+
</button>
534+
</DropdownMenuTrigger>
535+
<DropdownMenuContent align='end'>
536+
<DropdownMenuItem
537+
className='text-[var(--text-error)]'
538+
onSelect={() => handleRemoveMember(member.id)}
539+
disabled={removeMember.isPending}
540+
>
541+
Remove
542+
</DropdownMenuItem>
543+
</DropdownMenuContent>
544+
</DropdownMenu>
526545
</div>
527546
</div>
528547
)
@@ -561,25 +580,41 @@ export function CredentialSets() {
561580
</div>
562581

563582
<div className='ml-4 flex items-center gap-1'>
564-
<Chip
565-
onClick={() => handleResendInvitation(invitation.id, email)}
566-
disabled={
567-
resendingInvitations.has(invitation.id) ||
568-
(resendCooldowns[invitation.id] ?? 0) > 0
569-
}
570-
>
571-
{resendingInvitations.has(invitation.id)
572-
? 'Sending...'
573-
: resendCooldowns[invitation.id]
574-
? `Resend (${resendCooldowns[invitation.id]}s)`
575-
: 'Resend'}
576-
</Chip>
577-
<Chip
578-
onClick={() => handleCancelInvitation(invitation.id)}
579-
disabled={cancellingInvitations.has(invitation.id)}
580-
>
581-
{cancellingInvitations.has(invitation.id) ? 'Cancelling...' : 'Cancel'}
582-
</Chip>
583+
<DropdownMenu>
584+
<DropdownMenuTrigger asChild>
585+
<button
586+
type='button'
587+
aria-label='Invitation actions'
588+
className={chipVariants({ flush: true })}
589+
>
590+
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
591+
</button>
592+
</DropdownMenuTrigger>
593+
<DropdownMenuContent align='end'>
594+
<DropdownMenuItem
595+
onSelect={() => handleResendInvitation(invitation.id, email)}
596+
disabled={
597+
resendingInvitations.has(invitation.id) ||
598+
(resendCooldowns[invitation.id] ?? 0) > 0
599+
}
600+
>
601+
{resendingInvitations.has(invitation.id)
602+
? 'Sending...'
603+
: resendCooldowns[invitation.id]
604+
? `Resend (${resendCooldowns[invitation.id]}s)`
605+
: 'Resend'}
606+
</DropdownMenuItem>
607+
<DropdownMenuItem
608+
className='text-[var(--text-error)]'
609+
onSelect={() => handleCancelInvitation(invitation.id)}
610+
disabled={cancellingInvitations.has(invitation.id)}
611+
>
612+
{cancellingInvitations.has(invitation.id)
613+
? 'Cancelling...'
614+
: 'Cancel'}
615+
</DropdownMenuItem>
616+
</DropdownMenuContent>
617+
</DropdownMenu>
583618
</div>
584619
</div>
585620
)
@@ -729,14 +764,30 @@ export function CredentialSets() {
729764
</span>
730765
</div>
731766
</div>
732-
<div className='flex items-center gap-2'>
733-
<Chip onClick={() => setViewingSet(set)}>Details</Chip>
734-
<Chip
735-
onClick={() => handleDeleteClick(set)}
736-
disabled={deletingSetIds.has(set.id)}
737-
>
738-
{deletingSetIds.has(set.id) ? 'Deleting...' : 'Delete'}
739-
</Chip>
767+
<div className='flex items-center gap-1'>
768+
<DropdownMenu>
769+
<DropdownMenuTrigger asChild>
770+
<button
771+
type='button'
772+
aria-label='Group actions'
773+
className={chipVariants({ flush: true })}
774+
>
775+
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
776+
</button>
777+
</DropdownMenuTrigger>
778+
<DropdownMenuContent align='end'>
779+
<DropdownMenuItem onSelect={() => setViewingSet(set)}>
780+
Details
781+
</DropdownMenuItem>
782+
<DropdownMenuItem
783+
className='text-[var(--text-error)]'
784+
onSelect={() => handleDeleteClick(set)}
785+
disabled={deletingSetIds.has(set.id)}
786+
>
787+
Delete
788+
</DropdownMenuItem>
789+
</DropdownMenuContent>
790+
</DropdownMenu>
740791
</div>
741792
</div>
742793
))}

apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,18 @@ import { createLogger } from '@sim/logger'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import { Plus } from 'lucide-react'
77
import { useParams } from 'next/navigation'
8-
import { Chip, ChipConfirmModal, ChipInput, Search } from '@/components/emcn'
8+
import {
9+
Chip,
10+
ChipConfirmModal,
11+
ChipInput,
12+
chipVariants,
13+
DropdownMenu,
14+
DropdownMenuContent,
15+
DropdownMenuItem,
16+
DropdownMenuTrigger,
17+
MoreHorizontal,
18+
Search,
19+
} from '@/components/emcn'
920
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
1021
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
1122

@@ -134,14 +145,30 @@ export function CustomTools() {
134145
</p>
135146
)}
136147
</div>
137-
<div className='flex flex-shrink-0 items-center gap-2'>
138-
<Chip onClick={() => setEditingTool(tool.id)}>Edit</Chip>
139-
<Chip
140-
onClick={() => handleDeleteClick(tool.id)}
141-
disabled={deletingTools.has(tool.id)}
142-
>
143-
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
144-
</Chip>
148+
<div className='flex flex-shrink-0 items-center gap-1'>
149+
<DropdownMenu>
150+
<DropdownMenuTrigger asChild>
151+
<button
152+
type='button'
153+
aria-label='Tool actions'
154+
className={chipVariants({ flush: true })}
155+
>
156+
<MoreHorizontal className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
157+
</button>
158+
</DropdownMenuTrigger>
159+
<DropdownMenuContent align='end'>
160+
<DropdownMenuItem onSelect={() => setEditingTool(tool.id)}>
161+
Edit
162+
</DropdownMenuItem>
163+
<DropdownMenuItem
164+
className='text-[var(--text-error)]'
165+
onSelect={() => handleDeleteClick(tool.id)}
166+
disabled={deletingTools.has(tool.id)}
167+
>
168+
Delete
169+
</DropdownMenuItem>
170+
</DropdownMenuContent>
171+
</DropdownMenu>
145172
</div>
146173
</div>
147174
))}

0 commit comments

Comments
 (0)