feat(custom-blocks): org-member visibility, workspace-admin-gated management (#5507)

* 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ddpeoz81CSqYQ73Dzf7yo

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-07-08 16:51:17 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4a80374c03
commit 4cd1461b9e
8 changed files with 124 additions and 35 deletions
+1
View File
@@ -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,
@@ -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 `<svg>`/`<img>` is normalized to 20px. */
/** Icon node centered in the tile; a `<svg>` is normalized to 20px, an `<img>` 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 `<svg>`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 (
<div className='flex items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
<div className={TILE_CLASS}>{icon}</div>
<div className={cn(TILE_BASE, iconFill ? '[&_img]:size-full' : '[&_img]:size-5')}>
{icon}
</div>
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
{description != null && (
@@ -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,
},
{
@@ -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
@@ -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
<SettingRow label='Icon' labelTooltip='Square image (PNG, JPEG, or SVG). Optional.'>
<div className='flex items-center gap-4'>
<DropZone onDrop={iconUpload.handleFileDrop}>
<DropZone onDrop={canManageBlock ? iconUpload.handleFileDrop : () => {}}>
<button
type='button'
onClick={iconUpload.handleThumbnailClick}
disabled={iconUpload.isUploading}
disabled={iconUpload.isUploading || !canManageBlock}
className='group relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--surface-2)] transition-colors hover:bg-[var(--surface-3)] disabled:opacity-50'
>
{iconUpload.isUploading ? (
@@ -468,7 +498,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
variant='outline'
size='sm'
onClick={iconUpload.handleThumbnailClick}
disabled={iconUpload.isUploading}
disabled={iconUpload.isUploading || !canManageBlock}
className='text-[13px]'
>
{iconUrl ? 'Change' : 'Upload'}
@@ -479,7 +509,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
variant='ghost'
size='sm'
onClick={iconUpload.handleRemove}
disabled={iconUpload.isUploading}
disabled={iconUpload.isUploading || !canManageBlock}
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
<X className='size-[14px]' />
@@ -502,6 +532,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
onChange={(e) => setName(e.target.value)}
placeholder='Invoice Parser'
maxLength={60}
disabled={!canManageBlock}
/>
</SettingRow>
@@ -512,6 +543,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
placeholder='What this block does'
rows={2}
maxLength={280}
disabled={!canManageBlock}
/>
</SettingRow>
@@ -579,6 +611,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
onChange={(e) => setPlaceholder(i.id, e.target.value)}
placeholder='Shown in the empty field'
maxLength={200}
disabled={!canManageBlock}
/>
</div>
</div>
@@ -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}
/>
</div>
)
@@ -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'),
},
]
: []
}
>
<SettingsSection label={`Blocks (${blocks.length})`}>
{blocks.length === 0 ? (
<SettingsEmptyState variant='inline'>
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.'}
</SettingsEmptyState>
) : filtered.length === 0 ? (
<SettingsEmptyState variant='inline'>
@@ -90,6 +111,7 @@ export function CustomBlocks() {
>
<SettingsResourceRow
icon={<Icon />}
iconFill
title={cb.name}
description={cb.description || undefined}
trailing={
@@ -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(),
@@ -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<CustomBlockWithInputs[]> {
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,