feat: let native triggers act through per-user or shared connections

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 c00f00057d
commit 18d0d3ccf0
59 changed files with 1914 additions and 984 deletions
@@ -0,0 +1,110 @@
<script lang="ts">
import { WorkspaceIntegrationService } from '$lib/gen'
import type { NativeServiceName, NativeTriggerConnection } from '$lib/gen/types.gen'
import { Button } from '$lib/components/common'
import Alert from '$lib/components/common/alert/Alert.svelte'
import Select from '$lib/components/select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import { Plug } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { connectNativeAccount, getServiceConfig } from './utils'
interface Props {
service: NativeServiceName
value: string | undefined
/** An existing trigger keeps the connection its webhook was registered under. */
locked?: boolean
disabled?: boolean
}
let { service, value = $bindable(), locked = false, disabled = false }: Props = $props()
const operatingWorkspace = useOperatingWorkspace()
const displayName = $derived(getServiceConfig(service)?.serviceDisplayName ?? service)
let connections = $state<NativeTriggerConnection[] | undefined>(undefined)
let connecting = $state(false)
async function load() {
try {
connections = await WorkspaceIntegrationService.listNativeTriggerConnections({
workspace: $operatingWorkspace!,
serviceName: service
})
} catch (err: any) {
connections = []
sendUserToast(`Failed to load ${displayName} connections: ${err.body ?? err.message}`, true)
}
if (!locked && value === undefined && connections.length > 0) {
value = connections[0].path
}
}
$effect(() => {
service
$operatingWorkspace
load()
})
async function connect() {
connecting = true
try {
const path = await connectNativeAccount($operatingWorkspace!, service)
await load()
value = path
sendUserToast(`${displayName} account connected`)
} catch (err: any) {
sendUserToast(`Could not connect ${displayName}: ${err.body ?? err.message}`, true)
} finally {
connecting = false
}
}
const items = $derived.by(() => {
const list = (connections ?? []).map((c) => ({ label: c.path, value: c.path }))
if (value && !list.some((i) => i.value === value)) {
list.push({ label: value, value })
}
return list
})
const unusable = $derived(
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>
{#if locked}
<p class="text-2xs text-secondary">
The webhook is registered under this account. To use another one, create a new trigger.
</p>
{/if}
{#if unusable}
<Alert type="warning" title="You cannot use this connection" size="xs">
Saving acts as the account at {value}. Ask its owner for read access to it, or create a new
trigger with your own account.
</Alert>
{/if}
</div>
@@ -0,0 +1,69 @@
<script lang="ts">
import { onMount } from 'svelte'
import { page } from '$app/state'
import { WorkspaceIntegrationService } from '$lib/gen'
import type { NativeServiceName } from '$lib/gen/types.gen'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { Loader2 } from 'lucide-svelte'
import { postNativeOAuthResult } from './utils'
interface Props {
/** The exact redirect URI the authorization request was made with. */
redirectUri: string
}
let { redirectUri }: Props = $props()
let outcome = $state<{ ok: true; path: string } | { ok: false; error: string } | undefined>()
// The signed state carries the workspace and service it was issued for, so this page works
// whatever workspace is selected and wherever the provider sends the user back.
function decodeState(state: string): { workspace: string; service: NativeServiceName } {
const payload = atob(state.split(':')[0].replace(/-/g, '+').replace(/_/g, '/'))
const [workspace, service] = payload.split(':')
return { workspace, service: service as NativeServiceName }
}
async function finish(): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
const error = page.url.searchParams.get('error')
const code = page.url.searchParams.get('code')
const state = page.url.searchParams.get('state')
if (error || !code || !state) {
return { ok: false, error: error ?? 'The provider returned no authorization code' }
}
try {
const { workspace, service } = decodeState(state)
const path = await WorkspaceIntegrationService.nativeTriggerServiceCallback({
workspace,
serviceName: service,
requestBody: { code, state, redirect_uri: redirectUri }
})
return { ok: true, path }
} catch (err: any) {
return { ok: false, error: err.body ?? err.message ?? String(err) }
}
}
onMount(async () => {
outcome = await finish()
postNativeOAuthResult(outcome)
if (window.opener) {
window.close()
}
})
</script>
<div class="max-w-lg mx-auto mt-16 px-4">
{#if outcome === undefined}
<div class="flex items-center gap-2 text-sm text-secondary">
<Loader2 class="animate-spin" size={16} />
Finishing the connection...
</div>
{:else if outcome.ok}
<Alert type="success" title="Account connected">
Saved as {outcome.path}. You can close this window.
</Alert>
{:else}
<Alert type="error" title="Could not connect the account">{outcome.error}</Alert>
{/if}
</div>
@@ -6,8 +6,10 @@
validateCommonFields,
getServiceConfig,
getTemplatePath,
saveNativeTriggerFromCfg
saveNativeTriggerFromCfg,
setNativeConnectionContext
} from './utils'
import NativeConnectionPicker from './NativeConnectionPicker.svelte'
import { usedTriggerKinds } from '$lib/stores'
import { emptyString, sendUserToast } from '$lib/utils'
import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights'
@@ -130,6 +132,8 @@
let externalError = $state<string | undefined>(undefined)
let retryEdit = $state<(() => void) | undefined>(undefined)
let enabled = $state(true)
let connectionPath = $state<string | undefined>(undefined)
setNativeConnectionContext(() => connectionPath)
export function openNew(
nis_flow?: boolean,
@@ -161,6 +165,7 @@
externalError = undefined
retryEdit = undefined
enabled = true
connectionPath = undefined
}
export function openRecreate(nativeTrigger: ExtendedNativeTrigger) {
@@ -190,6 +195,7 @@
externalError = undefined
retryEdit = undefined
enabled = nativeTrigger.enabled
connectionPath = nativeTrigger.connection_path ?? undefined
}
export async function openEdit(
@@ -224,6 +230,7 @@
initialScriptPath = ''
summary = ''
enabled = true
connectionPath = undefined
try {
const fullTrigger = await NativeTriggerService.getNativeTrigger({
@@ -241,6 +248,7 @@
externalData = fullTrigger.external_data
externalError = fullTrigger.external_error ?? undefined
enabled = fullTrigger.enabled
connectionPath = fullTrigger.connection_path ?? undefined
// Apply default values if provided (for draft triggers)
if (defaultValues) {
@@ -268,7 +276,8 @@
script_path: scriptPath,
is_flow: isFlow,
service_config: serviceConfig,
summary: summary !== '' ? summary : undefined
summary: summary !== '' ? summary : undefined,
connection_path: connectionPath
}
}
@@ -313,7 +322,8 @@
loadingForm ||
!can_write ||
!hasChanged ||
loadError !== undefined
loadError !== undefined ||
(isNew && !connectionPath)
)
const saveCfg = $derived.by(getSaveCfg)
@@ -562,6 +572,17 @@
</Section>
{/if}
{#if !loadingConfig}
<Section label="{serviceInfo?.serviceDisplayName} account">
<NativeConnectionPicker
{service}
bind:value={connectionPath}
locked={!isNew}
disabled={loading || !can_write}
/>
</Section>
{/if}
{#if loadingConfig}
<Section label="{serviceInfo?.serviceDisplayName} configuration">
<div class="flex items-center gap-2 text-secondary text-xs">
@@ -569,20 +590,25 @@
Loading configuration from {serviceInfo?.serviceDisplayName}...
</div>
</Section>
{:else if isNew && !connectionPath}
<!-- The pickers browse the service as the chosen account, so there is nothing to
configure until one is chosen. -->
{:else if ServiceFormComponent}
<ServiceFormComponent
bind:loading={loadingForm}
bind:this={serviceFormRef}
bind:serviceConfig
bind:errors
{externalData}
disabled={loading || loadingConfig || !can_write}
path={scriptPath}
{isFlow}
token=""
triggerTokens={undefined}
scopes={[]}
/>
{#key connectionPath}
<ServiceFormComponent
bind:loading={loadingForm}
bind:this={serviceFormRef}
bind:serviceConfig
bind:errors
{externalData}
disabled={loading || loadingConfig || !can_write}
path={scriptPath}
{isFlow}
token=""
triggerTokens={undefined}
scopes={[]}
/>
{/key}
{:else}
<Section label="{serviceInfo?.serviceDisplayName} configuration">
<div class="text-red-500 text-xs space-y-2">
@@ -7,8 +7,10 @@
import { sendUserToast } from '$lib/toast'
import { Loader2 } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useNativeConnection } from '../../utils'
const operatingWorkspace = useOperatingWorkspace()
const connectionPath = useNativeConnection()
interface Props {
serviceConfig: Record<string, any>
@@ -66,7 +68,8 @@
loading = true
try {
repos = await NativeTriggerService.listGithubRepos({
workspace: $operatingWorkspace
workspace: $operatingWorkspace,
connectionPath: connectionPath()
})
} catch (err: any) {
console.error('Failed to load GitHub repositories:', err)
@@ -6,8 +6,10 @@
import { Loader2, X, RefreshCw } from 'lucide-svelte'
import GoogleCalendarIcon from '$lib/components/icons/GoogleCalendarIcon.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useNativeConnection } from '../../utils'
const operatingWorkspace = useOperatingWorkspace()
const connectionPath = useNativeConnection()
interface Props {
calendarId: string
@@ -26,7 +28,8 @@
loading = true
try {
calendars = await NativeTriggerService.listGoogleCalendars({
workspace: $operatingWorkspace
workspace: $operatingWorkspace,
connectionPath: connectionPath()
})
if (calendarId && !calendarName) {
const found = calendars.find((c) => c.id === calendarId)
@@ -9,8 +9,10 @@
import { Debounced, watch } from 'runed'
import { untrack } from 'svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { useNativeConnection } from '../../utils'
const operatingWorkspace = useOperatingWorkspace()
const connectionPath = useNativeConnection()
interface Props {
resourceId: string
@@ -53,6 +55,7 @@
try {
const params: Parameters<typeof NativeTriggerService.listGoogleDriveFiles>[0] = {
workspace: $operatingWorkspace,
connectionPath: connectionPath(),
pageToken
}
@@ -98,7 +101,8 @@
try {
sharedDrives = await NativeTriggerService.listGoogleSharedDrives({
workspace: $operatingWorkspace
workspace: $operatingWorkspace,
connectionPath: connectionPath()
})
} catch (err: any) {
sendUserToast(`Failed to load shared drives: ${err.body || err.message}`, true)
@@ -6,10 +6,11 @@
import SchemaForm from '$lib/components/SchemaForm.svelte'
import Section from '$lib/components/Section.svelte'
import { Loader2 } from 'lucide-svelte'
import { getNextcloudSchema } from '../../utils'
import { getNextcloudSchema, useNativeConnection } from '../../utils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const connectionPath = useNativeConnection()
interface Props {
serviceConfig: Record<string, any>
@@ -49,7 +50,8 @@
eventsError = undefined
try {
const events = await NativeTriggerService.listNextCloudEvents({
workspace: $operatingWorkspace!
workspace: $operatingWorkspace!,
connectionPath: connectionPath()
})
availableEvents = events
serviceSchema = getNextcloudSchema(events)
@@ -2,8 +2,84 @@ import type { NativeServiceName, NativeTrigger, NativeTriggerData } from '$lib/g
import { isCloudHosted } from '$lib/cloud'
import { NativeTriggerService, WorkspaceIntegrationService } from '$lib/gen'
import { get, type Writable } from 'svelte/store'
import { getContext, setContext } from 'svelte'
import { sendUserToast } from '$lib/toast'
const CONNECTION_CONTEXT = Symbol('nativeTriggerConnection')
/** Makes the connection a trigger editor acts through available to the pickers of its form. */
export function setNativeConnectionContext(connectionPath: () => string | undefined) {
setContext(CONNECTION_CONTEXT, connectionPath)
}
/** The connection the service pickers browse through; undefined lets the server pick. */
export function useNativeConnection(): () => string | undefined {
return getContext<(() => string | undefined) | undefined>(CONNECTION_CONTEXT) ?? (() => undefined)
}
const CONNECT_MESSAGE = 'native_trigger_oauth'
export type NativeOAuthMessage =
| { type: typeof CONNECT_MESSAGE; ok: true; path: string }
| { type: typeof CONNECT_MESSAGE; ok: false; error: string }
/** The page the provider redirects to, as registered on the workspace's OAuth apps. */
export function nativeOAuthRedirectUri(service: NativeServiceName): string {
return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${service}`
}
/** Hands the outcome of a connect popup back to the page that opened it. */
export function postNativeOAuthResult(
result: { ok: true; path: string } | { ok: false; error: string }
) {
const message = { type: CONNECT_MESSAGE, ...result } as NativeOAuthMessage
window.opener?.postMessage(message, window.location.origin)
}
/**
* Connect an account in a popup and resolve with the path of the saved connection. The
* connection is saved at `resourcePath`, or at the caller's default path when omitted.
*/
export async function connectNativeAccount(
workspace: string,
service: NativeServiceName,
resourcePath?: string
): Promise<string> {
const authUrl = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({
workspace,
serviceName: service,
requestBody: { redirect_uri: nativeOAuthRedirectUri(service), resource_path: resourcePath }
})
const popup = window.open(authUrl, '_blank', 'popup,width=600,height=750')
if (!popup) {
throw new Error('The connection window was blocked. Allow popups for this site and retry.')
}
return new Promise((resolve, reject) => {
const onMessage = (event: MessageEvent<NativeOAuthMessage>) => {
if (event.origin !== window.location.origin || event.data?.type !== CONNECT_MESSAGE) return
cleanup()
if (event.data.ok) resolve(event.data.path)
else reject(new Error(event.data.error))
}
// The callback page posts its result and then closes itself, so a closed window may still
// have a message in flight: give it a moment before treating the close as a cancel.
const closedPoll = setInterval(() => {
if (popup.closed) {
clearInterval(closedPoll)
setTimeout(() => {
cleanup()
reject(new Error('The connection window was closed before finishing'))
}, 1000)
}
}, 500)
function cleanup() {
window.removeEventListener('message', onMessage)
clearInterval(closedPoll)
}
window.addEventListener('message', onMessage)
})
}
export interface NativeTriggerConfig {
readonly serviceDisplayName: string
readonly serviceKey: NativeServiceName
@@ -216,8 +292,13 @@ export async function saveNativeTriggerFromCfg(
service_config: triggerCfg.service_config,
summary: triggerCfg.summary
}
// Only a create can set it: an update ignores the field, and `setenabled` owns it thereafter.
const createBody: NativeTriggerData = { ...requestBody, enabled: triggerCfg.enabled ?? true }
// Only a create can set these: an update ignores both, `setenabled` owns `enabled` thereafter,
// and the connection stays the one the webhook was registered under.
const createBody: NativeTriggerData = {
...requestBody,
enabled: triggerCfg.enabled ?? true,
connection_path: triggerCfg.connection_path
}
const serviceName = NATIVE_TRIGGER_SERVICES[service].serviceDisplayName
@@ -4,22 +4,27 @@
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, X, ExternalLink, Cog, Plug } from 'lucide-svelte'
import { X, ExternalLink, Cog, Plug } 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'
import { WorkspaceIntegrationService, type NativeServiceName } from '$lib/gen'
import {
WorkspaceIntegrationService,
type NativeServiceName,
type NativeTriggerConnection
} from '$lib/gen'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
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 { page } from '$app/state'
import { goto } from '$app/navigation'
import {
connectNativeAccount,
nativeOAuthRedirectUri
} from '$lib/components/triggers/native/utils'
interface WorkspaceIntegration {
service_name: string
resource_path?: string
oauth_data: {
client_id: string
client_secret: string
@@ -89,16 +94,13 @@
}
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>>({})
let pendingCallback = $state<{
serviceName: NativeServiceName
code: string
state: string
workspace: string
} | null>(null)
// 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()
@@ -113,9 +115,9 @@
})
integrations = response.map((item) => ({
service_name: item.service_name,
resource_path: item.resource_path ?? undefined,
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)
@@ -124,48 +126,84 @@
}
}
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: `Disconnect ${displayName}?`,
confirmationText: 'Disconnect',
children: `This will delete all ${displayName} triggers associated with this integration. This action cannot be undone.`
title: `Remove the ${displayName} integration?`,
confirmationText: 'Remove',
children: `This removes the OAuth app configuration, every connected ${displayName} account and all ${displayName} triggers. This action cannot be undone.`
})
if (!confirmed) return
try {
await WorkspaceIntegrationService.deleteNativeTriggerService({
workspace: $workspaceStore,
serviceName: serviceName as any
serviceName: serviceName as NativeServiceName
})
sendUserToast(`${displayName} disconnected successfully`)
sendUserToast(`${displayName} integration removed`)
loadIntegrations()
} catch (err: any) {
sendUserToast(`Failed to disconnect ${displayName}: ${err.message}`, true)
sendUserToast(`Failed to remove ${displayName}: ${err.body ?? err.message}`, true)
}
}
async function connectService(serviceName: string, redirectUri: string) {
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 connect(serviceName: string) {
if (!$workspaceStore) return
connecting = serviceName
try {
const auth_url = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({
workspace: $workspaceStore,
serviceName: serviceName as any,
requestBody: { redirect_uri: redirectUri }
})
if (auth_url) {
window.location.href = auth_url
}
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.message}`,
`Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.body ?? err.message}`,
true
)
} finally {
connecting = null
}
}
@@ -176,7 +214,7 @@
try {
await WorkspaceIntegrationService.createNativeTriggerService({
workspace: $workspaceStore,
serviceName: serviceName as any,
serviceName: serviceName as NativeServiceName,
requestBody: oauthData
})
sendUserToast(
@@ -207,33 +245,9 @@
}
}
async function connectWithInstanceCredentials(serviceName: string) {
if (!$workspaceStore) return
connecting = serviceName
try {
const redirectUri = getRedirectUri(serviceName)
const auth_url = await WorkspaceIntegrationService.generateInstanceConnectUrl({
workspace: $workspaceStore,
serviceName: serviceName as NativeServiceName,
requestBody: { redirect_uri: redirectUri }
})
if (auth_url) {
window.location.href = auth_url
}
} catch (err: any) {
sendUserToast(
`Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`,
true
)
connecting = null
}
}
function isConfigured(integration: WorkspaceIntegration): boolean {
if (integration.oauth_data === null) return false
if (integration.oauth_data.instance_shared) return true
/** 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 (
@@ -243,82 +257,10 @@
)
}
function isConnected(integration: WorkspaceIntegration): boolean {
if (integration.oauth_data?.instance_shared) {
return !!integration.resource_path
}
return isConfigured(integration) && !!integration.resource_path
}
function getIntegrationByService(serviceName: string): WorkspaceIntegration | null {
return integrations.find((integration) => integration.service_name === serviceName) || null
}
function handleOAuthCallback(
workspace: string,
serviceName: NativeServiceName,
code: string,
state: string
) {
// Phase 1: store callback params and clean URL — don't call backend yet
pendingCallback = { serviceName, code, state, workspace }
// Pre-populate resource path from existing integration (for reconnect)
const integration = getIntegrationByService(serviceName)
resourcePath = integration?.resource_path ?? undefined
const url = new URL(page.url)
url.searchParams.delete('code')
url.searchParams.delete('state')
url.searchParams.delete('service')
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true })
}
async function finalizePendingCallback() {
if (!pendingCallback) return
const { serviceName, code, state, workspace } = pendingCallback
try {
const redirectUri = getRedirectUri(serviceName)
await WorkspaceIntegrationService.nativeTriggerServiceCallback({
serviceName,
workspace,
requestBody: {
code,
state,
redirect_uri: redirectUri,
resource_path: resourcePath
}
})
sendUserToast(`${supportedServices[serviceName]?.displayName} connected successfully!`)
await loadIntegrations()
} catch (err: any) {
sendUserToast(`Failed to complete OAuth connection: ${err.message}`, true)
} finally {
pendingCallback = null
resourcePath = undefined
pathError = undefined
}
}
$effect(() => {
if (
page.url.searchParams.has('code') &&
page.url.searchParams.has('state') &&
page.url.searchParams.has('service') &&
$workspaceStore
) {
const service = page.url.searchParams.get('service')! as NativeServiceName
const code = page.url.searchParams.get('code')!
const state = page.url.searchParams.get('state')!
handleOAuthCallback($workspaceStore, service, code, state)
}
})
function getRedirectUri(serviceName: string): string {
return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${serviceName}`
}
$effect(() => {
if ($workspaceStore) {
loadIntegrations()
@@ -330,48 +272,11 @@
<div class="flex flex-col">
<SettingsPageHeader
title="Native Triggers"
description="Connect your workspace to external services for native triggers and enhanced functionality. These connections are shared across all workspace members and are required for native triggers to work."
description="Configure the OAuth app of each service. Members then connect their own account, or one shared through a folder, and each trigger acts as the account it was created with."
link="https://www.windmill.dev/docs/core_concepts/native_triggers"
/>
{#if pendingCallback}
{@const serviceName = pendingCallback.serviceName}
{@const config = supportedServices[serviceName]}
<div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary">
<div class="text-sm font-semibold text-emphasis mb-2">
Save {config?.displayName ?? serviceName} credentials as resource
</div>
<div class="text-xs text-secondary mb-3">
Choose where to save the OAuth resource. This resource will store the access token for the
integration.
</div>
<Path
kind="resource"
initialPath={resourcePath ?? ''}
namePlaceholder={'native_' + serviceName}
bind:path={resourcePath}
bind:error={pathError}
/>
<div class="flex gap-2 mt-3">
<Button
variant="accent"
disabled={!resourcePath || !!pathError}
onclick={finalizePendingCallback}
>
Save
</Button>
<Button
onclick={() => {
pendingCallback = null
resourcePath = undefined
pathError = undefined
}}
>
Cancel
</Button>
</div>
</div>
{:else if loading}
{#if loading}
<div class="space-y-4">
{#each new Array(3) as _}
<Skeleton layout={[[6], 0.4]} />
@@ -382,12 +287,12 @@
{#each Object.entries(supportedServices) as [serviceName, config]}
{@const integration = getIntegrationByService(serviceName)}
{@const isConnecting = connecting === serviceName}
{@const isOAuthConfigured = integration && isConfigured(integration)}
{@const isServiceConnected = integration && isConnected(integration)}
{@const isShowingConfig = showingConfig === serviceName}
{@const workspaceApp = hasWorkspaceApp(integration)}
{@const usesInstanceApp = !workspaceApp && instanceSharingAvailable[serviceName]}
{@const serviceConnections = connections[serviceName] ?? []}
<div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 flex items-center justify-center">
<config.icon class="w-6 h-6" />
@@ -398,65 +303,37 @@
</div>
</div>
<div class="flex items-center gap-2">
{#if isServiceConnected}
<div class="flex items-center gap-1 text-green-600 text-xs">
<Check size={16} />
<span class="font-semibold">Connected</span>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
{#if workspaceApp || usesInstanceApp}
<Button
onclick={() =>
integration?.oauth_data?.instance_shared
? connectWithInstanceCredentials(serviceName)
: connectService(serviceName, getRedirectUri(serviceName))}
variant="default"
onclick={() => {
choosingPathFor = serviceName
resourcePath = undefined
}}
disabled={isConnecting}
startIcon={{ icon: Plug }}
>
{isConnecting ? 'Reconnecting...' : 'Reconnect'}
Connect account
</Button>
{/if}
<Button
variant="default"
onclick={() => (showingConfig = showingConfig === serviceName ? null : serviceName)}
startIcon={{ icon: Cog }}
>
Configure OAuth
</Button>
{#if integration?.oauth_data}
<Button
variant="default"
destructive
onclick={() => deleteIntegration(serviceName)}
startIcon={{ icon: X }}
>
Delete
</Button>
{:else if isOAuthConfigured}
<Button
variant="accent"
onclick={() => connectService(serviceName, getRedirectUri(serviceName))}
disabled={isConnecting}
startIcon={{ icon: Plug }}
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
<Button
destructive
onclick={() => deleteIntegration(serviceName)}
startIcon={{ icon: X }}
>
Delete
</Button>
{:else if instanceSharingAvailable[serviceName]}
<Button
variant="accent"
onclick={() => connectWithInstanceCredentials(serviceName)}
disabled={isConnecting}
startIcon={{ icon: Plug }}
>
{isConnecting ? 'Connecting...' : 'Connect'}
</Button>
{:else}
<Button
variant="accent"
onclick={() =>
(showingConfig = showingConfig === serviceName ? null : serviceName)}
startIcon={{ icon: Cog }}
>
Configure OAuth
Remove
</Button>
{/if}
{#if config.docsUrl}
<Button href={config.docsUrl} target="_blank" startIcon={{ icon: ExternalLink }}>
Docs
@@ -465,25 +342,85 @@
</div>
</div>
{#if instanceSharingAvailable[serviceName] && !isOAuthConfigured}
{#if usesInstanceApp}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<Alert type="info" title="Redirect URI required">
<p class="text-sm mb-2">
Your instance admin has configured Google OAuth for native triggers. Before
connecting, ensure the following redirect URI has been added to the
<a
href="https://console.cloud.google.com/apis/credentials"
target="_blank"
rel="noopener noreferrer"
class="underline">Google Cloud Console</a
> by the instance admin:
</p>
<ClipboardPanel content={getRedirectUri(serviceName)} size="sm" />
</Alert>
{#if serviceName === 'google'}
<Alert type="info" title="Uses the instance OAuth app">
<p class="text-sm mb-2">
Your instance admin shares a Google OAuth app. Before connecting, ensure the
following redirect URI has been added to it in the
<a
href="https://console.cloud.google.com/apis/credentials"
target="_blank"
rel="noopener noreferrer"
class="underline">Google Cloud Console</a
>:
</p>
<ClipboardPanel content={nativeOAuthRedirectUri('google')} size="sm" />
</Alert>
{:else}
<Alert type="info" title="Uses the instance OAuth app">
Your instance admin shares a {config.displayName} OAuth app, so members can connect
their accounts without further setup.
</Alert>
{/if}
</div>
{/if}
{#if isShowingConfig}
{#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.
</div>
<Path
kind="resource"
initialPath=""
namePlaceholder={'native_' + serviceName}
bind:path={resourcePath}
bind:error={pathError}
/>
<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">
<span class="font-mono truncate">{connection.path}</span>
<div class="flex items-center gap-2 shrink-0">
{#if connection.owner}
<span class="text-secondary">connected by {connection.owner}</span>
{/if}
<Button
unifiedSize="xs"
variant="subtle"
destructive
onclick={() => disconnect(serviceName, connection.path)}
>
Disconnect
</Button>
</div>
</div>
{/each}
</div>
{/if}
{#if showingConfig === serviceName}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
{#if serviceName === 'nextcloud'}
<Alert type="info" title="Requirements" class="mb-4">
@@ -516,9 +453,9 @@
{/if}
<OAuthClientConfig
{serviceName}
redirectUri={getRedirectUri(serviceName)}
redirectUri={nativeOAuthRedirectUri(serviceName as NativeServiceName)}
serviceDisplayName={config.displayName}
existingConfig={integration?.oauth_data}
existingConfig={workspaceApp ? integration?.oauth_data : null}
requiresBaseUrl={config.requiresBaseUrl !== false}
clientIdPlaceholder={config.clientIdPlaceholder}
clientSecretPlaceholder={config.clientSecretPlaceholder}
@@ -533,12 +470,6 @@
</div>
{/each}
</div>
{#if integrations.length === 0}
<Alert type="warning" title="No Integrations Connected">
Connect to external services above to enable native triggers for your workspace.
</Alert>
{/if}
{/if}
</div>
@@ -70,6 +70,9 @@
import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte'
import Trashbin from '$lib/components/settings/Trashbin.svelte'
import { untrack } from 'svelte'
import NativeOAuthCallback from '$lib/components/triggers/native/NativeOAuthCallback.svelte'
import { nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils'
import type { NativeServiceName } from '$lib/gen'
import DbtSettings, {
convertDbtSettingsFromBackend,
type DbtSettingsType
@@ -385,6 +388,13 @@
// The deploy filters configure what may be promoted into the parent, so they only mean
// something for a fork. A root workspace deploys nowhere by lineage.
const showDeployToTab = $derived(Boolean(currentWorkspace?.parent_workspace_id))
// The page native trigger OAuth providers redirect to. Anyone can connect an account, so the
// callback is handled before the admin gate.
const nativeOAuthService = $derived(
$page.url.searchParams.get('tab') === 'native_triggers' && $page.url.searchParams.has('state')
? ($page.url.searchParams.get('service') as NativeServiceName | null)
: null
)
const canAdmin = $derived(($userStore?.is_admin ?? false) || Boolean($superadmin))
let yamlMode = $state(false)
// A workspace where the user is not an admin hides the toggle, so it must not stay in YAML mode.
@@ -1532,7 +1542,9 @@
</script>
<CenteredPage wrapperClasses="pb-0 h-full" handleOverflow={false} class="flex flex-col h-full">
{#if canAdmin || isForkOwner}
{#if nativeOAuthService}
<NativeOAuthCallback redirectUri={nativeOAuthRedirectUri(nativeOAuthService)} />
{:else if canAdmin || isForkOwner}
<!-- No title of its own: the band already says which workspace's settings these are, and the
two controls that sat beside the title are the page's actions. The panes below scroll
inside themselves, so the bar keeps its edge at rest rather than only under a scroll. -->
@@ -0,0 +1,8 @@
<script lang="ts">
import { page } from '$app/state'
import NativeOAuthCallback from '$lib/components/triggers/native/NativeOAuthCallback.svelte'
</script>
<!-- Where native trigger connections made with the instance GitHub OAuth app land: that app only
redirects below its registered callback, /oauth/callback/github. -->
<NativeOAuthCallback redirectUri="{page.url.origin}/oauth/callback/github/native_trigger" />