Files
windmill/frontend/src/lib/components/VariableForm.svelte
T
6186a0645d feat: edit variables, resources and triggers in their own session tab (#11206)
* feat: edit variables, resources and triggers in their own session tab

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

* fix: take a trigger's flow kind from its config and name the written trigger

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

* refactor: one operating-workspace context for editors acting on a session's workspace

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

* fix: reload page item tabs only in sessions acting on the tool's workspace

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

* fix: resolve open in workspace for every preview tab kind in one place

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

* fix: guard every component under a session editor against the navigation workspace

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

* fix: show a page item tab whose item is gone instead of mounting its editor

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

* fix: follow a saved page item in the tab's own workspace and restore in-frame rows

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

* feat: close a page item tab from its editor header

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

* fix: judge trigger permissions by the user acting in the session workspace

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

* fix: judge every session-editor permission by the user acting in that workspace

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

* fix: keep session permissions reactive to the acting role and restore tabs as last seen

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

* fix: default Path's owner controls to the user acting in its workspace

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-19 23:29:14 +02:00

217 lines
7.2 KiB
Svelte

<script lang="ts">
import Path from './Path.svelte'
import LabelsInput from './LabelsInput.svelte'
import Toggle from './Toggle.svelte'
import Alert from './common/alert/Alert.svelte'
import { Button } from './common'
import Tooltip from './Tooltip.svelte'
import Label from './Label.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Loader2, RotateCcw } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { type UserExt } from '$lib/stores'
import { isOwner } from '$lib/utils'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import EncryptedDraftField from './EncryptedDraftField.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Variable {
value: string
is_secret: boolean
description: string
}
interface Props {
path: string
initialPath: string
pathError: string
variable: Variable
labels: string[] | undefined
wsSpecific: boolean
deployTo: string | undefined
can_write: boolean
edit: boolean
onLoadSecret?: () => void
/** Workspace the path is validated against; defaults to the nav workspace. */
workspace?: string | undefined
/** The user acting in `workspace`, resolved by the editor above. `undefined` while
* `null` while that lookup is pending or after it failed: every check below then
* refuses, rather than answering with the navigation user's rights in another
* workspace. */
actingUser: UserExt | null
}
let {
path = $bindable(),
initialPath,
pathError = $bindable(),
variable = $bindable(),
labels = $bindable(),
wsSpecific = $bindable(),
deployTo,
can_write,
edit,
onLoadSecret,
workspace = undefined,
actingUser
}: Props = $props()
let ws = $derived(workspace ?? $operatingWorkspace)
// Loading the deployed secret overwrites the draft row this form shares with the AI
// chat, so every path that would trigger it has to be blocked while that row stages a
// value — otherwise the staged one is replaced and the next deploy carries the old one.
// '' is the sentinel for "stages nothing", matching the deploy bodies.
let hasStagedValue = $derived(variable.value !== '')
const MAX_VARIABLE_LENGTH = 10000
let editorKind: 'plain' | 'json' | 'yaml' = $state('plain')
let editor: any = $state(undefined)
export function setCode(value: string) {
editor?.setCode(value)
}
</script>
<div class="flex flex-col gap-1">
<label for="path" class="text-xs font-semibold text-emphasis">Path</label>
<Path
disabled={initialPath != '' && !isOwner(initialPath, actingUser ?? undefined, ws)}
bind:error={pathError}
bind:path
{initialPath}
namePlaceholder="variable"
kind="variable"
workspaceOverride={workspace}
{actingUser}
/>
<LabelsInput bind:labels />
</div>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Secret</span>
<!-- An `$encrypted:` value is only redeemable while the variable stays secret — the
deploy endpoints decrypt the marker inside their `is_secret` branch and store it
verbatim otherwise — so un-securing one has to be unreachable until it is Reset. -->
<Toggle
on:change={() => edit && !hasStagedValue && onLoadSecret?.()}
bind:checked={variable.is_secret}
disabled={edit && (!actingUser || actingUser.operator || isEncryptedDraftValue(variable.value))}
/>
{#if variable.is_secret}
<Alert type="info" title="Audit log for each access">
Every secret is encrypted at rest and in transit with a key specific to this workspace. In
addition, any read of a secret variable generates an audit log whose operation name is:
variables.decrypt_secret
</Alert>
{/if}
</label>
{#if deployTo}
<Label
label="Workspace specific"
tooltip="Prevents this variable from being deployed to prod/staging. May have been enabled automatically because a workspace-specific resource references this variable via $var:. Disabling this toggle does not retroactively un-mark the resource that referenced it."
>
<Toggle bind:checked={wsSpecific} />
</Label>
{/if}
<div class="flex flex-col gap-1">
<label for="variable-value" class="flex flex-row justify-left items-center">
<span class="text-xs font-semibold text-emphasis">Variable value&nbsp;</span>
{#if !isEncryptedDraftValue(variable.value)}
<span class="text-xs text-secondary font-normal">
({variable.value.length}/{MAX_VARIABLE_LENGTH} characters)
</span>
{/if}
{#if edit && variable.is_secret}
<div class="ml-3"></div>
{#if hasStagedValue}
<!-- Clearing the staged value is the only way back to loading the deployed one;
see `hasStagedValue`. An `$encrypted:` value additionally cannot be displayed. -->
<Button
size="xs"
variant="default"
startIcon={{ icon: RotateCcw }}
disabled={!can_write}
on:click={() => (variable.value = '')}
>
Reset
</Button>
{:else if actingUser?.operator}
<div class="p-2 border">Operators cannot load secret value</div>
{:else}
<Button size="xs" variant="default" on:click={() => onLoadSecret?.()}>
Load secret value<Tooltip>Will generate an audit log</Tooltip>
</Button>
{/if}
{/if}
</label>
<div>
{#if isEncryptedDraftValue(variable.value)}
<!-- The draft's secret value was encrypted server-side and can't be
loaded back. Saving deploys the last saved value as-is; Reset clears
it so a new secret can be typed. -->
<EncryptedDraftField disabled={!can_write} onReset={() => (variable.value = '')} />
{:else}
<div class="flex flex-col gap-2">
<ToggleButtonGroup bind:selected={editorKind}>
{#snippet children({ item })}
<ToggleButton value="plain" label="Plain" {item} />
<ToggleButton value="json" label="Json" {item} />
<ToggleButton value="yaml" label="YAML" {item} />
{/snippet}
</ToggleButtonGroup>
{#if editorKind == 'plain'}
<textarea
disabled={!can_write}
rows="4"
use:autosize
bind:value={variable.value}
placeholder="Update variable value"
id="variable-value"
></textarea>
{:else if editorKind == 'json'}
<div class="border rounded mb-4 w-full">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={editor}
autoHeight
lang="json"
bind:code={variable.value}
fixedOverflowWidgets={false}
class="bg-surface-tertiary"
/>
{/await}
</div>
{:else if editorKind == 'yaml'}
<div class="border rounded mb-4 w-full">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={editor}
autoHeight
lang="yaml"
bind:code={variable.value}
fixedOverflowWidgets={false}
class="bg-surface-tertiary"
/>
{/await}
</div>
{/if}
</div>
{/if}
</div>
</div>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Description</span>
<textarea rows="4" use:autosize bind:value={variable.description} placeholder="Used for X"
></textarea>
</label>