Skip to content

Commit 8f89f20

Browse files
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
1 parent cd25970 commit 8f89f20

6 files changed

Lines changed: 56 additions & 21 deletions

File tree

apps/sim/app/api/custom-blocks/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function toWire(block: CustomBlockWithInputs) {
2929
organizationId: block.organizationId,
3030
workflowId: block.workflowId,
3131
workflowName: block.workflowName,
32+
workspaceId: block.workspaceId,
3233
workspaceName: block.workspaceName,
3334
type: block.type,
3435
name: block.name,

apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactNode } from 'react'
2+
import { cn } from '@sim/emcn'
23

34
/**
45
* The canonical settings "resource row": a rounded-bordered icon tile, a
@@ -11,8 +12,14 @@ import type { ReactNode } from 'react'
1112
* contains to 20px, so callers pass their raw icon node without pre-sizing it.
1213
*/
1314
interface SettingsResourceRowProps {
14-
/** Icon node centered in the tile; any `<svg>`/`<img>` is normalized to 20px. */
15+
/** Icon node centered in the tile; a `<svg>` is normalized to 20px, an `<img>` to 20px (or the full tile when `iconFill`). */
1516
icon: ReactNode
17+
/**
18+
* Let an image icon fill the tile edge-to-edge instead of clamping to 20px.
19+
* Use for uploaded image/logo icons (e.g. custom blocks); glyph `<svg>`s still
20+
* normalize to 20px so a fallback icon doesn't balloon.
21+
*/
22+
iconFill?: boolean
1623
/** Primary line — truncates. */
1724
title: ReactNode
1825
/** Secondary muted line — truncates. */
@@ -21,19 +28,22 @@ interface SettingsResourceRowProps {
2128
trailing?: ReactNode
2229
}
2330

24-
const TILE_CLASS =
25-
'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'
31+
const TILE_BASE =
32+
'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'
2633

2734
export function SettingsResourceRow({
2835
icon,
36+
iconFill = false,
2937
title,
3038
description,
3139
trailing,
3240
}: SettingsResourceRowProps) {
3341
return (
3442
<div className='flex items-center justify-between gap-2.5'>
3543
<div className='flex min-w-0 items-center gap-2.5'>
36-
<div className={TILE_CLASS}>{icon}</div>
44+
<div className={cn(TILE_BASE, iconFill ? '[&_img]:size-full' : '[&_img]:size-5')}>
45+
{icon}
46+
</div>
3747
<div className='flex min-w-0 flex-col justify-center gap-[1px]'>
3848
<span className='truncate text-[var(--text-body)] text-sm'>{title}</span>
3949
{description != null && (

apps/sim/ee/custom-blocks/components/custom-block-detail.tsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
8282
const update = useUpdateCustomBlock(workspaceId)
8383
const remove = useDeleteCustomBlock(workspaceId)
8484

85-
// Source picker (create only). Editing a block never re-points its source.
86-
const { data: workspaces = [] } = useWorkspacesQuery(isCreate)
85+
// Needed in both modes: the source picker (create) and the manage gate (edit).
86+
const { data: workspaces = [] } = useWorkspacesQuery()
87+
88+
// A block is manageable only by an admin of its SOURCE workspace — the same authz
89+
// the publish/update/delete routes enforce. Everyone else gets a read-only view
90+
// (no Save/Discard, no Delete, disabled fields). Create is always manageable: the
91+
// list gates the entry on workspace-admin and the picker only offers admin workspaces.
92+
const canManageBlock =
93+
isCreate || workspaces.some((w) => w.id === existing?.workspaceId && w.permissions === 'admin')
8794
// Custom blocks are org-scoped and the settings list only shows the current org's
8895
// blocks, so a block published to another org's workspace would silently never
8996
// appear here. Restrict the picker to workspaces in the current workspace's org.
@@ -387,14 +394,16 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
387394
title={existing?.name || 'New block'}
388395
description='Publish a deployed workflow as a reusable, org-wide block.'
389396
actions={[
390-
...saveDiscardActions({
391-
dirty,
392-
saving,
393-
onSave: handleSave,
394-
onDiscard: handleDiscard,
395-
saveDisabled,
396-
}),
397-
...(existing
397+
...(canManageBlock
398+
? saveDiscardActions({
399+
dirty,
400+
saving,
401+
onSave: handleSave,
402+
onDiscard: handleDiscard,
403+
saveDisabled,
404+
})
405+
: []),
406+
...(existing && canManageBlock
398407
? [
399408
{
400409
text: remove.isPending ? 'Deleting...' : 'Delete',
@@ -467,11 +476,11 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
467476

468477
<SettingRow label='Icon' labelTooltip='Square image (PNG, JPEG, or SVG). Optional.'>
469478
<div className='flex items-center gap-4'>
470-
<DropZone onDrop={iconUpload.handleFileDrop}>
479+
<DropZone onDrop={canManageBlock ? iconUpload.handleFileDrop : () => {}}>
471480
<button
472481
type='button'
473482
onClick={iconUpload.handleThumbnailClick}
474-
disabled={iconUpload.isUploading}
483+
disabled={iconUpload.isUploading || !canManageBlock}
475484
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'
476485
>
477486
{iconUpload.isUploading ? (
@@ -489,7 +498,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
489498
variant='outline'
490499
size='sm'
491500
onClick={iconUpload.handleThumbnailClick}
492-
disabled={iconUpload.isUploading}
501+
disabled={iconUpload.isUploading || !canManageBlock}
493502
className='text-[13px]'
494503
>
495504
{iconUrl ? 'Change' : 'Upload'}
@@ -500,7 +509,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
500509
variant='ghost'
501510
size='sm'
502511
onClick={iconUpload.handleRemove}
503-
disabled={iconUpload.isUploading}
512+
disabled={iconUpload.isUploading || !canManageBlock}
504513
className='text-[13px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
505514
>
506515
<X className='size-[14px]' />
@@ -523,6 +532,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
523532
onChange={(e) => setName(e.target.value)}
524533
placeholder='Invoice Parser'
525534
maxLength={60}
535+
disabled={!canManageBlock}
526536
/>
527537
</SettingRow>
528538

@@ -533,6 +543,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
533543
placeholder='What this block does'
534544
rows={2}
535545
maxLength={280}
546+
disabled={!canManageBlock}
536547
/>
537548
</SettingRow>
538549

@@ -600,6 +611,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
600611
onChange={(e) => setPlaceholder(i.id, e.target.value)}
601612
placeholder='Shown in the empty field'
602613
maxLength={200}
614+
disabled={!canManageBlock}
603615
/>
604616
</div>
605617
</div>
@@ -623,7 +635,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
623635
className='w-full'
624636
dropdownWidth='trigger'
625637
maxHeight={280}
626-
disabled={deployed.isLoading || outputGroups.length === 0}
638+
disabled={deployed.isLoading || outputGroups.length === 0 || !canManageBlock}
627639
emptyMessage={deployed.isLoading ? 'Loading workflow…' : 'No outputs found.'}
628640
options={[]}
629641
groups={outputGroups}
@@ -655,6 +667,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
655667
placeholder='name'
656668
className='w-[140px]'
657669
maxLength={60}
670+
disabled={!canManageBlock}
658671
/>
659672
</div>
660673
)

apps/sim/ee/custom-blocks/components/custom-blocks.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export function CustomBlocks() {
111111
>
112112
<SettingsResourceRow
113113
icon={<Icon />}
114+
iconFill
114115
title={cb.name}
115116
description={cb.description || undefined}
116117
trailing={

apps/sim/lib/api/contracts/custom-blocks.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export const customBlockSchema = z.object({
3939
workflowId: z.string(),
4040
/** Name of the bound source workflow (for display; the source can't be changed). */
4141
workflowName: z.string(),
42+
/** Source workflow's home workspace id — used client-side to gate manage affordances. */
43+
workspaceId: z.string().nullable(),
4244
/** Name of the source workflow's home workspace (display only). */
4345
workspaceName: z.string().nullable(),
4446
type: z.string(),

apps/sim/lib/workflows/custom-blocks/operations.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface CustomBlockWithInputs {
3434
organizationId: string
3535
workflowId: string
3636
workflowName: string
37+
/** Source workflow's home workspace id — used client-side to gate manage affordances. */
38+
workspaceId: string | null
3739
workspaceName: string | null
3840
type: string
3941
name: string
@@ -162,18 +164,24 @@ export async function listCustomBlocksWithInputs(
162164
organizationId: string
163165
): Promise<CustomBlockWithInputs[]> {
164166
const rows = await db
165-
.select({ block: customBlock, workflowName: workflow.name, workspaceName: workspace.name })
167+
.select({
168+
block: customBlock,
169+
workflowName: workflow.name,
170+
workspaceId: workflow.workspaceId,
171+
workspaceName: workspace.name,
172+
})
166173
.from(customBlock)
167174
.innerJoin(workflow, eq(workflow.id, customBlock.workflowId))
168175
.leftJoin(workspace, eq(workspace.id, workflow.workspaceId))
169176
.where(eq(customBlock.organizationId, organizationId))
170177

171178
return Promise.all(
172-
rows.map(async ({ block: row, workflowName, workspaceName }) => ({
179+
rows.map(async ({ block: row, workflowName, workspaceId, workspaceName }) => ({
173180
id: row.id,
174181
organizationId: row.organizationId,
175182
workflowId: row.workflowId,
176183
workflowName,
184+
workspaceId,
177185
workspaceName,
178186
type: row.type,
179187
name: row.name,

0 commit comments

Comments
 (0)