mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 08:03:38 +00:00
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:
co-authored by
Claude Opus 5.5
parent
c00f00057d
commit
18d0d3ccf0
@@ -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">
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+4
-2
@@ -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" />
|
||||
Reference in New Issue
Block a user