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:
hugocasa
2026-10-16 14:36:10 +02:00
co-authored by Claude Opus 5.5
parent 85e44e851d
commit c176bcef00
2 changed files with 142 additions and 179 deletions
@@ -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}