mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-21 08:03:33 +00:00
486 lines
18 KiB
Svelte
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} />
|