From 4cd1461b9e4ab10c643dae9edfe2fd27ecd7008e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 8 Jul 2026 13:51:17 -0700 Subject: [PATCH] feat(custom-blocks): org-member visibility, workspace-admin-gated management (#5507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): let workspace admins reach the Custom blocks settings page 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 * 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 * feat(custom-blocks): read-only detail for non-managers + fill image icon - 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 * 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 * fix(custom-blocks): don't flag create as dirty when source auto-snaps 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 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- apps/sim/app/api/custom-blocks/route.ts | 1 + .../settings-resource-row.tsx | 18 +++-- .../[workspaceId]/settings/navigation.ts | 9 +++ .../settings-sidebar/settings-sidebar.tsx | 6 +- .../components/custom-block-detail.tsx | 70 ++++++++++++++----- .../components/custom-blocks.tsx | 40 ++++++++--- apps/sim/lib/api/contracts/custom-blocks.ts | 2 + .../lib/workflows/custom-blocks/operations.ts | 13 +++- 8 files changed, 124 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts index 99b6319aa3..7c3c964b1d 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 51a7dbd961..88d9365e65 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/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 813993070f..e795197281 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -70,6 +70,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. */ @@ -283,6 +291,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 dc93cfef58..40f99677ef 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 @@ -136,13 +136,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 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 8ecf826e15..d9d48a99ee 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. @@ -91,11 +98,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) @@ -235,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()) @@ -286,7 +314,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD function handleDiscard() { if (isCreate) { - setSelectedWorkspaceId(workspaceId) + setSelectedWorkspaceId(eligibleDefaultWorkspaceId) setSelectedWorkflowId('') } setName(existing?.name ?? '') @@ -366,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', @@ -446,11 +476,11 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
- + {}}>
@@ -602,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} @@ -634,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 036d2aa9f5..f7534c9bf3 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 ? ( @@ -90,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 baf18abeaf..95d0915bca 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 83df77525c..28d7dc5af3 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, @@ -381,6 +389,7 @@ export async function publishCustomBlock(params: { organizationId, workflowId, workflowName: wf.name, + workspaceId: wf.workspaceId, workspaceName: ws?.name ?? null, type, name,