Files
windmill/frontend/src/lib/components/VariableForm.svelte
T
Diego ImbertandClaude Opus 5.5 da5c58de2a fix: keep secret variable values hidden when toggling secret (#11383)
* fix: keep secret variable values hidden when toggling secret off and on

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

* fix: clarify the secret unlock hint and hide it from read-only users

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

* fix: name the secret toggle and skip the load lock on draft-only variables

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

* fix: name the secret toggle with aria-label so its heading does not flip it

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

* fix: re-hide a cleared secret value when turning secret back on

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:39:10 +02:00

239 lines
8.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
/** Fires after the user flips the Secret toggle, with its new state. */
onSecretChange?: (isSecret: boolean) => void
/** Whether the deployed variable being edited is secret. */
deployedSecret?: boolean
/** 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,
onSecretChange,
deployedSecret = false,
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 !== '')
// Un-securing has to send the plaintext, and fetching it silently on the toggle would
// reveal the secret without the explicit, audited "Load secret value" action.
let unsecureNeedsLoad = $derived(edit && deployedSecret && variable.is_secret && !hasStagedValue)
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>
<!-- No <label> for the heading: clicking it would flip the toggle, and a wrapping one would
stretch the hit area over the whole row and the alert. The toggle carries its name instead. -->
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Secret</span>
<div class="flex flex-row items-center gap-2">
<!-- 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
ariaLabel="Secret"
class="w-fit"
on:change={(e) => onSecretChange?.(e.detail)}
bind:checked={variable.is_secret}
disabled={unsecureNeedsLoad ||
(edit && (!actingUser || actingUser.operator || isEncryptedDraftValue(variable.value)))}
/>
{#if unsecureNeedsLoad && can_write && !actingUser?.operator}
<span class="text-2xs text-secondary">
Load the secret value or enter a new one to make it non-secret
</span>
{/if}
</div>
{#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}
</div>
{#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>