feat: change on behalf selector to allow picking any user + select value in target by default if possible (#8113)

* Make modal for on behalf of selector

* Auto-select target

* Show name of selected OnBehalfOfSelector

* Fix frontend check
This commit is contained in:
wendrul
2026-02-26 14:02:11 +00:00
committed by GitHub
parent 6b8ae5578d
commit 82a574a980
3 changed files with 197 additions and 76 deletions
@@ -40,7 +40,7 @@
import DiffDrawer from './DiffDrawer.svelte'
import DeployWorkspaceDrawer from './DeployWorkspaceDrawer.svelte'
import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import type { Kind } from '$lib/utils_deployable'
import { deployItem, getItemValue, getOnBehalfOfEmail } from '$lib/utils_workspace_deploy'
@@ -119,6 +119,8 @@
// Source workspace on_behalf_of emails (keyed by workspace/kind:path)
let onBehalfOfInfo = $state<Record<string, string | undefined>>({})
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
let customOnBehalfOfEmails = $state<Record<string, string>>({})
let deployTargetWorkspace = $derived(mergeIntoParent ? parentWorkspaceId : currentWorkspaceId)
function getItemKey(diff: WorkspaceItemDiff): string {
return `${diff.kind}:${diff.path}`
@@ -210,14 +212,9 @@
return onBehalfOfInfo[getWorkspacedKey(targetWorkspace, itemKey)]
}
// Check if an item needs on_behalf_of selection (more than 1 unique option)
// Check if an item needs on_behalf_of selection
function itemNeedsOnBehalfOfSelection(itemKey: string, kind: string): boolean {
return needsOnBehalfOfSelection(
kind,
getSourceEmail(itemKey),
getTargetEmail(itemKey),
$userStore?.email
)
return needsOnBehalfOfSelection(kind, getSourceEmail(itemKey))
}
// Check if all required on_behalf_of selections are made
@@ -234,8 +231,8 @@
// Get the email to use for deployment based on user's choice
function getOnBehalfOfEmailForDeploy(itemKey: string): string | undefined {
const choice = onBehalfOfChoice[itemKey]
if (choice === 'source') return getSourceEmail(itemKey)
if (choice === 'target') return getTargetEmail(itemKey)
if (choice === 'custom') return customOnBehalfOfEmails[itemKey]
// 'me' or undefined = don't pass, backend will use deploying user's email
return undefined
}
@@ -868,7 +865,6 @@
{#snippet itemActions(item)}
{@const diff = item.diff as WorkspaceItemDiff}
{@const key = item.key}
{@const sourceEmail = getSourceEmail(key)}
{@const targetEmail = getTargetEmail(key)}
{@const isConflict = diff.ahead > 0 && diff.behind > 0}
{@const existsInBothWorkspaces = !(
@@ -878,12 +874,16 @@
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
<OnBehalfOfSelector
{sourceEmail}
targetWorkspace={deployTargetWorkspace}
{targetEmail}
selected={onBehalfOfChoice[key]}
onSelect={(choice) => (onBehalfOfChoice[key] = choice)}
onSelect={(choice, email) => {
onBehalfOfChoice[key] = choice
if (email) customOnBehalfOfEmails[key] = email
}}
kind={diff.kind}
canPreserve={canPreserveOnBehalfOf}
customEmail={customOnBehalfOfEmails[key]}
/>
{/if}
<!-- Status badges -->
@@ -1,7 +1,7 @@
<script lang="ts">
import { createEventDispatcher, untrack } from 'svelte'
import { base } from '$lib/base'
import { enterpriseLicense, superadmin, userStore, workspaceStore } from '$lib/stores'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import {
AppService,
FlowService,
@@ -77,24 +77,19 @@
// Target workspace on_behalf_of emails (keyed by kind:path)
let targetOnBehalfOfInfo = $state<Record<string, string | undefined>>({})
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
let customOnBehalfOfEmails = $state<Record<string, string>>({})
let canPreserveOnBehalfOf = $state(false)
// Check if an item needs on_behalf_of selection (more than 1 unique option)
// Check if an item needs on_behalf_of selection
function itemNeedsOnBehalfOfSelection(statusPath: string, kind: string): boolean {
const myIdentity = kind === 'trigger' ? $userStore?.username : $userStore?.email
return needsOnBehalfOfSelection(
kind,
sourceOnBehalfOfInfo[statusPath],
targetOnBehalfOfInfo[statusPath],
myIdentity
)
return needsOnBehalfOfSelection(kind, sourceOnBehalfOfInfo[statusPath])
}
// Get the email to use for deployment based on user's choice
function getOnBehalfOfEmailForDeploy(statusPath: string): string | undefined {
const choice = onBehalfOfChoice[statusPath]
if (choice === 'source') return sourceOnBehalfOfInfo[statusPath]
if (choice === 'target') return targetOnBehalfOfInfo[statusPath]
if (choice === 'custom') return customOnBehalfOfEmails[statusPath]
// 'me' or undefined = don't pass, backend will use deploying user's email
return undefined
}
@@ -465,18 +460,21 @@
{@const statusPath = item.key}
{@const exists = allAlreadyExists[statusPath]}
{@const status = deploymentStatus[statusPath]}
{@const sourceEmail = sourceOnBehalfOfInfo[statusPath]}
{@const targetEmail = targetOnBehalfOfInfo[statusPath]}
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(statusPath, item.kind)}
<OnBehalfOfSelector
{sourceEmail}
targetWorkspace={workspaceToDeployTo!}
{targetEmail}
selected={onBehalfOfChoice[statusPath]}
onSelect={(choice) => (onBehalfOfChoice[statusPath] = choice)}
onSelect={(choice, email) => {
onBehalfOfChoice[statusPath] = choice
if (email) customOnBehalfOfEmails[statusPath] = email
}}
kind={item.kind}
canPreserve={canPreserveOnBehalfOf}
customEmail={customOnBehalfOfEmails[statusPath]}
/>
{/if}
@@ -1,94 +1,217 @@
<script lang="ts" module>
export type OnBehalfOfChoice = 'source' | 'target' | 'me' | undefined
export type OnBehalfOfChoice = 'target' | 'me' | 'custom' | undefined
/**
* Check if an item needs on_behalf_of selection (more than 1 unique option available)
* Check if an item needs on_behalf_of selection.
* Shows the selector when the source item has an on_behalf_of_email set.
*/
export function needsOnBehalfOfSelection(
kind: string,
sourceEmail: string | undefined,
targetEmail: string | undefined,
myEmail: string | undefined
sourceEmail: string | undefined
): boolean {
if (kind !== 'flow' && kind !== 'script' && kind !== 'app' && kind !== 'trigger') return false
// Don't show if no on_behalf_of is set in source
if (!sourceEmail) return false
// Count unique options: source, target (even if undefined counts as different), me
const options = new Set([sourceEmail, myEmail])
// Target is a unique option if it differs from source (including undefined != defined)
if (targetEmail !== sourceEmail) {
options.add(targetEmail ?? '__not_set__')
}
// Show if more than 1 unique option
return options.size > 1
return !!sourceEmail
}
</script>
<script lang="ts">
import { Check, UserCog } from 'lucide-svelte'
import { Check, UserCog, Users, ExternalLink } from 'lucide-svelte'
import MeltPopover from './meltComponents/Popover.svelte'
import Modal from './common/modal/Modal.svelte'
import { userStore } from '$lib/stores'
import { UserService, type User } from '$lib/gen'
import TextInput from './text_input/TextInput.svelte'
interface Props {
sourceEmail: string | undefined
targetWorkspace: string
targetEmail: string | undefined
selected: OnBehalfOfChoice
onSelect: (choice: OnBehalfOfChoice) => void
onSelect: (choice: OnBehalfOfChoice, email?: string) => void
kind: string
canPreserve: boolean
/** The email of the custom-selected user (for display) */
customEmail?: string | undefined
}
let { sourceEmail, targetEmail, selected, onSelect, kind, canPreserve }: Props = $props()
let { targetWorkspace, targetEmail, selected, onSelect, kind, canPreserve, customEmail }: Props =
$props()
let label = $derived(
kind === 'trigger'
? 'Set the user this will be recorded as edited by:'
: 'Set the user this will be run on behalf of:'
)
let users = $state<User[]>([])
let usersLoaded = $state(false)
let modalOpen = $state(false)
let searchQuery = $state('')
async function loadUsers() {
if (usersLoaded) return
try {
users = await UserService.listUsers({ workspace: targetWorkspace })
} catch {
users = []
}
usersLoaded = true
}
// Fetch users eagerly so we can resolve usernames for display
loadUsers()
function resolveUsername(email: string | undefined): string | undefined {
if (!email) return undefined
return users.find((u) => u.email === email)?.username ?? email
}
let targetUsername = $derived(resolveUsername(targetEmail))
let customUsername = $derived(resolveUsername(customEmail))
let activeUsers = $derived(users.filter((u) => !u.disabled))
let filteredUsers = $derived(
searchQuery
? activeUsers.filter((u) => {
const q = searchQuery.toLowerCase()
return (
u.username.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.name?.toLowerCase()?.includes(q) ?? false)
)
})
: activeUsers
)
// Preselect "target" when available and user has permission to preserve
$effect(() => {
if (selected === undefined && targetEmail && canPreserve) {
onSelect('target')
}
})
function openModal() {
loadUsers()
searchQuery = ''
modalOpen = true
}
function selectUser(user: User) {
onSelect('custom', user.email)
modalOpen = false
}
let selectedDisplayName = $derived.by(() => {
if (selected === 'target') return targetUsername
if (selected === 'me') return $userStore?.username
if (selected === 'custom') return customUsername
return undefined
})
</script>
<MeltPopover placement="bottom">
<MeltPopover placement="bottom" on:openChange={(e) => e.detail && loadUsers()}>
<svelte:fragment slot="trigger">
<UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" />
<span class="inline-flex items-center gap-1">
<UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" />
{#if selectedDisplayName}
<span class="text-xs truncate max-w-24">{selectedDisplayName}</span>
{/if}
</span>
</svelte:fragment>
<div slot="content" class="p-3 flex flex-col gap-2 min-w-48">
<div slot="content" let:close={closePopover} class="p-3 flex flex-col gap-2 min-w-48">
<div class="text-xs font-medium text-secondary mb-1">{label}</div>
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => onSelect('source')}
>
<Check class="w-3 h-3 {selected === 'source' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{sourceEmail}</span>
<span class="text-xs text-tertiary">(source)</span>
</button>
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve || !targetEmail
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve || !targetEmail}
onclick={() => onSelect('target')}
>
<Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40 {!targetEmail ? 'italic text-tertiary' : ''}"
>{targetEmail ?? 'unknown'}</span
<!-- Target option -->
{#if targetEmail}
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => onSelect('target')}
>
<span class="text-xs text-tertiary">(target)</span>
</button>
<Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40">{targetUsername}</span>
<span class="text-xs text-tertiary">(target)</span>
</button>
{/if}
<!-- Me option -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover"
onclick={() => onSelect('me')}
>
<Check class="w-3 h-3 {selected === 'me' ? 'opacity-100' : 'opacity-0'}" />
<span class="truncate max-w-40"
>{kind === 'trigger' ? $userStore?.username : $userStore?.email}</span
>
<span class="truncate max-w-40">{$userStore?.username}</span>
<span class="text-xs text-tertiary">(me)</span>
</button>
<!-- Custom / Pick from workspace -->
<button
class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve
? 'opacity-50 cursor-not-allowed'
: ''}"
disabled={!canPreserve}
onclick={() => {
closePopover()
openModal()
}}
>
{#if selected === 'custom' && customUsername}
<Check class="w-3 h-3 opacity-100" />
<span class="truncate max-w-40">{customUsername}</span>
<span class="text-xs text-tertiary">(custom)</span>
{:else}
<Check class="w-3 h-3 opacity-0" />
<Users class="w-3 h-3 text-tertiary" />
<span>Pick from workspace&hellip;</span>
{/if}
</button>
</div>
</MeltPopover>
<!-- User selection modal -->
<Modal title="Select a user" bind:open={modalOpen} kind="X">
<div class="flex flex-col gap-4">
<div class="text-xs text-secondary">
{#if kind === 'trigger'}
Choose the user this trigger will be recorded as edited by in the target workspace.
{:else}
Choose the user this {kind} will run on behalf of in the target workspace. The selected
user's permissions will be used when executing.
{/if}
<a
href="https://www.windmill.dev/docs/core_concepts/roles_and_permissions"
target="_blank"
rel="noopener noreferrer"
class="text-blue-500 hover:underline inline-flex items-center gap-0.5"
>
Learn more
<ExternalLink class="w-3 h-3" />
</a>
</div>
<TextInput bind:value={searchQuery} inputProps={{ placeholder: 'Search users...' }} />
<div class="max-h-60 overflow-y-auto border rounded">
{#each filteredUsers as user (user.email)}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-left text-sm hover:bg-surface-hover border-b last:border-b-0"
onclick={() => selectUser(user)}
>
<div class="flex flex-col min-w-0">
<span class="font-medium truncate">{user.username}</span>
<span class="text-xs text-tertiary truncate">{user.email}</span>
</div>
{#if customEmail === user.email && selected === 'custom'}
<Check class="w-4 h-4 text-green-500 ml-auto flex-shrink-0" />
{/if}
</button>
{:else}
<div class="px-3 py-4 text-sm text-tertiary text-center">
{#if !usersLoaded}
Loading users&hellip;
{:else}
No users found
{/if}
</div>
{/each}
</div>
</div>
</Modal>