mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Claude Opus 4.8
parent
4a80374c03
commit
4cd1461b9e
@@ -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,
|
||||
|
||||
+14
-4
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user