mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
feat: pick the workspace connection like other workspace resources, connect and disconnect from the picker
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
85e44e851d
commit
c176bcef00
@@ -4,8 +4,11 @@
|
||||
import { Button } from '$lib/components/common'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Plug } from 'lucide-svelte'
|
||||
import { Plus, Unplug } from 'lucide-svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { connectNativeAccount, getServiceConfig } from './utils'
|
||||
|
||||
@@ -15,12 +18,34 @@
|
||||
/** An existing trigger keeps the connection its webhook was registered under. */
|
||||
locked?: boolean
|
||||
disabled?: boolean
|
||||
/** Only offer the caller's own connections. */
|
||||
ownOnly?: boolean
|
||||
/** Select the first connection when none is chosen yet. */
|
||||
autoSelect?: boolean
|
||||
clearable?: boolean
|
||||
placeholder?: string
|
||||
/** Called instead of setting `value` directly when the user picks or clears a connection. */
|
||||
onSelect?: (path: string | undefined) => void
|
||||
/** Called after an account is connected or disconnected from the picker. */
|
||||
onConnectionsChange?: () => void
|
||||
}
|
||||
|
||||
let { service, value = $bindable(), locked = false, disabled = false }: Props = $props()
|
||||
let {
|
||||
service,
|
||||
value = $bindable(),
|
||||
locked = false,
|
||||
disabled = false,
|
||||
ownOnly = false,
|
||||
autoSelect = true,
|
||||
clearable = false,
|
||||
placeholder,
|
||||
onSelect,
|
||||
onConnectionsChange
|
||||
}: Props = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const displayName = $derived(getServiceConfig(service)?.serviceDisplayName ?? service)
|
||||
const confirmationModal = createAsyncConfirmationModal()
|
||||
|
||||
let connections = $state<NativeTriggerConnection[] | undefined>(undefined)
|
||||
let connecting = $state(false)
|
||||
@@ -35,8 +60,8 @@
|
||||
connections = []
|
||||
sendUserToast(`Failed to load ${displayName} connections: ${err.body ?? err.message}`, true)
|
||||
}
|
||||
if (!locked && value === undefined && connections.length > 0) {
|
||||
value = connections[0].path
|
||||
if (autoSelect && !locked && value === undefined && offered.length > 0) {
|
||||
select(offered[0].path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +71,19 @@
|
||||
load()
|
||||
})
|
||||
|
||||
function select(path: string | undefined) {
|
||||
if (onSelect) onSelect(path)
|
||||
else value = path
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
connecting = true
|
||||
try {
|
||||
const path = await connectNativeAccount($operatingWorkspace!, service)
|
||||
await load()
|
||||
value = path
|
||||
sendUserToast(`${displayName} account connected`)
|
||||
onConnectionsChange?.()
|
||||
select(path)
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Could not connect ${displayName}: ${err.body ?? err.message}`, true)
|
||||
} finally {
|
||||
@@ -60,9 +91,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(path: string) {
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: `Disconnect ${path}?`,
|
||||
confirmationText: 'Disconnect',
|
||||
children: `This deletes the ${displayName} triggers created with this account. This action cannot be undone.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
try {
|
||||
await WorkspaceIntegrationService.deleteNativeTriggerConnection({
|
||||
workspace: $operatingWorkspace!,
|
||||
serviceName: service,
|
||||
path
|
||||
})
|
||||
sendUserToast(`${path} disconnected`)
|
||||
if (value === path) select(undefined)
|
||||
await load()
|
||||
onConnectionsChange?.()
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to disconnect ${path}: ${err.body ?? err.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Members cannot write the workspace connection; the server refuses anyone else it does not own.
|
||||
function canDisconnect(c: NativeTriggerConnection): boolean {
|
||||
if ($userStore?.is_admin) return true
|
||||
return !c.workspace && !!c.owner && c.owner === $userStore?.username
|
||||
}
|
||||
|
||||
const offered = $derived(
|
||||
(connections ?? []).filter((c) => !ownOnly || c.owner === $userStore?.username)
|
||||
)
|
||||
const items = $derived.by(() => {
|
||||
const list = (connections ?? []).map((c) => ({
|
||||
label: c.workspace ? `${c.path} (workspace connection)` : c.path,
|
||||
const list = offered.map((c) => ({
|
||||
label: c.workspace && !ownOnly ? `${c.path} (workspace connection)` : c.path,
|
||||
value: c.path
|
||||
}))
|
||||
if (value && !list.some((i) => i.value === value)) {
|
||||
@@ -71,34 +133,50 @@
|
||||
return list
|
||||
})
|
||||
const unusable = $derived(
|
||||
connections !== undefined && !!value && !connections.some((c) => c.path === value)
|
||||
!ownOnly && connections !== undefined && !!value && !connections.some((c) => c.path === value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Select
|
||||
class="grow"
|
||||
{items}
|
||||
bind:value
|
||||
disabled={disabled || locked}
|
||||
loading={connections === undefined}
|
||||
placeholder="Connect an account to create this trigger"
|
||||
noItemsMsg="No {displayName} account connected yet"
|
||||
/>
|
||||
{#if !locked}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
startIcon={{ icon: Plug }}
|
||||
onclick={connect}
|
||||
disabled={disabled || connecting}
|
||||
loading={connecting}
|
||||
>
|
||||
Connect account
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<Select
|
||||
{items}
|
||||
bind:value={() => value, (v) => select(v)}
|
||||
onClear={() => select(undefined)}
|
||||
{clearable}
|
||||
disabled={disabled || locked}
|
||||
loading={connections === undefined || connecting}
|
||||
placeholder={placeholder ?? `Connect a ${displayName} account to create this trigger`}
|
||||
noItemsMsg="No {displayName} account connected yet"
|
||||
>
|
||||
{#snippet endSnippet({ item, close })}
|
||||
{@const connection = connections?.find((c) => c.path === item.value)}
|
||||
{#if connection && canDisconnect(connection)}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
iconOnly
|
||||
startIcon={{ icon: Unplug }}
|
||||
title="Disconnect"
|
||||
onclick={() => (close(), disconnect(connection.path))}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet bottomSnippet({ close })}
|
||||
<div class="flex bg-surface border-t">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
wrapperClasses="flex-1"
|
||||
btnClasses="rounded-none"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={disabled || connecting}
|
||||
onclick={() => (close(), connect())}
|
||||
>
|
||||
Connect a {displayName} account
|
||||
</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Select>
|
||||
{#if locked}
|
||||
<p class="text-2xs text-secondary">
|
||||
The webhook is registered under this account. To use another one, create a new trigger.
|
||||
@@ -111,3 +189,5 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmationModal {...confirmationModal.props} />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { Button, Alert, Badge } from '$lib/components/common'
|
||||
import { Button, Alert } from '$lib/components/common'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
|
||||
import { X, ExternalLink, Cog, Plug } from 'lucide-svelte'
|
||||
import { X, ExternalLink, Cog } from 'lucide-svelte'
|
||||
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
|
||||
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
|
||||
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
|
||||
@@ -17,11 +17,8 @@
|
||||
import OAuthClientConfig from './OAuthClientConfig.svelte'
|
||||
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import {
|
||||
connectNativeAccount,
|
||||
nativeOAuthRedirectUri
|
||||
} from '$lib/components/triggers/native/utils'
|
||||
import NativeConnectionPicker from '$lib/components/triggers/native/NativeConnectionPicker.svelte'
|
||||
import { nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils'
|
||||
|
||||
interface WorkspaceIntegration {
|
||||
service_name: string
|
||||
@@ -96,13 +93,8 @@
|
||||
let integrations = $state<WorkspaceIntegration[]>([])
|
||||
let connections = $state<Record<string, NativeTriggerConnection[]>>({})
|
||||
let loading = $state(false)
|
||||
let connecting = $state<string | null>(null)
|
||||
let showingConfig = $state<string | null>(null)
|
||||
let instanceSharingAvailable = $state<Record<string, boolean>>({})
|
||||
// The service whose "where to save the connection" form is open.
|
||||
let choosingPathFor = $state<string | null>(null)
|
||||
let resourcePath = $state<string | undefined>(undefined)
|
||||
let pathError = $state<string | undefined>(undefined)
|
||||
let confirmationModal = createAsyncConfirmationModal()
|
||||
|
||||
async function loadIntegrations() {
|
||||
@@ -161,31 +153,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect(serviceName: string, path: string) {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: `Disconnect ${path}?`,
|
||||
confirmationText: 'Disconnect',
|
||||
children: `This deletes the ${displayName} triggers created with this account. This action cannot be undone.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await WorkspaceIntegrationService.deleteNativeTriggerConnection({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as NativeServiceName,
|
||||
path
|
||||
})
|
||||
sendUserToast(`${path} disconnected`)
|
||||
loadConnections(serviceName)
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to disconnect ${path}: ${err.body ?? err.message}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function setWorkspaceConnection(serviceName: string, path: string | null) {
|
||||
async function setWorkspaceConnection(serviceName: string, path: string | undefined) {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
|
||||
@@ -196,13 +164,20 @@
|
||||
children: `Every member will be able to create and edit ${displayName} triggers that act as this account. They will not see its token. It replaces any connection shared before.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
} else {
|
||||
const confirmed = await confirmationModal.ask({
|
||||
title: 'Stop sharing the workspace connection?',
|
||||
confirmationText: 'Stop sharing',
|
||||
children: `Members will no longer be able to create or edit ${displayName} triggers through it, unless they can read it. Its triggers keep running.`
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
try {
|
||||
await WorkspaceIntegrationService.setNativeTriggerWorkspaceConnection({
|
||||
workspace: $workspaceStore,
|
||||
serviceName: serviceName as NativeServiceName,
|
||||
requestBody: { path }
|
||||
requestBody: { path: path ?? null }
|
||||
})
|
||||
sendUserToast(path ? `${path} is now the workspace connection` : 'Stopped sharing')
|
||||
await loadConnections(serviceName)
|
||||
@@ -211,29 +186,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(serviceName: string) {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
connecting = serviceName
|
||||
try {
|
||||
const path = await connectNativeAccount(
|
||||
$workspaceStore,
|
||||
serviceName as NativeServiceName,
|
||||
resourcePath
|
||||
)
|
||||
sendUserToast(`${supportedServices[serviceName]?.displayName} account connected as ${path}`)
|
||||
choosingPathFor = null
|
||||
await loadConnections(serviceName)
|
||||
} catch (err: any) {
|
||||
sendUserToast(
|
||||
`Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.body ?? err.message}`,
|
||||
true
|
||||
)
|
||||
} finally {
|
||||
connecting = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrUpdateIntegration(serviceName: string, oauthData: any) {
|
||||
if (!$workspaceStore) return
|
||||
|
||||
@@ -312,7 +264,7 @@
|
||||
<div class="flex flex-col">
|
||||
<SettingsPageHeader
|
||||
title="Native Triggers"
|
||||
description="Configure the OAuth app of each service. Members then connect their own account, or use one shared through a folder or as the workspace connection, and each trigger acts as the account it was created with."
|
||||
description="Configure the OAuth app of each service. Members connect their own account when creating a trigger, or use the workspace connection, and each trigger acts as the account it was created with."
|
||||
link="https://www.windmill.dev/docs/core_concepts/native_triggers"
|
||||
/>
|
||||
|
||||
@@ -326,10 +278,9 @@
|
||||
<div class="space-y-4">
|
||||
{#each Object.entries(supportedServices) as [serviceName, config]}
|
||||
{@const integration = getIntegrationByService(serviceName)}
|
||||
{@const isConnecting = connecting === serviceName}
|
||||
{@const workspaceApp = hasWorkspaceApp(integration)}
|
||||
{@const usesInstanceApp = !workspaceApp && instanceSharingAvailable[serviceName]}
|
||||
{@const serviceConnections = connections[serviceName] ?? []}
|
||||
{@const workspaceConnection = connections[serviceName]?.find((c) => c.workspace)?.path}
|
||||
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
@@ -344,19 +295,6 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
{#if workspaceApp || usesInstanceApp}
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={() => {
|
||||
choosingPathFor = serviceName
|
||||
resourcePath = undefined
|
||||
}}
|
||||
disabled={isConnecting}
|
||||
startIcon={{ icon: Plug }}
|
||||
>
|
||||
Connect account
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={() => (showingConfig = showingConfig === serviceName ? null : serviceName)}
|
||||
@@ -407,78 +345,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if choosingPathFor === serviceName}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="text-xs text-secondary mb-2">
|
||||
Choose where to save the connection. Save it in a folder to let the folder's members
|
||||
create triggers with this account.
|
||||
{#if workspaceApp || usesInstanceApp}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Workspace connection</div>
|
||||
<div class="text-xs text-secondary mb-1">
|
||||
Members can create triggers that act as this account, without access to its token.
|
||||
Only an account you connected yourself can be shared.
|
||||
</div>
|
||||
<Path
|
||||
kind="resource"
|
||||
initialPath=""
|
||||
namePlaceholder={'native_' + serviceName}
|
||||
bind:path={resourcePath}
|
||||
bind:error={pathError}
|
||||
<NativeConnectionPicker
|
||||
service={serviceName as NativeServiceName}
|
||||
value={workspaceConnection}
|
||||
ownOnly
|
||||
autoSelect={false}
|
||||
clearable
|
||||
placeholder="No workspace connection"
|
||||
onSelect={(path) => setWorkspaceConnection(serviceName, path)}
|
||||
onConnectionsChange={() => loadConnections(serviceName)}
|
||||
/>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={!resourcePath || !!pathError || isConnecting}
|
||||
loading={isConnecting}
|
||||
onclick={() => connect(serviceName)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Button variant="default" onclick={() => (choosingPathFor = null)}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serviceConnections.length > 0}
|
||||
<div
|
||||
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-1"
|
||||
>
|
||||
<div class="text-xs font-semibold text-emphasis mb-1">Connected accounts</div>
|
||||
{#each serviceConnections as connection (connection.path)}
|
||||
<div class="flex items-center justify-between gap-2 text-xs">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="font-mono truncate">{connection.path}</span>
|
||||
{#if connection.workspace}
|
||||
<Badge color="blue" small>Workspace connection</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if connection.owner}
|
||||
<span class="text-secondary">connected by {connection.owner}</span>
|
||||
{/if}
|
||||
{#if connection.workspace}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
onclick={() => setWorkspaceConnection(serviceName, null)}
|
||||
>
|
||||
Stop sharing
|
||||
</Button>
|
||||
{:else if connection.owner === $userStore?.username}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
onclick={() => setWorkspaceConnection(serviceName, connection.path)}
|
||||
>
|
||||
Share with workspace
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
destructive
|
||||
onclick={() => disconnect(serviceName, connection.path)}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user