From 2975cec8be04e039543e3d266f6a4a42bfe5223d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 10:13:57 -0700 Subject: [PATCH 1/5] fix(settings): let workspace admins reach the Custom blocks settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Custom blocks page lives in the Enterprise nav section, which the sidebar filter hides unless the user is an org admin/owner. But the server authz for managing custom blocks is source-workspace admin (hasWorkspaceAdminAccess), not org admin — so a workspace admin who could manage a block via the API couldn't even reach the page. Add an `allowNonOrgAdmin` nav flag that exempts an item from the org admin/owner requirement (the plan/hosted entitlement still applies; the page enforces its own per-resource authz), and set it on custom-blocks. Other Enterprise items (access control, audit logs, SSO, data retention/drains, whitelabeling) stay org-admin-only. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017ddpeoz81CSqYQ73Dzf7yo --- .../app/workspace/[workspaceId]/settings/navigation.ts | 9 +++++++++ .../components/settings-sidebar/settings-sidebar.tsx | 6 ++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 40481cb1373..37bb0225b0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -68,6 +68,14 @@ export interface NavigationItem { selfHostedOverride?: boolean requiresSuperUser?: boolean requiresAdminRole?: boolean + /** + * Exempt this item from the org admin/owner requirement that `requiresTeam` / + * `requiresEnterprise` otherwise impose in the sidebar. The plan/hosted + * entitlement still applies; the page enforces its own per-resource authz. Use + * for workspace-admin-managed features (e.g. custom blocks) that live in the + * Enterprise section but aren't org-admin concerns. + */ + allowNonOrgAdmin?: boolean /** Show in the sidebar even when the user lacks the required plan, with an upgrade badge. */ showWhenLocked?: boolean /** Hide for enterprise plans, which manage billing out-of-band. */ @@ -274,6 +282,7 @@ export const allNavigationItems: NavigationItem[] = [ section: 'enterprise', requiresHosted: true, requiresEnterprise: true, + allowNonOrgAdmin: true, selfHostedOverride: isCustomBlocksEnabled, }, { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index c9ca98d100c..7d0c15e403a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -127,13 +127,15 @@ export function SettingsSidebar({ return true } - if (item.requiresTeam && (!hasTeamPlan || !isOrgAdminOrOwner)) { + const orgAdminSatisfied = isOrgAdminOrOwner || item.allowNonOrgAdmin + + if (item.requiresTeam && (!hasTeamPlan || !orgAdminSatisfied)) { return false } if ( item.requiresEnterprise && - (!hasEnterprisePlan || !isOrgAdminOrOwner) && + (!hasEnterprisePlan || !orgAdminSatisfied) && !item.showWhenLocked ) { return false From cd259708644c18f39aaaf5c5877c42f0ba919a0f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 10:46:18 -0700 Subject: [PATCH 2/5] feat(custom-blocks): any org member views; create gated on workspace admin Split visibility from management to match the org-wide nature of custom blocks: - View: any org member (the nav item is now plan-gated, not org-admin). - Create: the "Create block" action shows only when the user is admin of a workspace in the current org, matching the publish route's source-workspace admin authz. - Source picker: lists only workspaces the user administers (in the current org); the default selection snaps to an eligible workspace when the current one isn't one they can publish from. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017ddpeoz81CSqYQ73Dzf7yo --- .../components/custom-block-detail.tsx | 25 +++++++++++- .../components/custom-blocks.tsx | 39 ++++++++++++++----- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 8ecf826e15f..1bd4954b889 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -91,11 +91,32 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD () => workspaces.find((w) => w.id === workspaceId)?.organizationId ?? null, [workspaces, workspaceId] ) + // Only workspaces the user can publish from (admin) — the publish route requires + // admin on the source workspace, so a member/read workspace can never be a source. const orgWorkspaces = useMemo( - () => (currentOrgId ? workspaces.filter((w) => w.organizationId === currentOrgId) : []), + () => + currentOrgId + ? workspaces.filter((w) => w.organizationId === currentOrgId && w.permissions === 'admin') + : [], [workspaces, currentOrgId] ) + const eligibleDefaultWorkspaceId = useMemo( + () => + orgWorkspaces.some((w) => w.id === workspaceId) + ? workspaceId + : (orgWorkspaces[0]?.id ?? workspaceId), + [orgWorkspaces, workspaceId] + ) const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(workspaceId) + // Once the eligible list loads, snap an ineligible selection (e.g. the current + // workspace when the user isn't its admin) to the first workspace they can publish from. + if ( + isCreate && + orgWorkspaces.length > 0 && + !orgWorkspaces.some((w) => w.id === selectedWorkspaceId) + ) { + setSelectedWorkspaceId(eligibleDefaultWorkspaceId) + } const [selectedWorkflowId, setSelectedWorkflowId] = useState('') const { data: workflows = [] } = useWorkflows(isCreate ? selectedWorkspaceId : undefined) @@ -286,7 +307,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD function handleDiscard() { if (isCreate) { - setSelectedWorkspaceId(workspaceId) + setSelectedWorkspaceId(eligibleDefaultWorkspaceId) setSelectedWorkflowId('') } setName(existing?.name ?? '') diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index 036d2aa9f56..e2dda5495a8 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -12,6 +12,7 @@ import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon' import { CustomBlockDetail } from '@/ee/custom-blocks/components/custom-block-detail' import { useWhitelabelSettings } from '@/ee/whitelabeling/hooks/whitelabel' import { useCanPublishCustomBlock, useCustomBlocks } from '@/hooks/queries/custom-blocks' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' export function CustomBlocks() { const params = useParams() @@ -19,6 +20,20 @@ export function CustomBlocks() { const { data: canManage = false, isLoading } = useCanPublishCustomBlock(workspaceId) const { data: blocks = [] } = useCustomBlocks(workspaceId) + const { data: workspaces = [] } = useWorkspacesQuery() + + // Any org member can view the org's blocks, but publishing requires admin on a + // source workspace — the publish route enforces admin on the picked workspace. + const currentOrgId = useMemo( + () => workspaces.find((w) => w.id === workspaceId)?.organizationId ?? null, + [workspaces, workspaceId] + ) + const canCreate = useMemo( + () => + !!currentOrgId && + workspaces.some((w) => w.organizationId === currentOrgId && w.permissions === 'admin'), + [workspaces, currentOrgId] + ) const { data: whitelabel } = useWhitelabelSettings(blocks[0]?.organizationId) const fallbackIconUrl = whitelabel?.logoUrl ?? null @@ -59,19 +74,25 @@ export function CustomBlocks() { onChange: setSearchTerm, placeholder: 'Search custom blocks...', }} - actions={[ - { - text: 'Create block', - icon: Plus, - variant: 'primary', - onSelect: () => setSelected('new'), - }, - ]} + actions={ + canCreate + ? [ + { + text: 'Create block', + icon: Plus, + variant: 'primary', + onSelect: () => setSelected('new'), + }, + ] + : [] + } > {blocks.length === 0 ? ( - No custom blocks yet. Click "Create block" to publish a workflow as a block. + {canCreate + ? 'No custom blocks yet. Click "Create block" to publish a workflow as a block.' + : 'No custom blocks yet.'} ) : filtered.length === 0 ? ( From 8f89f205eb21c82de7980e92e0353be5a7f3549f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 12:14:36 -0700 Subject: [PATCH 3/5] feat(custom-blocks): read-only detail for non-managers + fill image icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the source workspaceId to the block wire type and gate the detail view on it: a viewer who isn't an admin of the block's source workspace gets a read-only view — no Save/Discard, no Delete, all fields disabled. Matches the server authz (edit/delete require source-workspace admin), so the UI no longer dangles buttons that 403. - SettingsResourceRow: add an opt-in `iconFill` so uploaded image icons fill the tile edge-to-edge instead of clamping to 20px; glyph svgs still normalize to 20px. Custom blocks list opts in. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017ddpeoz81CSqYQ73Dzf7yo --- apps/sim/app/api/custom-blocks/route.ts | 1 + .../settings-resource-row.tsx | 18 ++++++-- .../components/custom-block-detail.tsx | 43 ++++++++++++------- .../components/custom-blocks.tsx | 1 + apps/sim/lib/api/contracts/custom-blocks.ts | 2 + .../lib/workflows/custom-blocks/operations.ts | 12 +++++- 6 files changed, 56 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts index 99b6319aa3d..7c3c964b1dd 100644 --- a/apps/sim/app/api/custom-blocks/route.ts +++ b/apps/sim/app/api/custom-blocks/route.ts @@ -29,6 +29,7 @@ function toWire(block: CustomBlockWithInputs) { organizationId: block.organizationId, workflowId: block.workflowId, workflowName: block.workflowName, + workspaceId: block.workspaceId, workspaceName: block.workspaceName, type: block.type, name: block.name, 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 51a7dbd9619..88d9365e65a 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 @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' /** * The canonical settings "resource row": a rounded-bordered icon tile, a @@ -11,8 +12,14 @@ import type { ReactNode } from 'react' * contains to 20px, so callers pass their raw icon node without pre-sizing it. */ interface SettingsResourceRowProps { - /** Icon node centered in the tile; any ``/`` is normalized to 20px. */ + /** Icon node centered in the tile; a `` is normalized to 20px, an `` to 20px (or the full tile when `iconFill`). */ icon: ReactNode + /** + * Let an image icon fill the tile edge-to-edge instead of clamping to 20px. + * Use for uploaded image/logo icons (e.g. custom blocks); glyph ``s still + * normalize to 20px so a fallback icon doesn't balloon. + */ + iconFill?: boolean /** Primary line — truncates. */ title: ReactNode /** Secondary muted line — truncates. */ @@ -21,11 +28,12 @@ interface SettingsResourceRowProps { trailing?: ReactNode } -const TILE_CLASS = - 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] [&_img]:size-5 [&_svg]:size-5' +const TILE_BASE = + 'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] [&_svg]:size-5' export function SettingsResourceRow({ icon, + iconFill = false, title, description, trailing, @@ -33,7 +41,9 @@ export function SettingsResourceRow({ return (
-
{icon}
+
+ {icon} +
{title} {description != null && ( diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index 1bd4954b889..d5d627daef3 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -82,8 +82,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD const update = useUpdateCustomBlock(workspaceId) const remove = useDeleteCustomBlock(workspaceId) - // Source picker (create only). Editing a block never re-points its source. - const { data: workspaces = [] } = useWorkspacesQuery(isCreate) + // Needed in both modes: the source picker (create) and the manage gate (edit). + const { data: workspaces = [] } = useWorkspacesQuery() + + // A block is manageable only by an admin of its SOURCE workspace — the same authz + // the publish/update/delete routes enforce. Everyone else gets a read-only view + // (no Save/Discard, no Delete, disabled fields). Create is always manageable: the + // list gates the entry on workspace-admin and the picker only offers admin workspaces. + const canManageBlock = + isCreate || workspaces.some((w) => w.id === existing?.workspaceId && w.permissions === 'admin') // Custom blocks are org-scoped and the settings list only shows the current org's // blocks, so a block published to another org's workspace would silently never // appear here. Restrict the picker to workspaces in the current workspace's org. @@ -387,14 +394,16 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD title={existing?.name || 'New block'} description='Publish a deployed workflow as a reusable, org-wide block.' actions={[ - ...saveDiscardActions({ - dirty, - saving, - onSave: handleSave, - onDiscard: handleDiscard, - saveDisabled, - }), - ...(existing + ...(canManageBlock + ? saveDiscardActions({ + dirty, + saving, + onSave: handleSave, + onDiscard: handleDiscard, + saveDisabled, + }) + : []), + ...(existing && canManageBlock ? [ { text: remove.isPending ? 'Deleting...' : 'Delete', @@ -467,11 +476,11 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
- + {}}>
@@ -623,7 +635,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD className='w-full' dropdownWidth='trigger' maxHeight={280} - disabled={deployed.isLoading || outputGroups.length === 0} + disabled={deployed.isLoading || outputGroups.length === 0 || !canManageBlock} emptyMessage={deployed.isLoading ? 'Loading workflow…' : 'No outputs found.'} options={[]} groups={outputGroups} @@ -655,6 +667,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD placeholder='name' className='w-[140px]' maxLength={60} + disabled={!canManageBlock} />
) diff --git a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx index e2dda5495a8..f7534c9bf3f 100644 --- a/apps/sim/ee/custom-blocks/components/custom-blocks.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-blocks.tsx @@ -111,6 +111,7 @@ export function CustomBlocks() { > } + iconFill title={cb.name} description={cb.description || undefined} trailing={ diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts index baf18abeaf4..95d0915bca2 100644 --- a/apps/sim/lib/api/contracts/custom-blocks.ts +++ b/apps/sim/lib/api/contracts/custom-blocks.ts @@ -39,6 +39,8 @@ export const customBlockSchema = z.object({ workflowId: z.string(), /** Name of the bound source workflow (for display; the source can't be changed). */ workflowName: z.string(), + /** Source workflow's home workspace id — used client-side to gate manage affordances. */ + workspaceId: z.string().nullable(), /** Name of the source workflow's home workspace (display only). */ workspaceName: z.string().nullable(), type: z.string(), diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index 83df77525ca..b36dc2ed3da 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -34,6 +34,8 @@ export interface CustomBlockWithInputs { organizationId: string workflowId: string workflowName: string + /** Source workflow's home workspace id — used client-side to gate manage affordances. */ + workspaceId: string | null workspaceName: string | null type: string name: string @@ -162,18 +164,24 @@ export async function listCustomBlocksWithInputs( organizationId: string ): Promise { const rows = await db - .select({ block: customBlock, workflowName: workflow.name, workspaceName: workspace.name }) + .select({ + block: customBlock, + workflowName: workflow.name, + workspaceId: workflow.workspaceId, + workspaceName: workspace.name, + }) .from(customBlock) .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) .leftJoin(workspace, eq(workspace.id, workflow.workspaceId)) .where(eq(customBlock.organizationId, organizationId)) return Promise.all( - rows.map(async ({ block: row, workflowName, workspaceName }) => ({ + rows.map(async ({ block: row, workflowName, workspaceId, workspaceName }) => ({ id: row.id, organizationId: row.organizationId, workflowId: row.workflowId, workflowName, + workspaceId, workspaceName, type: row.type, name: row.name, From 3bea60cb81cf972f2445731b363da3093a669e37 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 12:29:00 -0700 Subject: [PATCH 4/5] fix(custom-blocks): include workspaceId in publishCustomBlock return The publish path builds a CustomBlockWithInputs literal; it was missing the workspaceId field added to the interface, breaking the type check. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017ddpeoz81CSqYQ73Dzf7yo --- apps/sim/lib/workflows/custom-blocks/operations.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index b36dc2ed3da..28d7dc5af35 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -389,6 +389,7 @@ export async function publishCustomBlock(params: { organizationId, workflowId, workflowName: wf.name, + workspaceId: wf.workspaceId, workspaceName: ws?.name ?? null, type, name, From 414375f72f2c2c7a71126ee1ebef552d58d33e42 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 12:39:06 -0700 Subject: [PATCH 5/5] fix(custom-blocks): don't flag create as dirty when source auto-snaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create dirty-check compared selectedWorkspaceId against the URL workspace, but the source picker now auto-snaps to the first workspace the user can publish from — which may differ. Compare against that eligible default so opening/discarding the create flow doesn't show a false unsaved-changes state. (Bugbot) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017ddpeoz81CSqYQ73Dzf7yo --- apps/sim/ee/custom-blocks/components/custom-block-detail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx index d5d627daef3..d9d48a99eed 100644 --- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx +++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx @@ -263,7 +263,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD name.trim() || description.trim() || selectedWorkflowId || - selectedWorkspaceId !== workspaceId || + selectedWorkspaceId !== eligibleDefaultWorkspaceId || iconUrl || visibleOutputs.length > 0 || visibleInputs.some((i) => i.placeholder?.trim())