mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 16:05:43 +00:00
* fix(frontend): mint password secrets in the operating workspace A `password: true` string argument is rendered by PasswordArgInput, which mints an ephemeral secret variable on the first keystroke and rebinds the argument to `$var:<path>`. It minted into `$workspaceStore` — the globally active navigation workspace. Session editors operate on a different, possibly forked workspace without switching `$workspaceStore`, and thread that operating workspace explicitly as a `workspace` prop. When the two diverged the secret landed where the user was merely looking while the job ran elsewhere, and the backend failed with `Variable not found`. Add the `workspace` prop to PasswordArgInput and thread it through every hop between a form mount and the minting field, plus the entry points that supply it. Track `mintedIn` so updates target where the variable actually lives, and re-mint when the operating workspace moves after a path already exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(frontend): keep a password field consistent with its argument A parent can replace the whole args object without remounting this field — previewing a saved input, say — leaving `path` and `password` describing a secret the argument no longer points at. Minting from them then copies the old plaintext over the replacement, and the replacement is lost. State that rule once as `argReplaced` and gate every mint on it. The replacement can also land while the create is in flight, so the bound value is captured before the request and re-checked after it resolves; the variable that mint produced was never referenced, so it is deleted outright. A mint that ends without binding re-seeds `password` from what the argument now holds, so the field stops displaying a secret that will not be submitted and a later workspace move cannot re-mint the stale plaintext. `updateValue` returns early before anything is minted, since its 404 retry would otherwise bind over a replacement it cannot see. A failed initial mint now raises a toast rather than passing silently, which also removes the component's last unhandled rejection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(frontend): guard workspace forwarding to PasswordArgInput Every hop between the form a caller mounts and the PasswordArgInput that mints the secret must forward `workspace`, and so must the entry points that supply it. A hop that drops the prop falls back to the navigation workspace while the top-level case keeps passing, and no typechecker catches it because every hop declares `workspace?: string | undefined`. The forwarded expression is checked rather than the prop's presence, so `workspace={$workspaceStore}` and `workspace={undefined}` fail. Two ways the scan could stop guarding without failing are asserted too: an unterminated mount raises instead of swallowing the rest of the file, and the number of mounts parsed must equal the number of tag occurrences, so a mount written inline rather than at the start of a line fails loudly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(frontend): list and create variables in the operating workspace * fix(frontend): surface and bound a failed recovery mint * test(frontend): end a mount at the first line closing it --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
5.4 KiB
Svelte
161 lines
5.4 KiB
Svelte
<script lang="ts">
|
|
import { VariableService } from '$lib/gen'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import { generateRandomString } from '$lib/utils'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { Button } from './common'
|
|
import Password from './Password.svelte'
|
|
import { untrack } from 'svelte'
|
|
|
|
interface Props {
|
|
value?: string | undefined
|
|
disabled: boolean
|
|
minRows?: number
|
|
/** Workspace the ephemeral secret is minted in; defaults to the nav workspace.
|
|
* Session editors pass their acting workspace. */
|
|
workspace?: string | undefined
|
|
}
|
|
|
|
let { value = $bindable(undefined), disabled, minRows, workspace }: Props = $props()
|
|
|
|
let ws = $derived(workspace ?? $workspaceStore)
|
|
|
|
let path = $state('')
|
|
// Workspace the variable at `path` actually lives in; `ws` can move away from it.
|
|
let mintedIn = $state<string | undefined>(undefined)
|
|
// What the field mints from: an argument already holding a `$var:` ref has nothing to mint.
|
|
function plaintextOf(v: unknown): string {
|
|
return typeof v === 'string' && v !== '' && !v.startsWith('$var:') ? v : ''
|
|
}
|
|
let password = $state(plaintextOf(value))
|
|
|
|
// The argument no longer holds what this field would mint from — a parent can replace the whole
|
|
// args object without remounting it (previewing a saved input, say). Minting now would describe a
|
|
// secret the argument does not point at, and binding it would discard the replacement.
|
|
let argReplaced = $derived(path !== '' && value !== '$var:' + path)
|
|
|
|
let isGenerating = false
|
|
|
|
let userPrefix = $derived(
|
|
'u/' + ($userStore?.username ?? $userStore?.email)?.split('@')[0] + '/secret_arg/'
|
|
)
|
|
async function generateValue() {
|
|
if (isGenerating || argReplaced) return
|
|
isGenerating = true
|
|
const mintWs = ws!
|
|
const boundBefore = value
|
|
try {
|
|
let npath = userPrefix + generateRandomString(12)
|
|
let nvalue = '$var:' + npath
|
|
await VariableService.createVariable({
|
|
workspace: mintWs,
|
|
requestBody: {
|
|
value: password,
|
|
is_secret: true,
|
|
path: npath,
|
|
description: 'Ephemeral secret variable',
|
|
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
|
|
}
|
|
})
|
|
// The arg can be replaced the same way while the create is in flight. Nothing ever
|
|
// referenced the variable just minted, so delete it; it expires on its own if that fails.
|
|
if (value !== boundBefore) {
|
|
VariableService.deleteVariable({ workspace: mintWs, path: npath }).catch(() => {})
|
|
return
|
|
}
|
|
path = npath
|
|
mintedIn = mintWs
|
|
console.log('generated', nvalue)
|
|
value = nvalue
|
|
debouncedUpdate()
|
|
} finally {
|
|
// Ended without binding: discarded just above, or the create failed after the argument
|
|
// moved. The field would otherwise keep showing a secret the argument does not hold, and
|
|
// the mint effect tracks `ws` — a workspace move would bind that stale plaintext over the
|
|
// replacement. Re-seeding leaves the field describing the argument again.
|
|
if (path === '' && value !== boundBefore) {
|
|
password = plaintextOf(value)
|
|
}
|
|
isGenerating = false
|
|
}
|
|
}
|
|
|
|
async function updateValue() {
|
|
// The first keystroke queues an update before anything is minted: letting it run would 404 and
|
|
// retry the mint, binding over an argument that was replaced while the first mint was in flight.
|
|
if (path === '') return
|
|
const updating = path
|
|
try {
|
|
await VariableService.updateVariable({
|
|
workspace: mintedIn ?? ws!,
|
|
path: path,
|
|
requestBody: {
|
|
value: password
|
|
}
|
|
})
|
|
} catch (e) {
|
|
// A re-mint can bind a fresh variable while this update is in flight; recovering then
|
|
// would orphan the one it just bound.
|
|
if (path !== updating) return
|
|
generateValue().catch((e) =>
|
|
sendUserToast(`Could not create the secret: ${e?.body ?? e?.message ?? e}`, true)
|
|
)
|
|
}
|
|
}
|
|
|
|
let timeout: number | undefined = undefined
|
|
function debouncedUpdate() {
|
|
timeout && clearTimeout(timeout)
|
|
timeout = setTimeout(updateValue, 500)
|
|
}
|
|
|
|
$effect(() => {
|
|
password && untrack(() => debouncedUpdate())
|
|
})
|
|
|
|
$effect(() => {
|
|
ws &&
|
|
($userStore?.username || $userStore?.email) &&
|
|
path == '' &&
|
|
password != '' &&
|
|
untrack(() =>
|
|
// A failed mint leaves the plaintext bound to nothing and the argument empty. Only a
|
|
// further keystroke re-runs this, so say so rather than submitting the job without it.
|
|
generateValue().catch((e) =>
|
|
sendUserToast(`Could not create the secret: ${e?.body ?? e?.message ?? e}`, true)
|
|
)
|
|
)
|
|
})
|
|
|
|
// The operating workspace can move after minting (a session forking, say), leaving the
|
|
// variable behind where the job will not find it: mint a fresh one in the new workspace.
|
|
// Bounded to a live instance: a field mounted onto an existing `$var:` holds neither the
|
|
// plaintext nor the workspace it was minted in, so it can only be moved by retyping it.
|
|
$effect(() => {
|
|
const cur = ws
|
|
if (!cur || path === '' || password === '' || mintedIn === cur || argReplaced) return
|
|
untrack(() =>
|
|
generateValue().catch((e) =>
|
|
sendUserToast(`Could not create the secret in ${cur}: ${e?.body ?? e?.message ?? e}`, true)
|
|
)
|
|
)
|
|
})
|
|
</script>
|
|
|
|
{#if value?.startsWith('$var:') && !value.startsWith('$var:' + userPrefix)}
|
|
<div class="flex items-center gap-2 text-sm text-primary">
|
|
Linked to static variable
|
|
<Button
|
|
size="xs"
|
|
variant="default"
|
|
onclick={() => {
|
|
value = ''
|
|
}}
|
|
>
|
|
Reset variable link
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<Password {disabled} {minRows} bind:password />
|
|
{/if}
|