Files
windmill/frontend/src/lib/components/RemoteDeployConnect.svelte
T
Ruben FiszelandClaude Opus 5 4d12ea4614 feat: deploy from the UI to a workspace on another instance (#11245)
* feat: deploy from the UI to a workspace on another instance

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

* fix: keep the remote deploy proxy from being spent by a link

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

* fix: key remote deploy proxy URLs instead of a global client header

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

* fix: keep the remote deploy proxy key out of logs and restricted hands

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

* fix: serialize remote deploy connect with account and key changes

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

* feat: key remote deploy tokens to the account and connect by signing in

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

* fix: bind remote deploy connect to its target and order its locks

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

* fix: serialize remote deploy connect with target changes

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

* docs: list every lock remote deploy connect takes in auth-surface

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

* fix: never wait on the membership lock in remote deploy connect

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

* fix: keep a superseded target response out of the settings form

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

* fix: void stale remote deploy tokens on read instead of locking in connect

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

* fix: void remote deploy tokens older than the last target change

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

* fix: order remote deploy connections by when their connect started

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

* fix: bind stored remote deploy tokens to the membership and target they were connected under

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

* fix: answer remote deploy connect without settings as no target

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 01:40:53 +02:00

182 lines
5.2 KiB
Svelte

<script lang="ts">
import { page } from '$app/state'
import { untrack } from 'svelte'
import { WorkspaceService, type RemoteDeployConnection, type RemoteDeployTarget } from '$lib/gen'
import {
CHANNEL,
remoteDeployAuthorizeUrl,
remoteHost,
type RemoteDeployEvent
} from '$lib/remoteDeploy'
import { sendUserToast } from '$lib/toast'
import { ExternalLink, LogIn, Unplug } from 'lucide-svelte'
import { Button } from './common'
import Password from './Password.svelte'
let {
workspace,
target,
connection,
returnTo,
onChange
}: {
workspace: string
target: RemoteDeployTarget
connection: RemoteDeployConnection | undefined
/** Where to land after signing in when the popup is blocked and this tab navigates away:
* a page that shows the connection, since a drawer does not survive the round trip. */
returnTo?: string
onChange: () => void
} = $props()
let host = $derived(remoteHost(target.base_url))
let token: string | undefined = $state(undefined)
let connecting = $state(false)
let waitingForRemote = $state(false)
let showPaste = $state(false)
let error: string | undefined = $state(undefined)
let popupWatch: ReturnType<typeof setInterval> | undefined = undefined
function signIn() {
error = undefined
const url = remoteDeployAuthorizeUrl(
target,
workspace,
returnTo ?? page.url.pathname + page.url.search
)
// Opened blank and cut loose before it navigates: the remote page gets no handle on this
// window, and a blocked popup is still detected.
const popup = window.open('', 'windmill-remote-deploy', 'popup,width=640,height=760')
if (!popup) {
window.location.href = url
return
}
popup.opener = null
popup.location.href = url
waitingForRemote = true
clearInterval(popupWatch)
popupWatch = setInterval(() => {
if (popup.closed) {
clearInterval(popupWatch)
waitingForRemote = false
}
}, 500)
}
$effect(() => () => clearInterval(popupWatch))
// The drawer stays mounted across a workspace switch: a token pasted for one target must not
// be submitted to the next one.
$effect(() => {
;[workspace, target.base_url, target.workspace_id]
untrack(() => {
token = undefined
error = undefined
})
})
// The callback page runs in the popup, or in this tab when the popup was blocked.
$effect(() => {
const channel = new BroadcastChannel(CHANNEL)
channel.onmessage = (event: MessageEvent<RemoteDeployEvent>) => {
if (event.data?.workspace !== workspace) return
waitingForRemote = false
if (event.data.type === 'connected') {
onChange()
} else {
error = event.data.error
}
}
return () => channel.close()
})
async function connect() {
if (!token) return
connecting = true
error = undefined
try {
await WorkspaceService.connectRemoteDeploy({ workspace, requestBody: { token, target } })
token = undefined
onChange()
} catch (e: any) {
error = e?.body ?? e?.message ?? String(e)
} finally {
connecting = false
}
}
async function disconnect() {
await WorkspaceService.disconnectRemoteDeploy({ workspace })
sendUserToast(`Disconnected from ${target.base_url}`)
onChange()
}
</script>
{#if connection}
<div class="flex flex-row items-center gap-2 text-xs text-secondary">
<span>
Deploying as <span class="font-semibold text-primary">{connection.remote_email}</span> on
{target.base_url}
</span>
<Button variant="subtle" unifiedSize="xs" startIcon={{ icon: Unplug }} onclick={disconnect}>
Disconnect
</Button>
</div>
{:else}
<div class="flex flex-col gap-3 max-w-xl">
<p class="text-xs text-secondary">
Deploys to {target.workspace_id} on {target.base_url} run with your own account on that instance,
so its permissions, protection rules and audit logs apply. Sign in there to connect; the token
it issues is stored encrypted and only ever sent to that instance.
</p>
<div class="flex flex-row items-center gap-3">
<Button variant="accent" unifiedSize="sm" startIcon={{ icon: LogIn }} onclick={signIn}>
Sign in to {host}
</Button>
{#if waitingForRemote}
<span class="text-xs text-secondary">Waiting for {host}…</span>
{/if}
<Button variant="subtle" unifiedSize="sm" onclick={() => (showPaste = !showPaste)}>
{showPaste ? 'Hide token field' : 'Paste a token instead'}
</Button>
</div>
{#if showPaste}
<div class="flex flex-col gap-2">
<a
class="text-xs flex flex-row items-center gap-1 w-fit"
href="{target.base_url}/#user-settings"
target="_blank"
rel="noopener noreferrer"
>
Create a token on {host}
<ExternalLink size={12} />
</a>
<div class="flex flex-row gap-2 items-start">
<div class="grow">
<Password
bind:password={token}
small
allowMultiline={false}
placeholder="Token for {target.base_url}"
error={error != undefined}
onKeyDown={(e) => e.key == 'Enter' && connect()}
/>
</div>
<Button
variant="default"
unifiedSize="sm"
disabled={!token || connecting}
loading={connecting}
onclick={connect}
>
Connect
</Button>
</div>
</div>
{/if}
{#if error}
<p class="text-xs text-red-500 dark:text-red-400 break-words">{error}</p>
{/if}
</div>
{/if}