mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 08:02:43 +00:00
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:
co-authored by
Claude Opus 5.5
parent
07d4f7fe61
commit
75e3e7ea41
@@ -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,
|
||||
|
||||
@@ -711,7 +711,6 @@
|
||||
currentEditor: writable(undefined),
|
||||
modulesTestStates,
|
||||
outputPickerOpenFns,
|
||||
preserveOnBehalfOf: writable(false),
|
||||
savedOnBehalfOfEmail: writable<string | undefined>(undefined),
|
||||
savedOnBehalfOfPermissionedAs: writable<string | undefined>(undefined),
|
||||
devTempScriptRefs: () => flowTempScriptRefs
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user