Files
windmill/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte
T

486 lines
18 KiB
Svelte

<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
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 { Check, ExternalLink, KeyRound, Pencil, Trash2 } from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import type { Item } from '$lib/utils'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
import {
WorkspaceIntegrationService,
type NativeServiceName,
type NativeTriggerConnection
} from '$lib/gen'
import OAuthClientConfig from './OAuthClientConfig.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import NativeConnectionPicker from '$lib/components/triggers/native/NativeConnectionPicker.svelte'
import { connectNativeAccount, nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils'
interface WorkspaceIntegration {
service_name: string
oauth_data: {
client_id: string
client_secret: string
base_url: string
instance_shared?: boolean
} | null
}
interface ServiceConfig {
name: string
displayName: string
description: string
icon: any
docsUrl?: string
requiresBaseUrl?: boolean
clientIdPlaceholder?: string
clientSecretPlaceholder?: string
setupInstructions?: string[]
}
const supportedServices: Record<string, ServiceConfig> = {
nextcloud: {
name: 'nextcloud',
displayName: 'Nextcloud',
description: 'File events',
icon: NextcloudIcon,
docsUrl: 'https://www.windmill.dev/docs/integrations/nextcloud',
setupInstructions: [
'Create an OAuth2 application in your Nextcloud instance (Administration settings → Security → OAuth 2.0 clients)',
'Configure the redirect URI shown below',
'Enter the client credentials below'
]
},
google: {
name: 'google',
displayName: 'Google',
description: 'Drive and Calendar changes',
icon: GoogleIcon,
docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#google-triggers',
requiresBaseUrl: false,
clientIdPlaceholder: 'xxxx.apps.googleusercontent.com',
clientSecretPlaceholder: 'Google Cloud Console client secret',
setupInstructions: [
'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" class="underline">Google Cloud Console - Credentials</a>',
'Create an OAuth 2.0 Client ID (Web application type)',
'Add the redirect URI shown below to "Authorized redirect URIs"',
'Enable the <a href="https://console.cloud.google.com/apis/library/drive.googleapis.com" target="_blank" rel="noopener" class="underline">Google Drive API</a> and <a href="https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" target="_blank" rel="noopener" class="underline">Google Calendar API</a> in your project',
'Enter the client credentials below'
]
},
github: {
name: 'github',
displayName: 'GitHub',
description: 'Repository events',
icon: GithubIcon,
docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#github-triggers',
requiresBaseUrl: false,
clientIdPlaceholder: 'GitHub OAuth App Client ID',
clientSecretPlaceholder: 'GitHub OAuth App Client Secret',
setupInstructions: [
'Go to <a href="https://github.com/settings/developers" target="_blank" rel="noopener" class="underline">GitHub Developer Settings</a>',
'Create a new OAuth App (not a GitHub App)',
'Set the "Authorization callback URL" to the redirect URI shown below',
'Enter the Client ID and Client Secret below'
]
}
}
let integrations = $state<WorkspaceIntegration[]>([])
let connections = $state<Record<string, NativeTriggerConnection[]>>({})
let loading = $state(false)
// The service whose OAuth app dialog is open.
let connectingShared = $state<string | null>(null)
let configOpen = $state<Record<string, boolean>>(
Object.fromEntries(Object.keys(supportedServices).map((s) => [s, false]))
)
let instanceSharingAvailable = $state<Record<string, boolean>>({})
let confirmationModal = createAsyncConfirmationModal()
async function loadIntegrations() {
if (!$workspaceStore) return
loading = true
try {
const response = await WorkspaceIntegrationService.listNativeTriggerServices({
workspace: $workspaceStore
})
integrations = response.map((item) => ({
service_name: item.service_name,
oauth_data: item.oauth_data || null
}))
await Promise.all(Object.keys(supportedServices).map(loadConnections))
} catch (err: any) {
console.error('Failed to load workspace integrations:', err)
sendUserToast(`Failed to load integrations: ${err.message}`, true)
} finally {
loading = false
}
}
async function loadConnections(serviceName: string) {
if (!$workspaceStore) return
try {
connections[serviceName] = await WorkspaceIntegrationService.listNativeTriggerConnections({
workspace: $workspaceStore,
serviceName: serviceName as NativeServiceName
})
} catch {
connections[serviceName] = []
}
}
async function deleteIntegration(serviceName: string) {
if (!$workspaceStore) return
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
const confirmed = await confirmationModal.ask({
title: `Remove the ${displayName} configuration?`,
confirmationText: 'Remove',
children: `This deletes the workspace's ${displayName} OAuth app, every ${displayName} account connected through it and all ${displayName} triggers, which stop receiving events. This action cannot be undone.`
})
if (!confirmed) return
try {
await WorkspaceIntegrationService.deleteNativeTriggerService({
workspace: $workspaceStore,
serviceName: serviceName as NativeServiceName
})
sendUserToast(`${displayName} integration removed`)
loadIntegrations()
} catch (err: any) {
sendUserToast(`Failed to remove ${displayName}: ${err.body ?? err.message}`, true)
}
}
async function setWorkspaceConnection(serviceName: string, path: string | undefined): Promise<boolean> {
if (!$workspaceStore) return false
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
if (path) {
const owner = connections[serviceName]?.find((c) => c.path === path)?.owner
const account = owner ? `this account, connected by ${owner}` : 'this account'
const confirmed = await confirmationModal.ask({
title: `Share ${path} with the workspace?`,
confirmationText: 'Share',
children: `Every member will be able to create and edit ${displayName} triggers that act as ${account}. They will not see its token. It replaces any connection shared before.`
})
if (!confirmed) return false
} else {
const confirmed = await confirmationModal.ask({
title: 'Stop sharing this account?',
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 false
}
try {
await WorkspaceIntegrationService.setNativeTriggerWorkspaceConnection({
workspace: $workspaceStore,
serviceName: serviceName as NativeServiceName,
requestBody: { path: path ?? null }
})
sendUserToast(path ? `${path} is now the shared account` : 'Stopped sharing')
await loadConnections(serviceName)
return true
} catch (err: any) {
sendUserToast(`Failed to update the shared account: ${err.body ?? err.message}`, true)
return false
}
}
async function connectAndShare(serviceName: string) {
if (!$workspaceStore) return
connectingShared = serviceName
try {
const path = await connectNativeAccount($workspaceStore, serviceName as NativeServiceName)
await loadConnections(serviceName)
await setWorkspaceConnection(serviceName, path)
} catch (err: any) {
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
sendUserToast(`Could not connect ${displayName}: ${err.body ?? err.message}`, true)
} finally {
connectingShared = null
}
}
function docsItem(config: ServiceConfig): Item[] {
return config.docsUrl
? [{ displayName: 'Docs', icon: ExternalLink, href: config.docsUrl, hrefTarget: '_blank' }]
: []
}
async function openConfig(serviceName: string, leavingInstanceApp: boolean) {
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
// A refresh token only works with the OAuth app that issued it.
if (leavingInstanceApp && (connections[serviceName]?.length ?? 0) > 0) {
const confirmed = await confirmationModal.ask({
title: `Use your own ${displayName} OAuth app?`,
confirmationText: 'Continue',
children: `Accounts connected with the instance app will have to be connected again, at the same path, before their triggers work again.`
})
if (!confirmed) return
}
configOpen[serviceName] = true
}
async function createOrUpdateIntegration(serviceName: string, oauthData: any) {
if (!$workspaceStore) return
const displayName = supportedServices[serviceName]?.displayName ?? serviceName
const current = getIntegrationByService(serviceName)?.oauth_data
// Leaving the instance app is confirmed when the form opens; this covers editing our own app.
const clientChanges =
!!current?.client_id &&
!current.instance_shared &&
current.client_id !== (oauthData.client_id ?? '')
// A refresh token only works with the OAuth app that issued it.
if (clientChanges && (connections[serviceName]?.length ?? 0) > 0) {
const confirmed = await confirmationModal.ask({
title: `Change the ${displayName} OAuth app?`,
confirmationText: 'Change',
children: `Accounts connected with the current app must be connected again, at the same path, before their triggers work again.`
})
if (!confirmed) return
}
try {
await WorkspaceIntegrationService.createNativeTriggerService({
workspace: $workspaceStore,
serviceName: serviceName as NativeServiceName,
requestBody: oauthData
})
sendUserToast(
`${supportedServices[serviceName]?.displayName} configuration saved successfully`
)
loadIntegrations()
} catch (err: any) {
sendUserToast(
`Failed to configure ${supportedServices[serviceName]?.displayName}: ${err.message}`,
true
)
}
}
async function checkInstanceSharing() {
if (!$workspaceStore) return
for (const serviceName of Object.keys(supportedServices)) {
try {
const available = await WorkspaceIntegrationService.checkInstanceSharingAvailable({
workspace: $workspaceStore,
serviceName: serviceName as NativeServiceName
})
instanceSharingAvailable[serviceName] = available
} catch {
instanceSharingAvailable[serviceName] = false
}
}
}
/** Whether the workspace has its own OAuth app for the service. */
function hasWorkspaceApp(integration: WorkspaceIntegration | null): boolean {
if (!integration?.oauth_data || integration.oauth_data.instance_shared) return false
const serviceConfig = supportedServices[integration.service_name]
const needsBaseUrl = serviceConfig?.requiresBaseUrl !== false
return (
!!integration.oauth_data.client_id &&
!!integration.oauth_data.client_secret &&
(!needsBaseUrl || !!integration.oauth_data.base_url)
)
}
function getIntegrationByService(serviceName: string): WorkspaceIntegration | null {
return integrations.find((integration) => integration.service_name === serviceName) || null
}
$effect(() => {
if ($workspaceStore) {
loadIntegrations()
checkInstanceSharing()
}
})
</script>
<div class="flex flex-col">
<SettingsPageHeader
title="Native triggers"
description="Configure the OAuth app of each service. Members connect their own account when creating a trigger, or use the shared account, and each trigger acts as the account it was created with."
link="https://www.windmill.dev/docs/core_concepts/native_triggers"
/>
{#if loading}
<div class="space-y-4">
{#each new Array(3) as _}
<Skeleton layout={[[6], 0.4]} />
{/each}
</div>
{:else}
<div class="space-y-4">
{#each Object.entries(supportedServices) as [serviceName, config]}
{@const integration = getIntegrationByService(serviceName)}
{@const workspaceApp = hasWorkspaceApp(integration)}
{@const usesInstanceApp = !workspaceApp && instanceSharingAvailable[serviceName]}
{@const workspaceConnection = connections[serviceName]?.find((c) => c.workspace)?.path}
<div class="border border-light rounded-md p-4 bg-surface-tertiary flex flex-col gap-3">
<div class="flex items-center gap-3">
<config.icon class="w-6 h-6 shrink-0" />
<div class="flex flex-col flex-1 min-w-0">
<div class="text-sm font-semibold text-emphasis">{config.displayName}</div>
<div class="text-xs text-secondary">{config.description}</div>
</div>
<div class="text-xs text-secondary shrink-0">
{workspaceApp
? 'Configured in workspace'
: usesInstanceApp
? 'Configured by instance'
: 'Not configured'}
</div>
{#if workspaceApp || usesInstanceApp}
<DropdownV2
items={workspaceApp
? [
{
displayName: 'Edit OAuth app',
icon: Pencil,
action: () => openConfig(serviceName, false)
},
...docsItem(config),
{
displayName: 'Remove configuration',
icon: Trash2,
type: 'delete',
action: () => deleteIntegration(serviceName)
}
]
: [
{
displayName: 'Use a workspace OAuth app',
icon: KeyRound,
action: () => openConfig(serviceName, true)
},
...docsItem(config)
]}
/>
{:else}
<Button
unifiedSize="sm"
variant="default"
onclick={() => openConfig(serviceName, false)}
>
Set up
</Button>
{/if}
</div>
{#if !workspaceApp && !usesInstanceApp}
<div class="text-xs text-secondary">
Members can't create {config.displayName} triggers yet.
</div>
{:else}
{#if serviceName === 'nextcloud' && !workspaceConnection}
<div class="flex items-center justify-between gap-4">
<div class="text-xs text-secondary">
Nextcloud only accepts webhooks from admin accounts, so members without one need a
shared account.
</div>
<Button
unifiedSize="sm"
variant="default"
loading={connectingShared === serviceName}
onclick={() => connectAndShare(serviceName)}
>
Connect and share
</Button>
</div>
{:else}
<div class="flex items-center gap-1.5 text-xs text-secondary">
<Check size={14} class="shrink-0" />
Members can connect their own {config.displayName} account when they create a trigger{workspaceConnection
? ', or use the shared one'
: ''}.
</div>
{/if}
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<span class="text-xs font-semibold text-emphasis shrink-0">Shared account</span>
<div class="flex-1 min-w-0 max-w-md">
<!-- Remounted so its list includes an account connected from elsewhere on the page. -->
{#key workspaceConnection}
<NativeConnectionPicker
service={serviceName as NativeServiceName}
value={workspaceConnection}
autoSelect={false}
clearable
labelWorkspace={false}
placeholder="None"
onSelect={(path) => setWorkspaceConnection(serviceName, path)}
onConnectionsChange={() => loadConnections(serviceName)}
/>
{/key}
</div>
</div>
<div class="text-2xs text-hint">
Members can create triggers as this account without seeing its token.
</div>
</div>
{/if}
<OAuthClientConfig
bind:open={configOpen[serviceName]}
{serviceName}
redirectUri={nativeOAuthRedirectUri(serviceName as NativeServiceName)}
serviceDisplayName={config.displayName}
existingConfig={workspaceApp ? integration?.oauth_data : null}
requiresBaseUrl={config.requiresBaseUrl !== false}
clientIdPlaceholder={config.clientIdPlaceholder}
clientSecretPlaceholder={config.clientSecretPlaceholder}
setupInstructions={config.setupInstructions}
onConfigSaved={async (oauthData) => {
await createOrUpdateIntegration(serviceName, oauthData)
configOpen[serviceName] = false
}}
>
{#snippet requirements()}
{#if serviceName === 'nextcloud'}
<Alert type="info" title="Nextcloud requirements">
<ul class="list-disc pl-4 flex flex-col gap-1">
<li>Nextcloud 33 or later.</li>
<li>
The <a
href="https://apps.nextcloud.com/apps/integration_windmill"
target="_blank"
rel="noopener noreferrer"
class="underline">Windmill integration app</a
> installed.
</li>
<li>
<a
href="https://docs.nextcloud.com/server/latest/admin_manual/installation/source_installation.html#pretty-urls"
target="_blank"
rel="noopener noreferrer"
class="underline">Pretty URLs</a
> enabled.
</li>
</ul>
</Alert>
{/if}
{/snippet}
</OAuthClientConfig>
</div>
{/each}
</div>
{/if}
</div>
<ConfirmationModal {...confirmationModal.props} />