Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/custom-blocks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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. */
Expand All @@ -21,19 +28,22 @@ 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,
}: SettingsResourceRowProps) {
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 && (
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -274,6 +282,7 @@ export const allNavigationItems: NavigationItem[] = [
section: 'enterprise',
requiresHosted: true,
requiresEnterprise: true,
allowNonOrgAdmin: true,
selfHostedOverride: isCustomBlocksEnabled,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 52 additions & 18 deletions apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,48 @@ 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.
const currentOrgId = useMemo(
() => 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)

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -286,7 +314,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD

function handleDiscard() {
if (isCreate) {
setSelectedWorkspaceId(workspaceId)
Comment thread
cursor[bot] marked this conversation as resolved.
setSelectedWorkspaceId(eligibleDefaultWorkspaceId)
setSelectedWorkflowId('')
}
setName(existing?.name ?? '')
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 ? (
Expand All @@ -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'}
Expand All @@ -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]' />
Expand All @@ -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>

Expand All @@ -512,6 +543,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
placeholder='What this block does'
rows={2}
maxLength={280}
disabled={!canManageBlock}
/>
</SettingRow>

Expand Down Expand Up @@ -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>
Expand All @@ -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}
Expand Down Expand Up @@ -634,6 +667,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
placeholder='name'
className='w-[140px]'
maxLength={60}
disabled={!canManageBlock}
/>
</div>
)
Expand Down
40 changes: 31 additions & 9 deletions apps/sim/ee/custom-blocks/components/custom-blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,28 @@ 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()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined

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

Expand Down Expand Up @@ -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'>
Expand All @@ -90,6 +111,7 @@ export function CustomBlocks() {
>
<SettingsResourceRow
icon={<Icon />}
iconFill
title={cb.name}
description={cb.description || undefined}
trailing={
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api/contracts/custom-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
13 changes: 11 additions & 2 deletions apps/sim/lib/workflows/custom-blocks/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -381,6 +389,7 @@ export async function publishCustomBlock(params: {
organizationId,
workflowId,
workflowName: wf.name,
workspaceId: wf.workspaceId,
workspaceName: ws?.name ?? null,
type,
name,
Expand Down
Loading