mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
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:
@@ -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…</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…
|
||||
{:else}
|
||||
No users found
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user