feat: confirm run-as on flow and script deploy, mute from editor menu (#12015)

* refactor: harmonize flow and script settings layout

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the priority input open while it is edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: confirm run-as on flow and script deploy, mute from editor menu

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep settings rows open while edited and name the deploying user for non-admins

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: reread the deployed run-as user after a deploy that keeps the editor open

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: write a setting before its row opens and show its real state while edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: slide settings rows open to their full height

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: send the popover's deployment message when Deploy is clicked

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: remount a settings row's fields when its toggle turns it on

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: send the popover's deployment message from Deploy & Stay here

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: clear the deployment message after a successful deploy

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: read the deployed row before muting from the menu, title Deploy only when icon-only

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-10-27 23:52:02 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 07d4f7fe61
commit 75e3e7ea41
18 changed files with 750 additions and 261 deletions
@@ -3,19 +3,24 @@
import { Save, CornerDownLeft } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
type DropdownItem = {
label: string
/** Gets the message typed in the hover popover, for items that deploy. */
onClick: (deploymentMsg: string) => void
}
const {
loading = false,
loadingSave = false,
dropdownItems = [],
unifiedSize = 'md'
unifiedSize = 'md',
iconOnly = false
}: {
loading?: boolean
loadingSave?: boolean
dropdownItems?: Array<{
label: string
onClick: () => void
}>
dropdownItems?: DropdownItem[] | (() => DropdownItem[]) | undefined
unifiedSize?: 'sm' | 'md' | 'lg'
iconOnly?: boolean
} = $props()
const dispatch = createEventDispatcher()
@@ -24,6 +29,20 @@
let hideDropdown = $state(false)
let deploymentMsg = $state('')
let dropdownOpen = $state(false)
/** Call after a successful deploy: the next deploy from the same editor would store it again. */
export function clearMessage() {
deploymentMsg = ''
}
function withMessage(items: DropdownItem[]) {
return items.map((item) => ({ ...item, onClick: () => item.onClick(deploymentMsg) }))
}
let items = $derived(
typeof dropdownItems === 'function'
? () => withMessage(dropdownItems())
: dropdownItems && withMessage(dropdownItems)
)
</script>
<Button
@@ -31,9 +50,11 @@
loading={loadingSave}
variant="accent"
{unifiedSize}
{iconOnly}
title={iconOnly ? 'Deploy' : undefined}
startIcon={{ icon: Save }}
on:click={() => dispatch('save')}
{dropdownItems}
on:click={() => dispatch('save', deploymentMsg)}
dropdownItems={items}
tooltipPopover={{
placement: 'bottom-end',
openDelay: dropdownOpen ? 200 : 0,
-1
View File
@@ -711,7 +711,6 @@
currentEditor: writable(undefined),
modulesTestStates,
outputPickerOpenFns,
preserveOnBehalfOf: writable(false),
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined),
devTempScriptRefs: () => flowTempScriptRefs
+109 -17
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import DeployOnBehalfOfDialog from '$lib/components/itemSettings/DeployOnBehalfOfDialog.svelte'
import { deployAsksOnBehalfOf } from '$lib/components/itemSettings/onBehalfOf'
import { useFolderDefaultPermissionedAs } from '$lib/components/useFolderDefaultPermissionedAs.svelte'
import {
FlowService,
type Flow,
@@ -63,7 +66,12 @@
} from './flows/flowState'
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
import type { FlowEditorContext, FlowInput, FlowInputEditorState } from './flows/types'
import type {
ExtendedOpenFlow,
FlowEditorContext,
FlowInput,
FlowInputEditorState
} from './flows/types'
import { setFlowPanelPlacementTelemetry } from './flows/flowEditorTelemetry'
import { SelectionManager } from './graph/selectionUtils.svelte'
import { NoteEditor } from './graph/noteEditor.svelte'
@@ -71,6 +79,8 @@
import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte'
import { cleanFlow } from './flows/utils.svelte'
import { DiffIcon, HistoryIcon, FileJson, Settings, Undo, Redo, Disc } from 'lucide-svelte'
import { muteErrorHandlerMenuItem } from './details/errorHandlerToggle'
import { useDeployedItem } from './details/deployedItem.svelte'
import Awareness from './Awareness.svelte'
import { getAllModules } from './flows/flowExplorer'
import { type FlowCopilotContext } from './copilot/flow'
@@ -194,16 +204,21 @@
let initialPathStore = writable(initialPath)
// For preserve_on_behalf_of feature
let preserveOnBehalfOf = writable(false)
let savedOnBehalfOfEmail = writable<string | undefined>(savedFlow?.on_behalf_of_email)
let savedOnBehalfOfPermissionedAs = writable<string | undefined>(savedFlow?.on_behalf_of)
const isNewFlow = $derived(newFlow || initialPath == '')
let deployDialogOpen = $state(false)
let deployDialogMsg = $state('')
let deployButton: DeployButton | undefined = $state(undefined)
// Keep savedOnBehalfOfEmail in sync when savedFlow is loaded asynchronously
// Keep savedOnBehalfOfEmail in sync as the flow loads. Once deployed, the deployed row says
// who the flow runs as: `savedFlow` carries its draft's user. A flow that runs on behalf of
// nobody has no email field at all, so its absence is synced too.
$effect(() => {
if (savedFlow?.on_behalf_of_email !== undefined) {
savedOnBehalfOfEmail.set(savedFlow.on_behalf_of_email)
savedOnBehalfOfPermissionedAs.set(savedFlow.on_behalf_of)
const saved = deployedFlowPath ? deployedItem.current : savedFlow
if (saved) {
savedOnBehalfOfEmail.set(saved.on_behalf_of_email)
savedOnBehalfOfPermissionedAs.set(saved.on_behalf_of)
}
})
@@ -476,6 +491,30 @@
}
async function handleSaveFlow(deploymentMsg?: string) {
// Compares against the user deployed right now, which updates the saved stores; a failed
// lookup asks rather than trusting the draft's user.
const deployed = deployedFlowPath ? await deployedItem.refetch() : undefined
if (
(deployedFlowPath && !deployed) ||
// Asks while the folder default is loading: the backend would apply it unconfirmed.
folderDefault.loading ||
deployAsksOnBehalfOf('flow', {
user: actingUser,
email: flowStore.val.on_behalf_of_email,
permissionedAs: flowStore.val.on_behalf_of,
savedEmail: deployed?.on_behalf_of_email,
savedPermissionedAs: deployed?.on_behalf_of,
folderDefault: folderDefault.value
})
) {
deployDialogMsg = deploymentMsg ?? ''
deployDialogOpen = true
return
}
await deployFlow(deploymentMsg)
}
async function deployFlow(deploymentMsg?: string) {
withAIChangesWarning(async () => {
await handleSaveFlowInternal(deploymentMsg)
})
@@ -638,12 +677,13 @@
// The editor clears a step's lock when its code is edited, but a draft written elsewhere
// can hold a step whose code changed and whose lock did not. A failed lookup skips the
// check rather than blocking the deploy.
const liveFlowValue = isNewFlow
const liveFlow = isNewFlow
? undefined
: await FlowService.getFlowByPath({ workspace: opWorkspace!, path: initialPath })
.then((f) => f.value)
.catch(() => undefined)
dropStaleStepLocks(flow.value, liveFlowValue)
: await FlowService.getFlowByPath({
workspace: opWorkspace!,
path: userDraftPath || initialPath
}).catch(() => undefined)
dropStaleStepLocks(flow.value, liveFlow?.value)
if (isNewFlow) {
await FlowService.createFlow({
@@ -660,7 +700,7 @@
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email,
on_behalf_of: flow.on_behalf_of,
preserve_on_behalf_of: $preserveOnBehalfOf || undefined,
preserve_on_behalf_of: flow.on_behalf_of_email ? true : undefined,
deployment_message: deploymentMsg || undefined,
labels: (flow as any).labels,
// Sent explicitly: an omitted field keeps the deployed value.
@@ -710,11 +750,15 @@
schema: flow.schema,
tag: flow.tag,
dedicated_worker: flow.dedicated_worker,
ws_error_handler_muted: flow.ws_error_handler_muted,
// Muting applies to the deployed flow at once, from the editor menu or the flow's
// page, and the backend unmutes on an omitted value: keep the one deployed now.
ws_error_handler_muted: liveFlow
? liveFlow.ws_error_handler_muted
: flow.ws_error_handler_muted,
visible_to_runner_only: flow.visible_to_runner_only,
on_behalf_of_email: flow.on_behalf_of_email,
on_behalf_of: flow.on_behalf_of,
preserve_on_behalf_of: $preserveOnBehalfOf || undefined,
preserve_on_behalf_of: flow.on_behalf_of_email ? true : undefined,
deployment_message: deploymentMsg || undefined,
labels: (flow as any).labels,
// Sent explicitly: an omitted field keeps the deployed value.
@@ -736,6 +780,10 @@
} as Flow
setDraftTriggers([])
loadingSave = false
// Settings shows the deployed run-as user while the editor stays open, a session's
// included: read the version just written.
void deployedItem.refetch()
deployButton?.clearMessage()
onDeploy?.({ path: $pathStore })
} catch (err) {
onDeployError?.({ error: err })
@@ -766,6 +814,11 @@
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
const history = initHistory(untrack(() => flowStore).val)
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
// Only the flow's first deploy takes the folder's default user.
const folderDefault = useFolderDefaultPermissionedAs(
() => (isNewFlow ? $pathStore : undefined),
() => opWorkspace
)
// Linked-agent tool resolutions are scoped by workspace + flow path, but publishers key by the
// flow doc's own path while readers use the live-edited $pathStore — which diverge for renames
@@ -996,7 +1049,6 @@
flowInputEditorState: flowInputEditorStateStore,
modulesTestStates,
outputPickerOpenFns,
preserveOnBehalfOf,
savedOnBehalfOfEmail,
savedOnBehalfOfPermissionedAs,
opWorkspace: () => opWorkspace
@@ -1369,9 +1421,32 @@
displayName: 'Test flow & record',
icon: Disc,
action: () => flowPreviewButtons?.openRecordingPreview()
}
},
// Before the first deploy the switch edits the flow, and the create sends it.
muteErrorHandlerMenuItem({
kind: 'flow',
deployed: !!deployedFlowPath,
muted: deployedFlowPath ? deployedItem.muted : !!editedFlow().ws_error_handler_muted,
hasEnterpriseLicense: !!$enterpriseLicense,
onToggle: () => {
if (deployedFlowPath) deployedItem.toggle()
else editedFlow().ws_error_handler_muted = !editedFlow().ws_error_handler_muted
}
})
]
}
const editedFlow = () => flowStore.val as ExtendedOpenFlow
// The URL path: `initialPath` is the staged name once a draft that renames the flow reloads.
const deployedFlowPath = $derived(
!newFlow && savedFlow && savedFlow.no_deployed !== true
? userDraftPath || initialPath || undefined
: undefined
)
const deployedItem = useDeployedItem(
'flow',
() => opWorkspace,
() => deployedFlowPath
)
function onCustomUiChange(
customUi: FlowBuilderWhitelabelCustomUi | undefined,
@@ -1557,6 +1632,21 @@
{@render children?.()}
<DeployOnBehalfOfDialog
kind="flow"
bind:open={deployDialogOpen}
bind:message={deployDialogMsg}
bind:email={flowStore.val.on_behalf_of_email}
bind:permissionedAs={flowStore.val.on_behalf_of}
savedEmail={isNewFlow ? undefined : $savedOnBehalfOfEmail}
savedPermissionedAs={isNewFlow ? undefined : $savedOnBehalfOfPermissionedAs}
isNew={isNewFlow}
folderDefault={folderDefault.value}
folderDefaultLoading={folderDefault.loading}
targetWorkspace={opWorkspace ?? ''}
onConfirmed={() => deployFlow(deployDialogMsg)}
/>
<DeployOverrideConfirmationModal
{deployedBy}
{confirmCallback}
@@ -1684,6 +1774,7 @@
{/if}
<DeployButton
bind:this={deployButton}
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
{loadingSave}
@@ -1799,6 +1890,7 @@
<OpenInSessionButton source={headerSessionOpen} />
<DeployButton
bind:this={deployButton}
on:save={async ({ detail }) => await handleSaveFlow(detail)}
{loading}
{loadingSave}
+120 -46
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import DeployOnBehalfOfDialog from '$lib/components/itemSettings/DeployOnBehalfOfDialog.svelte'
import { deployAsksOnBehalfOf } from '$lib/components/itemSettings/onBehalfOf'
import { useFolderDefaultPermissionedAs } from '$lib/components/useFolderDefaultPermissionedAs.svelte'
import {
ScriptService,
type Script,
@@ -74,6 +77,8 @@
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { parsePipelineAnnotations } from './assets/AssetGraph/parsePipelineAnnotations'
import DropdownV2 from './DropdownV2.svelte'
import { muteErrorHandlerMenuItem } from './details/errorHandlerToggle'
import { useDeployedItem } from './details/deployedItem.svelte'
import { getLocalSetting, storeLocalSetting, type Item } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
@@ -119,11 +124,7 @@
import WorkerTagSelect from './WorkerTagSelect.svelte'
import type { ButtonType } from './common/button/model'
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
import type { OnBehalfOfChoice } from './OnBehalfOfSelector.svelte'
import RunOnBehalfOfSetting from './itemSettings/RunOnBehalfOfSetting.svelte'
import MuteErrorHandlerSetting from './itemSettings/MuteErrorHandlerSetting.svelte'
import SettingGroup from './itemSettings/SettingGroup.svelte'
import { keepsDeployedOnBehalfOf } from './itemSettings/onBehalfOf'
import ScriptRuntimeSettings from './itemSettings/ScriptRuntimeSettings.svelte'
import InvisibleRunsSetting from './itemSettings/InvisibleRunsSetting.svelte'
import SettingIcons from './itemSettings/SettingIcons.svelte'
@@ -244,6 +245,40 @@
let localWorkerTags = $state<string[] | undefined>(undefined)
const scriptWorkerTags = $derived(usesLocalTags ? localWorkerTags : $workerTags)
const scriptIsDeployed = $derived(!!initialPath && !!savedScript && !savedScript.no_deployed)
// The URL path: `initialPath` is the staged name once a draft that renames the script reloads.
const deployedItem = useDeployedItem(
'script',
() => opWorkspace,
() => (scriptIsDeployed ? userDraftPath || initialPath : undefined)
)
// Who the deployed script runs as: `savedScript` carries its draft's user.
const savedRunAs = $derived(
scriptIsDeployed ? (deployedItem.current ?? savedScript) : savedScript
)
const offersMute = $derived(customUi?.settingsPanel?.metadata?.disableMute !== true)
function getMoreMenuItems(): Item[] {
return [
...(compactTopbar ? getCompactMenuItems() : []),
...(offersMute
? [
// Before the first deploy the switch edits the script, and the deploy sends it.
muteErrorHandlerMenuItem({
kind: 'script',
deployed: scriptIsDeployed,
muted: scriptIsDeployed ? deployedItem.muted : !!script.ws_error_handler_muted,
hasEnterpriseLicense: !!$enterpriseLicense,
onToggle: () => {
if (scriptIsDeployed) deployedItem.toggle()
else script.ws_error_handler_muted = !script.ws_error_handler_muted
}
})
]
: [])
]
}
function getCompactMenuItems(): Item[] {
const hasTags = (scriptWorkerTags?.length ?? 0) > 0
const isDraftOnly = savedScript?.no_deployed === true
@@ -279,24 +314,17 @@
let args: Record<string, any> = $state(untrack(() => initialArgs)) // Test args input
let selectedInputTab: 'main' | 'preprocessor' | 'diagram' = $state('main')
let hasPreprocessor = $state(false)
let preserveOnBehalfOf = $state(false)
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
function keepDeployedOnBehalfOf() {
if (
!keepsDeployedOnBehalfOf(
actingUser,
onBehalfOfChoice,
script.on_behalf_of_email,
savedScript?.on_behalf_of_email
)
)
return
onBehalfOfChoice = 'target'
script.on_behalf_of_email = savedScript?.on_behalf_of_email
script.on_behalf_of = savedScript?.on_behalf_of
preserveOnBehalfOf = true
}
const isNewScript = $derived(initialPath == '' || savedScript?.no_deployed === true)
// Only the script's first deploy takes the folder's default user.
// A rename is a create at the new path, so the backend applies that path's folder default too.
const folderDefault = useFolderDefaultPermissionedAs(
() => (isNewScript || script.path !== (userDraftPath || initialPath) ? script.path : undefined),
() => opWorkspace
)
let deployDialogOpen = $state(false)
let deployDialogMsg = $state('')
let deployButton: DeployButton | undefined = $state(undefined)
let deployDialogStay = false
let metadataOpen = $state(
!untrack(() => neverShowMeta) &&
@@ -634,6 +662,31 @@
}
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void> {
// Compares against the user deployed right now; a failed lookup asks rather than trusting
// the draft's user.
const deployed = scriptIsDeployed ? await deployedItem.refetch() : undefined
if (
(scriptIsDeployed && !deployed) ||
// Asks while the folder default is loading: the backend would apply it unconfirmed.
folderDefault.loading ||
deployAsksOnBehalfOf('script', {
user: actingUser,
email: script.on_behalf_of_email,
permissionedAs: script.on_behalf_of,
savedEmail: deployed?.on_behalf_of_email,
savedPermissionedAs: deployed?.on_behalf_of,
folderDefault: folderDefault.value
})
) {
deployDialogStay = stay
deployDialogMsg = deployMsg ?? ''
deployDialogOpen = true
return
}
await deployScript(stay, deployMsg)
}
async function deployScript(stay: boolean, deployMsg?: string): Promise<void> {
scriptEditor?.flushModuleState()
// Fetch latest version and fetch entire script after if needed
let actual_parent_hash: string | undefined = undefined
@@ -795,6 +848,17 @@
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
// Muting applies to the deployed script at once, from the editor menu or the script's
// page, and a new version without the value is unmuted: keep the one deployed now.
// The deployed row sits at the URL path: `initialPath` is the staged name after a reload.
const deployedMuted = scriptIsDeployed
? await ScriptService.getScriptByPath({
workspace: opWorkspace!,
path: userDraftPath || initialPath
})
.then((s) => !!s.ws_error_handler_muted)
.catch(() => undefined)
: undefined
const newHash = await ScriptService.createScript({
workspace: opWorkspace!,
requestBody: {
@@ -822,7 +886,7 @@
max_total_debounces_amount: script.max_total_debounces_amount,
cache_ttl: script.cache_ttl,
cache_ignore_s3_path: script.cache_ignore_s3_path,
ws_error_handler_muted: script.ws_error_handler_muted,
ws_error_handler_muted: deployedMuted ?? script.ws_error_handler_muted,
priority: script.priority,
restart_unless_cancelled: script.restart_unless_cancelled,
delete_after_secs: script.delete_after_secs,
@@ -834,7 +898,7 @@
deployment_message: deploymentMsg || undefined,
on_behalf_of_email: script.on_behalf_of_email,
on_behalf_of: script.on_behalf_of,
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
preserve_on_behalf_of: script.on_behalf_of_email ? true : undefined,
assets: script.assets,
modules: script.modules,
labels: script.labels,
@@ -894,6 +958,10 @@
// the version we just wrote.
script.parent_hash = newHash
}
// Settings shows the deployed run-as user while the editor stays open, a session's
// included: read the version just written.
void deployedItem.refetch()
deployButton?.clearMessage()
// Always notify on a successful deploy; the consumer decides whether to
// navigate (route) or stay + sync the preview (session). Previously the
// stay/lib branch skipped onDeploy, so session previews didn't sync after
@@ -1098,15 +1166,15 @@
initialPath: string,
savedScript: ((Script | NewScript) & { no_deployed?: boolean }) | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
let dropdownItems: { label: string; onClick: (deploymentMsg: string) => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
? [
...(!inSessionPane
? [
{
label: 'Deploy & Stay here',
onClick: () => {
handleEditScript(true)
onClick: (deploymentMsg) => {
handleEditScript(true, deploymentMsg)
}
},
{
@@ -1399,17 +1467,20 @@
<svelte:window onkeydown={onKeyDown} />
{@render children?.()}
{#snippet muteSetting()}
{#if customUi?.settingsPanel?.metadata?.disableMute !== true}
<SettingGroup label="Error handling" class="flex flex-col gap-6">
<MuteErrorHandlerSetting
kind="script"
path={script.path}
bind:muted={script.ws_error_handler_muted}
/>
</SettingGroup>
{/if}
{/snippet}
<DeployOnBehalfOfDialog
kind="script"
bind:open={deployDialogOpen}
bind:message={deployDialogMsg}
bind:email={script.on_behalf_of_email}
bind:permissionedAs={script.on_behalf_of}
savedEmail={isNewScript ? undefined : savedRunAs?.on_behalf_of_email}
savedPermissionedAs={isNewScript ? undefined : savedRunAs?.on_behalf_of}
isNew={isNewScript}
folderDefault={folderDefault.value}
folderDefaultLoading={folderDefault.loading}
targetWorkspace={opWorkspace ?? ''}
onConfirmed={() => deployScript(deployDialogStay, deployDialogMsg)}
/>
<DeployOverrideConfirmationModal
{deployedBy}
@@ -1814,18 +1885,16 @@
placeholder={customUi?.tagSelectPlaceholder}
/>
</Section>
{@render muteSetting()}
{/if}
</div>
</TabContent>
<TabContent value="runtime">
<div class="flex flex-col gap-8 p-4 pb-8" {@attach keepDeployedOnBehalfOf}>
<div class="p-4 pb-8">
<ScriptRuntimeSettings
bind:script
tagPlaceholder={customUi?.tagSelectPlaceholder}
defaultTimeout={customUi?.defaultTimeout}
/>
{@render muteSetting()}
</div>
</TabContent>
<TabContent value="permissions">
@@ -1834,10 +1903,8 @@
kind="script"
bind:email={script.on_behalf_of_email}
bind:permissionedAs={script.on_behalf_of}
bind:preserve={preserveOnBehalfOf}
bind:choice={onBehalfOfChoice}
savedEmail={savedScript?.on_behalf_of_email}
savedPermissionedAs={savedScript?.on_behalf_of}
savedEmail={savedRunAs?.on_behalf_of_email}
savedPermissionedAs={savedRunAs?.on_behalf_of}
targetWorkspace={opWorkspace ?? ''}
isNew={isNewScript}
/>
@@ -2136,8 +2203,8 @@
</div>
{/if}
{/snippet}
{#if compactTopbar}
<DropdownV2 items={getCompactMenuItems} placement="bottom-end">
{#snippet moreMenu()}
<DropdownV2 items={getMoreMenuItems} placement="bottom-end">
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
@@ -2149,11 +2216,17 @@
/>
{/snippet}
</DropdownV2>
{/snippet}
{#if compactTopbar}
{@render moreMenu()}
{#if !phoneTopbar}
{@render diffButton()}
{@render settingsButton()}
{/if}
{:else}
{#if offersMute}
{@render moreMenu()}
{/if}
{@render diffButton()}
{#if customUi?.topBar?.tagEdit != false}
{#if scriptWorkerTags}
@@ -2177,6 +2250,7 @@
<OpenInSessionButton source={headerSessionOpen()} />
{/if}
<DeployButton
bind:this={deployButton}
loading={!fullyLoaded}
{loadingSave}
unifiedSize={headerBtnSize}
@@ -0,0 +1,65 @@
import { FlowService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { resource } from 'runed'
import { toggleWorkspaceErrorHandler } from './errorHandlerToggle'
type DeployedItem = {
ws_error_handler_muted?: boolean
on_behalf_of?: string
on_behalf_of_email?: string
}
/** The deployed script or flow at `path`, read from its row: an editor's loaded item is the
* deployed one with its draft laid over it, so its mute flag and run-as user are the draft's,
* which can differ from what is deployed. `path` is undefined while nothing is deployed, and
* `current` is undefined then, while loading, and when the lookup fails. */
export function useDeployedItem(
kind: 'script' | 'flow',
workspaceGetter: () => string | undefined,
pathGetter: () => string | undefined
) {
const item = resource(
() => ({ workspace: workspaceGetter(), path: pathGetter() }),
async ({ workspace, path }): Promise<DeployedItem | undefined> => {
if (!workspace || !path) return undefined
try {
return kind === 'flow'
? await FlowService.getFlowByPath({ workspace, path })
: await ScriptService.getScriptByPath({ workspace, path })
} catch {
return undefined
}
}
)
return {
get current() {
return pathGetter() ? item.current : undefined
},
get muted() {
return !!item.current?.ws_error_handler_muted
},
/** Reads the row again, for a deploy to compare against what is deployed right now. */
async refetch() {
return pathGetter() ? await item.refetch() : undefined
},
async toggle() {
const path = pathGetter()
if (!path) return
// A click asks for the opposite of what the switch shows. Without the row the switch
// shows "off" whatever is deployed, and would never show a muted item as muted: read
// the row first, and leave a flag already at the asked value as it is.
const muted = !item.current?.ws_error_handler_muted
const deployed = item.current ?? (await item.refetch())
if (!deployed) {
sendUserToast(`Could not read the deployed ${kind}, try again`, true)
return
}
if (!!deployed.ws_error_handler_muted === muted) return
const next = await toggleWorkspaceErrorHandler(workspaceGetter(), kind, path, !muted)
if (next !== undefined) {
item.mutate({ ...deployed, ws_error_handler_muted: next })
}
}
}
}
@@ -1,5 +1,32 @@
import { FlowService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
import { BellOff } from 'lucide-svelte'
/** The editor menu's switch for muting the item. Once it is deployed the switch writes to the
* deployed version at once and no deploy carries it; before that, the first deploy does. The
* caption says which, since the two look the same in the menu. */
export function muteErrorHandlerMenuItem(opts: {
kind: 'script' | 'flow'
deployed: boolean
muted: boolean
hasEnterpriseLicense: boolean
onToggle: () => void
}): Item {
return {
displayName: 'Mute error handler',
description: opts.deployed
? `Applies right away: failures of the deployed ${opts.kind} stop triggering the workspace error handler`
: `Applies when you deploy: failures of this ${opts.kind} won't trigger the workspace error handler`,
icon: BellOff,
toggle: opts.muted,
// Without a license muting is not on offer, but a muted item can still be unmuted.
disabled: !opts.hasEnterpriseLicense && !opts.muted,
tooltip: opts.hasEnterpriseLicense ? undefined : 'Enterprise Edition only',
separatorTop: true,
action: () => opts.onToggle()
}
}
/** Flips the workspace error handler for one script or flow and reports the outcome in a
* toast. Returns the new muted state, or undefined when nothing changed. The workspace is the
@@ -190,7 +190,6 @@
}),
currentEditor: writable(undefined),
outputPickerOpenFns: {},
preserveOnBehalfOf: writable(false),
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined),
...(outer ?? {}),
@@ -25,8 +25,6 @@
import DedicatedWorkersSetting from '$lib/components/itemSettings/DedicatedWorkersSetting.svelte'
import DeleteAfterUseSetting from '$lib/components/itemSettings/DeleteAfterUseSetting.svelte'
import RunOnBehalfOfSetting from '$lib/components/itemSettings/RunOnBehalfOfSetting.svelte'
import MuteErrorHandlerSetting from '$lib/components/itemSettings/MuteErrorHandlerSetting.svelte'
import type { OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte'
import InvisibleRunsSetting from '$lib/components/itemSettings/InvisibleRunsSetting.svelte'
import SettingIcons from '$lib/components/itemSettings/SettingIcons.svelte'
import ActiveSettingChips, {
@@ -40,11 +38,7 @@
import { modulesWithRetryOrSleep, SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte'
import { earlyReturnCandidates } from '../earlyReturnCandidates'
import Select from '$lib/components/select/Select.svelte'
import {
useOperatingUser,
useOperatingWorkspace
} from '$lib/components/operatingWorkspace.svelte'
import { keepsDeployedOnBehalfOf } from '$lib/components/itemSettings/onBehalfOf'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -65,30 +59,11 @@
previewArgs,
pathStore,
customUi,
preserveOnBehalfOf,
savedOnBehalfOfEmail,
savedOnBehalfOfPermissionedAs,
opWorkspace
} = getContext<FlowEditorContext>('FlowEditorContext')
let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined)
const operatingUser = useOperatingUser()
function keepDeployedOnBehalfOf() {
if (
!keepsDeployedOnBehalfOf(
operatingUser.current,
onBehalfOfChoice,
flowStore.val.on_behalf_of_email,
$savedOnBehalfOfEmail
)
)
return
onBehalfOfChoice = 'target'
flowStore.val.on_behalf_of_email = $savedOnBehalfOfEmail
flowStore.val.on_behalf_of = $savedOnBehalfOfPermissionedAs
$preserveOnBehalfOf = true
}
type FlowSettingsTab = 'metadata' | 'runtime' | 'permissions'
// Other panels open the settings on one setting by selecting `settings-<setting>`; the value
@@ -266,11 +241,7 @@
<!-- The rows, their wording and order are the script Runtime tab's; Flow control is flow-only. -->
<TabContent value="runtime">
<div
class="flex flex-col gap-8 p-4 pb-8"
{@attach scrollToSelectedRow}
{@attach keepDeployedOnBehalfOf}
>
<div class="flex flex-col gap-8 p-4 pb-8" {@attach scrollToSelectedRow}>
{#if customUi?.settingsTabs?.earlyStop != false || customUi?.settingsTabs?.earlyReturn != false}
<SettingGroup label="Flow control" class="flex flex-col gap-6">
{#if customUi?.settingsTabs?.earlyStop != false}
@@ -422,14 +393,6 @@
/>
{/if}
</SettingGroup>
<SettingGroup label="Error handling" class="flex flex-col gap-6">
<MuteErrorHandlerSetting
kind="flow"
path={$pathStore}
bind:muted={flowStore.val.ws_error_handler_muted}
/>
</SettingGroup>
</div>
</TabContent>
@@ -439,8 +402,6 @@
kind="flow"
bind:email={flowStore.val.on_behalf_of_email}
bind:permissionedAs={flowStore.val.on_behalf_of}
bind:preserve={$preserveOnBehalfOf}
bind:choice={onBehalfOfChoice}
savedEmail={$savedOnBehalfOfEmail}
savedPermissionedAs={$savedOnBehalfOfPermissionedAs}
targetWorkspace={opWorkspace?.() ?? $operatingWorkspace ?? ''}
@@ -97,7 +97,6 @@ export type FlowEditorContext = {
executionCount: Writable<number>
modulesTestStates: ModulesTestStates
outputPickerOpenFns: Record<string, () => void>
preserveOnBehalfOf: Writable<boolean>
savedOnBehalfOfEmail: Writable<string | undefined>
savedOnBehalfOfPermissionedAs: Writable<string | undefined>
// Only set by the local dev page (Dev.svelte): path -> temp-storage hash of
@@ -0,0 +1,143 @@
<script lang="ts">
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RunOnBehalfOfSetting from './RunOnBehalfOfSetting.svelte'
import { appOnBehalfOfTooltip, deployedPermissionedAs } from './onBehalfOf'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import { AlertTriangle } from 'lucide-svelte'
interface Props {
open: boolean
kind: 'flow' | 'script' | 'app'
/** The deployment message, carried over from the Deploy popover. */
message: string
/** The item's run-as user, which the picker writes; unset runs as whoever starts each run. */
email: string | undefined
permissionedAs: string | undefined
/** The deployed run-as user, unset before the first deploy or when it ran as its caller. */
savedEmail: string | undefined
savedPermissionedAs: string | undefined
isNew: boolean
/** The folder's default for a new item's path, which the deploy takes when no user is held. */
folderDefault?: string
/** While it loads the dialog can't name who the deploy runs as, so it can't be confirmed. */
folderDefaultLoading?: boolean
targetWorkspace: string
/** Apps: who can open the app, which decides whether viewers are recorded at all. */
executionMode?: string
rawApp?: boolean
onConfirmed: () => void
}
let {
open = $bindable(),
kind,
message = $bindable(),
email = $bindable(),
permissionedAs = $bindable(),
savedEmail,
savedPermissionedAs,
isNew,
folderDefault,
folderDefaultLoading = false,
targetWorkspace,
executionMode,
rawApp = false,
onConfirmed
}: Props = $props()
const operatingUser = useOperatingUser()
const me = $derived(
operatingUser.current?.username ? `u/${operatingUser.current.username}` : undefined
)
const savedUser = $derived(savedPermissionedAs ?? savedEmail)
// The backend gives a new item in a folder with a default user that user unless the deploy
// names one, so only a flow or script outside that case can deploy with the setting off.
const required = $derived(kind === 'app' || !!folderDefault)
const on = $derived(required || !!email)
// A flow or script whose deployed version ran on behalf of someone, turned off by this deploy.
const stops = $derived(!on && !!savedUser)
const runsAs = $derived(
deployedPermissionedAs(operatingUser.current, permissionedAs, folderDefault)
)
const who = $derived(!runsAs ? 'you' : runsAs === me ? `${runsAs} (you)` : runsAs)
const unchanged = $derived(!isNew && !!savedPermissionedAs && runsAs === savedPermissionedAs)
const folderDefaultNotMe = $derived(!!folderDefault && runsAs === folderDefault && runsAs !== me)
let pickerOpen = $state(false)
</script>
<ConfirmationModal
{open}
type="info"
title={on
? `Run this ${kind} on behalf of ${who}?`
: stops
? `Stop running this ${kind} on behalf of ${savedUser}?`
: `Deploy this ${kind}?`}
confirmationText="Deploy"
confirmDisabled={folderDefaultLoading}
keyListen={!pickerOpen}
alwaysPortal
onConfirmed={() => {
open = false
onConfirmed()
}}
onCanceled={() => (open = false)}
>
<div class="flex flex-col gap-4">
<p>
{#if stops}
Runs will use the access of whoever starts them, not <strong>{savedUser}</strong>.
{:else if !on}
Runs will use the access of whoever starts them.
{:else if kind === 'app'}
The app's runnables use the access of <strong>{runsAs ?? 'your user'}</strong> to resources
and variables.
{#if executionMode === 'anonymous'}
Anyone with the secret URL can run them, and runs by visitors who aren't signed in are
recorded as <code>anonymous</code>.
{:else}
Runs are still recorded under the viewer's name, and the code reads their email from
<code>WM_END_USER_EMAIL</code>.
{/if}
{:else}
Every run uses the access of <strong>{runsAs ?? 'your user'}</strong> to resources and
variables. Who started it is still recorded, and the code reads their email from
<code>WM_END_USER_EMAIL</code>.
{/if}
</p>
<div class="flex flex-col gap-1">
<RunOnBehalfOfSetting
{kind}
{required}
tooltip={kind === 'app' ? appOnBehalfOfTooltip(rawApp) : undefined}
bind:email
bind:permissionedAs
{savedEmail}
{savedPermissionedAs}
{targetWorkspace}
{isNew}
{folderDefault}
aboveConfirmationModal
onPickerOpenChange={(o) => (pickerOpen = o)}
hideDescription
/>
{#if on && unchanged}
<!-- pl-9 lines it up under the switch's label, as SettingToggle indents its contents. -->
<div class={['text-2xs text-secondary', !required && 'pl-9']}>
Same as the deployed version.
</div>
{/if}
{#if folderDefaultNotMe}
<div class="flex items-start gap-1.5 text-2xs text-yellow-600 dark:text-yellow-400">
<AlertTriangle size={14} class="shrink-0 text-yellow-500" />
<span>
This folder's default user is <strong>{folderDefault}</strong>, so the {kind} won't run
with your access. Pick yourself above to change that.
</span>
</div>
{/if}
</div>
<TextInput bind:value={message} inputProps={{ placeholder: 'Deployment message' }} />
</div>
</ConfirmationModal>
@@ -1,43 +0,0 @@
<script lang="ts">
import { toggleWorkspaceErrorHandler } from '$lib/components/details/errorHandlerToggle'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { enterpriseLicense } from '$lib/stores'
import SettingToggle from './SettingToggle.svelte'
interface Props {
kind: 'flow' | 'script'
path: string
/** Also sent with the next deploy. */
muted: boolean | undefined
}
let { kind, path, muted = $bindable() }: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
// The toggle shows the click until the request settles, then the muted state it left.
let pending: boolean | undefined = $state(undefined)
async function toggle() {
const next = await toggleWorkspaceErrorHandler($operatingWorkspace, kind, path, muted)
if (next !== undefined) muted = next
pending = undefined
}
</script>
{#snippet eeHint()}
Enterprise Edition only.
{/snippet}
<SettingToggle
setting="mute-error-handler"
label="Mute workspace error handler"
tooltip={`Failures of this ${kind} do not trigger the workspace error handler. Applies to the deployed ${kind} right away.`}
documentationLink="https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler"
checked={pending ?? !!muted}
onchange={(on) => {
pending = on
toggle()
}}
hint={$enterpriseLicense ? undefined : eeHint}
/>
@@ -1,56 +1,95 @@
<script lang="ts">
import Label from '$lib/components/Label.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import OnBehalfOfSelector, {
type OnBehalfOfChoice
} from '$lib/components/OnBehalfOfSelector.svelte'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import { userStore } from '$lib/stores'
import { AlertTriangle } from 'lucide-svelte'
import SettingToggle from './SettingToggle.svelte'
import { canPreserveOnBehalfOf, deployedPermissionedAs, WM_DEPLOYERS_GROUP } from './onBehalfOf'
const DOCS_LINK =
'https://www.windmill.dev/docs/core_concepts/roles_and_permissions#run-on-behalf-of'
interface Props {
kind: 'flow' | 'script'
kind: 'flow' | 'script' | 'app'
/** Email of the user whose access the runs use; undefined uses whoever starts each run. */
email: string | undefined
/** `u/username` of that user. */
permissionedAs: string | undefined
/** Sent as `preserve_on_behalf_of`: keep the picked user rather than the deployer. */
preserve: boolean
/** The selector's choice. Owned by the host, so it outlives this row's remounts: while it is
* undefined the selector picks the deployed user, which would override a later pick. */
choice: OnBehalfOfChoice
/** The deployed identity, which the "target" choice keeps. */
savedEmail: string | undefined
savedPermissionedAs: string | undefined
targetWorkspace: string
/** Apps always use a chosen user's access: no toggle, only the user choice. */
required?: boolean
/** Never deployed, so no deployed identity can change. */
isNew?: boolean
tooltip?: string
/** Inside a `ConfirmationModal`: see `OnBehalfOfSelector`. */
aboveConfirmationModal?: boolean
onPickerOpenChange?: (open: boolean) => void
/** The host explains the setting itself. */
hideDescription?: boolean
/** The folder's default for a new item's path, which a deploy holding no user takes. */
folderDefault?: string
}
let {
kind,
email = $bindable(),
permissionedAs = $bindable(),
preserve = $bindable(),
choice = $bindable(),
savedEmail,
savedPermissionedAs,
targetWorkspace,
isNew = false
required = false,
isNew = false,
tooltip,
aboveConfirmationModal = false,
onPickerOpenChange,
hideDescription = false,
folderDefault
}: Props = $props()
// The label keeps the docs' term; the description says what it gives, access to resources,
// and that the person who starts a run stays recorded and readable from WM_END_USER_EMAIL.
let shownTooltip = $derived(
tooltip ??
`Use it to share a ${kind} that touches secrets: people run it without ever seeing the credentials. Or pick a dedicated user, like billing-bot, so the ${kind} can only reach that user's folders and resources.`
)
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
let canPreserve = $derived(canPreserveOnBehalfOf(actingUser))
let myPermissionedAs = $derived($userStore?.username ? `u/${$userStore.username}` : undefined)
// "Me" is the acting user in the target workspace, its username and its email alike: usernames
// are per-workspace, and the backend rejects a pair that names two accounts.
let myPermissionedAs = $derived(actingUser?.username ? `u/${actingUser.username}` : undefined)
let enabled = $derived(!!email)
// Read from the bound values, not kept here: this row remounts each time its tab or dialog
// opens, and a choice that restarts empty lets the selector reselect the deployed user over
// the one picked.
let choice: OnBehalfOfChoice = $derived.by(() => {
// No user picked deploys the folder default, even over a deployed user: a script renamed
// into the folder is created at its new path.
if (!permissionedAs && !email && folderDefault) return 'custom'
const keepsSaved = savedPermissionedAs
? (permissionedAs ?? savedPermissionedAs) === savedPermissionedAs
: !!savedEmail && (email ?? savedEmail) === savedEmail
if (keepsSaved) return 'target'
const isMe = permissionedAs
? permissionedAs === myPermissionedAs
: !email || email === actingUser?.email
return isMe ? 'me' : 'custom'
})
let enabled = $derived(required || !!email)
let deployPermissionedAs = $derived(
enabled ? deployedPermissionedAs(actingUser, permissionedAs) : undefined
enabled ? deployedPermissionedAs(actingUser, permissionedAs, folderDefault) : undefined
)
// Whoever cannot keep another user's identity deploys the item as themselves.
let chosenUser = $derived(
(canPreserve ? (permissionedAs ?? email) : deployPermissionedAs) ?? 'the chosen user'
(canPreserve ? (permissionedAs ?? email ?? folderDefault) : deployPermissionedAs) ??
'the chosen user'
)
let willChange = $derived(
!isNew &&
@@ -58,59 +97,41 @@
!!deployPermissionedAs &&
deployPermissionedAs !== savedPermissionedAs
)
function setMe() {
email = actingUser?.email
permissionedAs = myPermissionedAs
}
</script>
{#snippet valueHint()}
Lets people run this {kind} with resources and variables they can't access themselves.
{/snippet}
<SettingToggle
setting="on-behalf-of"
label="Run on behalf of"
tooltip={`Use it to share a ${kind} that touches secrets: people run it without ever seeing the credentials. Or pick a dedicated user, like billing-bot, so the ${kind} can only reach that user's folders and resources.`}
documentationLink="https://www.windmill.dev/docs/core_concepts/roles_and_permissions#run-on-behalf-of"
hint={valueHint}
checked={enabled}
onchange={(on) => {
if (on) {
email = $userStore?.email
permissionedAs = myPermissionedAs
} else {
email = undefined
permissionedAs = undefined
preserve = false
choice = undefined
}
}}
>
{#snippet picker()}
<div class="flex flex-wrap items-center gap-1.5">
<OnBehalfOfSelector
{targetWorkspace}
targetValue={savedEmail}
targetValue={savedEmail ?? savedPermissionedAs}
selected={choice}
onSelect={(c, details) => {
choice = c
if (c === 'me') {
email = $userStore?.email
permissionedAs = myPermissionedAs
preserve = false
setMe()
} else if (c === 'target') {
// Keep the saved pair. An item that has no recorded principal yet sends the
// email alone and the backend derives one from it.
email = savedEmail
permissionedAs = savedPermissionedAs
preserve = true
} else if (c === 'custom' && details) {
email = details.email
permissionedAs = details.permissionedAs
preserve = true
}
}}
{kind}
{canPreserve}
customValue={choice === 'custom' ? email : undefined}
customValue={choice === 'custom' ? (permissionedAs ?? email ?? folderDefault) : undefined}
{folderDefault}
isDeployment={false}
{myPermissionedAs}
readOnlyWithoutPreserve
{aboveConfirmationModal}
{onPickerOpenChange}
/>
{#if willChange}
<AlertTriangle size={14} class="text-yellow-500" />
@@ -124,8 +145,57 @@
Only workspace admins and members of {WM_DEPLOYERS_GROUP} can pick another user.
</div>
{/if}
<div class="text-2xs text-secondary">
Every run uses the access of {chosenUser}. Who started it is still recorded, and the code reads
their email from <code>WM_END_USER_EMAIL</code>.
{#if !hideDescription}
<div class="text-2xs text-secondary">
{#if required}
Apps always run on behalf of someone: the people who open an app usually can't access its
scripts and resources, and guest or public viewers have no account at all. Viewers can start
only the runnables built into the app, and each one still gets the viewer's email in
<code>WM_END_USER_EMAIL</code>.
{:else}
Every run uses the access of {chosenUser}. Who started it is still recorded, and the code
reads their email from <code>WM_END_USER_EMAIL</code>.
{/if}
</div>
{/if}
{/snippet}
{#snippet valueHint()}
Lets people run this {kind} with resources and variables they can't access themselves.
{/snippet}
{#if required}
<div data-setting="on-behalf-of">
<Label label="Run on behalf of">
{#snippet header()}
<!-- The tooltip also renders an empty trigger span; one wrapper keeps the label gap single. -->
<span class="inline-flex">
<Tooltip documentationLink={DOCS_LINK}>
{#snippet text()}{shownTooltip}{/snippet}
</Tooltip>
</span>
{/snippet}
{@render picker()}
</Label>
</div>
</SettingToggle>
{:else}
<!-- The toggle's tooltip renders under a ConfirmationModal, whose host explains the setting. -->
<SettingToggle
setting="on-behalf-of"
label="Run on behalf of"
tooltip={aboveConfirmationModal ? undefined : shownTooltip}
documentationLink={aboveConfirmationModal ? undefined : DOCS_LINK}
hint={hideDescription ? undefined : valueHint}
checked={enabled}
onchange={(on) => {
if (on) {
setMe()
} else {
email = undefined
permissionedAs = undefined
}
}}
>
{@render picker()}
</SettingToggle>
{/if}
@@ -1,25 +1,49 @@
import { describe, it, expect } from 'vitest'
import { deployedPermissionedAs, keepsDeployedOnBehalfOf } from './onBehalfOf'
import { deployAsksOnBehalfOf, deployedPermissionedAs } from './onBehalfOf'
// Who a deploy leaves the item running on behalf of: only admins and wm_deployers keep another user.
// The deploy dialog names who the backend will leave the item running on behalf of, and asks only
// when that differs from the deployed version.
describe('onBehalfOf', () => {
const admin = { username: 'admin', is_admin: true }
const admin = { username: 'admin', email: 'admin@x.dev', is_admin: true }
const deployer = { username: 'dep', groups: ['wm_deployers'] }
const member = { username: 'alice' }
const member = { username: 'alice', email: 'alice@x.dev' }
it('keeps the held user only for a deployer who may preserve', () => {
expect(deployedPermissionedAs(admin, 'u/bot')).toBe('u/bot')
expect(deployedPermissionedAs(deployer, 'u/bot')).toBe('u/bot')
expect(deployedPermissionedAs(admin, undefined)).toBe('u/admin')
expect(deployedPermissionedAs(member, 'u/bot')).toBe('u/alice')
it('keeps the held user, then the folder default, only for a deployer who may preserve', () => {
expect(deployedPermissionedAs(admin, 'u/bot', 'u/folder')).toBe('u/bot')
expect(deployedPermissionedAs(deployer, undefined, 'u/folder')).toBe('u/folder')
expect(deployedPermissionedAs(admin, undefined, undefined)).toBe('u/admin')
expect(deployedPermissionedAs(member, 'u/bot', 'u/folder')).toBe('u/alice')
})
it('picks the deployed user only when nothing is picked and run-as is on', () => {
expect(keepsDeployedOnBehalfOf(admin, undefined, 'bot@x.dev', 'bot@x.dev')).toBe(true)
expect(keepsDeployedOnBehalfOf(deployer, undefined, 'me@x.dev', 'bot@x.dev')).toBe(true)
expect(keepsDeployedOnBehalfOf(admin, 'me', 'me@x.dev', 'bot@x.dev')).toBe(false)
expect(keepsDeployedOnBehalfOf(admin, undefined, undefined, 'bot@x.dev')).toBe(false)
expect(keepsDeployedOnBehalfOf(admin, undefined, 'me@x.dev', undefined)).toBe(false)
expect(keepsDeployedOnBehalfOf(member, undefined, 'bot@x.dev', 'bot@x.dev')).toBe(false)
it('asks on every app deploy, and on flow or script deploys that change the run-as user', () => {
const none = {
email: undefined,
permissionedAs: undefined,
savedEmail: undefined,
savedPermissionedAs: undefined
}
const bot = { email: 'bot@x.dev', permissionedAs: 'u/bot' }
const deployedAsBot = { savedEmail: 'bot@x.dev', savedPermissionedAs: 'u/bot' }
expect(deployAsksOnBehalfOf('app', { user: admin, ...none })).toBe(true)
expect(deployAsksOnBehalfOf('flow', { user: admin, ...none })).toBe(false)
// A first deploy, or one that turns it on.
expect(deployAsksOnBehalfOf('script', { user: admin, ...none, ...bot })).toBe(true)
expect(deployAsksOnBehalfOf('flow', { user: admin, ...none, folderDefault: 'u/f' })).toBe(true)
// Same user as deployed.
expect(deployAsksOnBehalfOf('flow', { user: admin, ...bot, ...deployedAsBot })).toBe(false)
// Stops.
expect(deployAsksOnBehalfOf('flow', { user: admin, ...none, ...deployedAsBot })).toBe(true)
// A script renamed into a folder takes its default over the deployed user.
expect(
deployAsksOnBehalfOf('script', {
user: admin,
...none,
...deployedAsBot,
folderDefault: 'u/f'
})
).toBe(true)
// The backend replaces the user with a deployer who may not preserve it.
expect(deployAsksOnBehalfOf('flow', { user: member, ...bot, ...deployedAsBot })).toBe(true)
})
})
@@ -20,24 +20,46 @@ export function canPreserveOnBehalfOf(user: DeployingUser): boolean {
)
}
/** Whether the deployed user should be picked, as the run-as selector does once it shows with
* nothing picked. The Runtime tab applies it too, so a deploy that only changes a runtime
* setting keeps sending `preserve_on_behalf_of`. */
export function keepsDeployedOnBehalfOf(
user: DeployingUser,
choice: string | undefined,
email: string | undefined,
savedEmail: string | undefined
): boolean {
return choice === undefined && !!email && !!savedEmail && canPreserveOnBehalfOf(user)
}
/** The `u/username` a deploy leaves the item running on behalf of: the held user for a deployer
* who may preserve, the deployer for anyone else. */
/** The `u/username` a deploy leaves the item running on behalf of. The editors send the user their
* deploy dialog confirmed, which the backend keeps for a deployer who may preserve and replaces
* with the deployer for anyone else. With no user held, a create takes the folder's default for
* the path. */
export function deployedPermissionedAs(
user: DeployingUser,
permissionedAs: string | undefined
permissionedAs: string | undefined,
folderDefault?: string
): string | undefined {
if (permissionedAs && canPreserveOnBehalfOf(user)) return permissionedAs
const kept = permissionedAs ?? folderDefault
if (kept && canPreserveOnBehalfOf(user)) return kept
return user?.username ? `u/${user.username}` : undefined
}
/** Whether a deploy asks first whose access the item will run with. Every app deploy asks, since
* all its viewers use that access. A flow or script deploy asks when it starts running on behalf
* of someone, changes who, or stops: a deploy that keeps the deployed user has nothing new to
* confirm. `saved*` are the deployed version's, unset before the first deploy. */
export function deployAsksOnBehalfOf(
kind: 'flow' | 'script' | 'app',
deploy: {
user: DeployingUser
email: string | undefined
permissionedAs: string | undefined
savedEmail: string | undefined
savedPermissionedAs: string | undefined
folderDefault?: string
}
): boolean {
if (kind === 'app') return true
const deployedHoldsUser = !!(deploy.savedPermissionedAs ?? deploy.savedEmail)
if (!deploy.email && !deploy.folderDefault) return deployedHoldsUser
if (!deployedHoldsUser) return true
return (
deployedPermissionedAs(deploy.user, deploy.permissionedAs, deploy.folderDefault) !==
deploy.savedPermissionedAs
)
}
/** What keeps lending the publisher's access to an app's viewers safe. */
export function appOnBehalfOfTooltip(rawApp: boolean): string {
return `To keep this safe, the app can call only the runnables it was deployed with, and viewers can't override their static inputs.${rawApp ? " Inline runnables can instead be set to read data tables and object storage with the viewer's own access." : ''}`
}
@@ -1,16 +1,19 @@
import { FolderService } from '$lib/gen'
import { Minimatch } from 'minimatch'
import { resource } from 'runed'
import { workspaceStore, userStore } from '$lib/stores'
import { fromStore } from 'svelte/store'
import { workspaceStore } from '$lib/stores'
import { useActingUser } from '$lib/actingUser.svelte'
import { canPreserveOnBehalfOf } from './itemSettings/onBehalfOf'
/**
* Resolves the folder's `default_permissioned_as` rule that matches a given item path.
* Returns `undefined` when the user is not admin/wm_deployer, the path is not under a
* folder, the folder has no matching rules, or the fetch fails.
*
* Tracks workspace/user store changes reactively so the value refreshes on workspace
* switch or permission change. Uses `runed`'s `resource()` which handles race conditions
* and loading state automatically.
* The rights are the acting user's in the resolved workspace, as the backend checks them there:
* `$userStore` answers for the navigation workspace only. `loading` holds while that user is
* still being looked up. Call during component initialisation.
*/
export function useFolderDefaultPermissionedAs(
pathGetter: () => string | undefined,
@@ -18,25 +21,14 @@ export function useFolderDefaultPermissionedAs(
// a forked session resolves the folder default from its own workspace.
wsGetter?: () => string | undefined
) {
// Subscribe to the stores so the effect re-runs on changes.
let navWorkspace = $state<string | undefined>(undefined)
let user = $state<any>(undefined)
$effect(() => {
const unsubWs = workspaceStore.subscribe((w) => (navWorkspace = w))
const unsubUser = userStore.subscribe((u) => (user = u))
return () => {
unsubWs()
unsubUser()
}
})
const navWorkspace = fromStore(workspaceStore)
const workspace = () => wsGetter?.() ?? navWorkspace.current
const acting = useActingUser(workspace)
const folderResource = resource(
() => ({ path: pathGetter(), workspace: wsGetter?.() ?? navWorkspace, user }),
() => ({ path: pathGetter(), workspace: workspace(), user: acting.current }),
async ({ path, workspace, user }) => {
const canPreserve =
user?.is_admin || user?.is_super_admin || (user?.groups ?? []).includes('wm_deployers')
if (!canPreserve || !workspace || !path?.startsWith('f/')) {
if (!canPreserveOnBehalfOf(user) || !workspace || !path?.startsWith('f/')) {
return undefined
}
const parts = path.split('/')
@@ -67,7 +59,11 @@ export function useFolderDefaultPermissionedAs(
return folderResource.current
},
get loading() {
return folderResource.loading
const ws = workspace()
return (
folderResource.loading ||
(!!ws && !!pathGetter()?.startsWith('f/') && !acting.resolved(ws))
)
}
}
}
@@ -143,6 +143,40 @@ describe('deployDraft preserves on_behalf_of', () => {
})
})
// Muting writes to the deployed row at once, so a draft saved before it holds a stale copy, and
// the backend unmutes on a deploy that sends `false`.
describe('deployDraft keeps the deployed mute flag', () => {
beforeEach(() => vi.clearAllMocks())
const muted = (call: any) => call[0].requestBody.ws_error_handler_muted
it('flow and script: sends the deployed flag over a stale draft', async () => {
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
ws_error_handler_muted: true,
draft: { path: 'f/ops/sync', value: {}, ws_error_handler_muted: false }
} as any)
await deployDraft('flow', 'f/ops/sync', 'ws')
expect(muted(vi.mocked(FlowService.updateFlow).mock.calls[0])).toBe(true)
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
hash: 'v1',
ws_error_handler_muted: true,
draft: { path: 'f/ops/job', ws_error_handler_muted: false }
} as any)
await deployDraft('script', 'f/ops/job', 'ws')
expect(muted(vi.mocked(ScriptService.createScript).mock.calls[0])).toBe(true)
})
it('draft-only flow: sends the flag the draft was muted with', async () => {
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
no_deployed: true,
draft: { path: 'f/ops/new', value: {}, ws_error_handler_muted: true }
} as any)
await deployDraft('flow', 'f/ops/new', 'ws', { draftOnly: true })
expect(muted(vi.mocked(FlowService.createFlow).mock.calls[0])).toBe(true)
})
})
// The deploy bodies are built field by field, so a forgotten field is dropped silently.
describe('deployDraft variable value_valid_until', () => {
beforeEach(() => vi.clearAllMocks())
+8 -1
View File
@@ -505,6 +505,10 @@ export async function deployDraft(
...rest,
path: scriptPath,
parent_hash: r.hash,
// The deployed script's: muting applies to it at once, so a draft's copy is stale.
// Before the first deploy the draft's is the only one.
ws_error_handler_muted:
draftOnly || r.no_deployed ? rest.ws_error_handler_muted : r.ws_error_handler_muted,
deployment_message: deploymentMessage,
// Deploy the draft's on-behalf-of as-is; the backend resets it to the
// deploying user without this flag, gated by can_preserve_on_behalf_of.
@@ -531,7 +535,10 @@ export async function deployDraft(
schema: d.schema,
tag: d.tag,
dedicated_worker: d.dedicated_worker,
ws_error_handler_muted: d.ws_error_handler_muted,
// The deployed flow's: muting applies to it at once, so a draft's copy is stale.
// Before the first deploy the draft's is the only one.
ws_error_handler_muted:
draftOnly || r.no_deployed ? d.ws_error_handler_muted : r.ws_error_handler_muted,
visible_to_runner_only: d.visible_to_runner_only,
on_behalf_of_email: d.on_behalf_of_email,
on_behalf_of: d.on_behalf_of,
@@ -118,7 +118,6 @@
currentEditor: writable(undefined),
modulesTestStates: new ModulesTestStates(),
outputPickerOpenFns: {},
preserveOnBehalfOf: writable(false),
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined)
})