mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 16:03:06 +00:00
feat: configure a native trigger service's OAuth app in a dialog
The card states once which OAuth app is in use, with a single Configure action, and the form follows the design system. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b27dfac402
commit
e2174c8057
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { Button, Alert } from '$lib/components/common'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import Modal from '$lib/components/common/modal/Modal.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
import { Save, ExternalLink } from 'lucide-svelte'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
serviceName: string
|
||||
serviceDisplayName: string
|
||||
existingConfig?: {
|
||||
@@ -16,178 +19,135 @@
|
||||
redirectUri: string
|
||||
onConfigSaved: (oauthData: any) => Promise<void>
|
||||
requiresBaseUrl?: boolean
|
||||
baseUrlLabel?: string
|
||||
baseUrlPlaceholder?: string
|
||||
baseUrlSettingsPath?: string
|
||||
clientIdPlaceholder?: string
|
||||
clientSecretPlaceholder?: string
|
||||
setupInstructions?: string[]
|
||||
/** Shown above the form, for what the service needs before it can be connected. */
|
||||
requirements?: Snippet
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
serviceName,
|
||||
serviceDisplayName,
|
||||
existingConfig,
|
||||
onConfigSaved,
|
||||
redirectUri,
|
||||
requiresBaseUrl = true,
|
||||
baseUrlLabel = `${serviceDisplayName} base URL`,
|
||||
baseUrlPlaceholder = `https://your-${serviceName}.example.com`,
|
||||
baseUrlSettingsPath = '/settings/admin/security',
|
||||
clientIdPlaceholder = `Generated by ${serviceDisplayName} OAuth app`,
|
||||
clientSecretPlaceholder = `Generated by ${serviceDisplayName} OAuth app`,
|
||||
setupInstructions
|
||||
setupInstructions,
|
||||
requirements
|
||||
}: Props = $props()
|
||||
|
||||
let saving = $state(false)
|
||||
let clientId = $state('')
|
||||
let clientSecret = $state('')
|
||||
let baseUrl = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (existingConfig) {
|
||||
clientId = existingConfig.client_id
|
||||
clientSecret = existingConfig.client_secret
|
||||
baseUrl = existingConfig.base_url
|
||||
if (open) {
|
||||
clientId = existingConfig?.client_id ?? ''
|
||||
clientSecret = existingConfig?.client_secret ?? ''
|
||||
baseUrl = existingConfig?.base_url ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
let canSave = $derived(
|
||||
!saving &&
|
||||
!!clientId.trim() &&
|
||||
!!clientSecret.trim() &&
|
||||
(!requiresBaseUrl || !!baseUrl.trim())
|
||||
const canSave = $derived(
|
||||
!saving && !!clientId.trim() && !!clientSecret.trim() && (!requiresBaseUrl || !!baseUrl.trim())
|
||||
)
|
||||
|
||||
async function saveConfig() {
|
||||
if (!canSave) {
|
||||
sendUserToast('Please fill in all required fields', true)
|
||||
return
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return
|
||||
saving = true
|
||||
try {
|
||||
const oauthData: Record<string, string> = {
|
||||
await onConfigSaved({
|
||||
client_id: clientId.trim(),
|
||||
client_secret: clientSecret.trim(),
|
||||
base_url: requiresBaseUrl ? baseUrl.trim().replace(/\/+$/, '') : '',
|
||||
redirect_uri: redirectUri.trim()
|
||||
}
|
||||
|
||||
await onConfigSaved(oauthData)
|
||||
resetForm()
|
||||
})
|
||||
} catch (err: any) {
|
||||
sendUserToast(`Failed to configure OAuth client: ${err.message}`, true)
|
||||
sendUserToast(`Failed to save the OAuth app: ${err.message}`, true)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
clientId = ''
|
||||
clientSecret = ''
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-md font-medium text-primary">{serviceDisplayName} OAuth Client Configuration</h4
|
||||
>
|
||||
</div>
|
||||
<Modal
|
||||
bind:open
|
||||
title="{serviceDisplayName} OAuth app"
|
||||
description="Members connect their {serviceDisplayName} accounts through this app."
|
||||
cancelText="Cancel"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
{@render requirements?.()}
|
||||
|
||||
{#if !existingConfig}
|
||||
<Alert type="warning" title="OAuth client required">
|
||||
<p class="text-sm mb-2">
|
||||
Before you can connect to {serviceDisplayName}, you need to configure an OAuth client.
|
||||
</p>
|
||||
{#if setupInstructions}
|
||||
<ol class="list-decimal list-inside space-y-1 text-sm">
|
||||
{#if !existingConfig && setupInstructions}
|
||||
<Alert type="info" title="Create an OAuth app in {serviceDisplayName}">
|
||||
<ol class="list-decimal list-inside flex flex-col gap-1">
|
||||
{#each setupInstructions as instruction}
|
||||
<li>{@html instruction}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4">
|
||||
{#if requiresBaseUrl}
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label={baseUrlLabel} />
|
||||
<p class="text-xs font-normal text-secondary"
|
||||
>The base URL of your {serviceDisplayName} instance (without trailing slash)</p
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={baseUrl}
|
||||
placeholder={baseUrlPlaceholder}
|
||||
class="windmill-input"
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if baseUrl && baseUrlSettingsPath}
|
||||
{#if requiresBaseUrl}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">{serviceDisplayName} URL</div>
|
||||
<div class="text-xs text-secondary">The address of your {serviceDisplayName} server</div>
|
||||
<TextInput
|
||||
bind:value={baseUrl}
|
||||
inputProps={{ type: 'url', placeholder: baseUrlPlaceholder, disabled: saving }}
|
||||
/>
|
||||
{#if baseUrl && baseUrlSettingsPath}
|
||||
<div class="flex">
|
||||
<Button
|
||||
wrapperClasses="self-start"
|
||||
href={`${baseUrl}${baseUrlSettingsPath}`}
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
href={`${baseUrl.replace(/\/+$/, '')}${baseUrlSettingsPath}`}
|
||||
target="_blank"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
{serviceDisplayName} OAuth Settings
|
||||
Open the {serviceDisplayName} OAuth settings
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label="Redirect URI" />
|
||||
<p class="text-xs font-normal text-secondary"
|
||||
>Use this URL when creating the OAuth app in {serviceDisplayName}</p
|
||||
>
|
||||
<ClipboardPanel content={redirectUri} size="sm" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label="Client ID" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={clientId}
|
||||
placeholder={clientIdPlaceholder}
|
||||
class="windmill-input"
|
||||
disabled={saving}
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Redirect URI</div>
|
||||
<div class="text-xs text-secondary">
|
||||
Register this URL when creating the OAuth app in {serviceDisplayName}
|
||||
</div>
|
||||
<ClipboardPanel content={redirectUri} size="sm" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label label="Client secret" />
|
||||
<input
|
||||
type="password"
|
||||
bind:value={clientSecret}
|
||||
placeholder={clientSecretPlaceholder}
|
||||
class="windmill-input"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Client ID</div>
|
||||
<TextInput
|
||||
bind:value={clientId}
|
||||
inputProps={{ placeholder: clientIdPlaceholder, disabled: saving }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
color="blue"
|
||||
onclick={saveConfig}
|
||||
disabled={!canSave}
|
||||
startIcon={{ icon: Save }}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Client secret</div>
|
||||
<TextInput
|
||||
bind:value={clientSecret}
|
||||
inputProps={{ type: 'password', placeholder: clientSecretPlaceholder, disabled: saving }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if existingConfig}
|
||||
<Alert type="success" title="OAuth client configured">
|
||||
<p class="text-sm">
|
||||
OAuth client is configured for {serviceDisplayName}. You can now connect this workspace to
|
||||
{serviceDisplayName}.
|
||||
</p>
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet actions()}
|
||||
<Button variant="accent" disabled={!canSave} loading={saving} onclick={save}>Save</Button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { Button, Alert, Badge } from '$lib/components/common'
|
||||
import { Button, Alert } from '$lib/components/common'
|
||||
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
|
||||
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
@@ -93,7 +93,10 @@
|
||||
let integrations = $state<WorkspaceIntegration[]>([])
|
||||
let connections = $state<Record<string, NativeTriggerConnection[]>>({})
|
||||
let loading = $state(false)
|
||||
let showingConfig = $state<string | null>(null)
|
||||
// The service whose OAuth app dialog is open.
|
||||
let configOpen = $state<Record<string, boolean>>(
|
||||
Object.fromEntries(Object.keys(supportedServices).map((s) => [s, false]))
|
||||
)
|
||||
let instanceSharingAvailable = $state<Record<string, boolean>>({})
|
||||
let confirmationModal = createAsyncConfirmationModal()
|
||||
|
||||
@@ -199,7 +202,7 @@
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
showingConfig = serviceName
|
||||
configOpen[serviceName] = true
|
||||
}
|
||||
|
||||
async function createOrUpdateIntegration(serviceName: string, oauthData: any) {
|
||||
@@ -301,131 +304,69 @@
|
||||
{@const usesInstanceApp = !workspaceApp && instanceSharingAvailable[serviceName]}
|
||||
{@const workspaceConnection = connections[serviceName]?.find((c) => c.workspace)?.path}
|
||||
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary">
|
||||
<div class="border border-light rounded-md p-4 bg-surface-tertiary flex flex-col gap-4">
|
||||
<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" />
|
||||
</div>
|
||||
<config.icon class="w-6 h-6 shrink-0" />
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm font-semibold text-emphasis">{config.displayName}</div>
|
||||
<div class="text-xs font-normal text-primary">{config.description}</div>
|
||||
<div class="text-xs text-secondary">{config.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if workspaceApp}
|
||||
<Badge color="green" small>Workspace OAuth app</Badge>
|
||||
{:else if usesInstanceApp}
|
||||
<Badge color="blue" small>Instance OAuth app</Badge>
|
||||
{:else}
|
||||
<Badge color="gray" small>Not set up</Badge>
|
||||
{/if}
|
||||
{#if config.docsUrl}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
href={config.docsUrl}
|
||||
target="_blank"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if config.docsUrl}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="subtle"
|
||||
href={config.docsUrl}
|
||||
target="_blank"
|
||||
startIcon={{ icon: ExternalLink }}
|
||||
>
|
||||
Docs
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2">
|
||||
<div class="border-t border-light pt-4 flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">OAuth app</div>
|
||||
<div class="text-xs text-secondary">
|
||||
{#if workspaceApp}
|
||||
Uses this workspace's {config.displayName} app.
|
||||
This workspace's own {config.displayName} app.
|
||||
{:else if usesInstanceApp}
|
||||
Uses the {config.displayName} app your instance admin shares.
|
||||
Shared by your instance admin. Configure one to use your own
|
||||
{config.displayName} app instead.
|
||||
{:else}
|
||||
Members cannot connect a {config.displayName} account until an OAuth app is set up.
|
||||
None yet. Members can connect {config.displayName} accounts once one is configured.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showingConfig !== serviceName}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
onclick={() => openConfig(serviceName, usesInstanceApp)}
|
||||
>
|
||||
{workspaceApp ? 'Edit' : usesInstanceApp ? 'Use your own app' : 'Set up'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
unifiedSize="sm"
|
||||
variant="default"
|
||||
onclick={() => openConfig(serviceName, usesInstanceApp)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
{#if usesInstanceApp && serviceName === 'google' && showingConfig !== serviceName}
|
||||
<div class="text-xs text-secondary">
|
||||
{#if usesInstanceApp && serviceName === 'google'}
|
||||
<div class="text-2xs text-hint">
|
||||
The instance app must list this redirect URI in the
|
||||
<a
|
||||
href="https://console.cloud.google.com/apis/credentials"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline">Google Cloud Console</a
|
||||
>:
|
||||
>
|
||||
</div>
|
||||
<ClipboardPanel content={nativeOAuthRedirectUri('google')} size="sm" />
|
||||
{/if}
|
||||
{#if showingConfig === serviceName}
|
||||
{#if serviceName === 'nextcloud'}
|
||||
<Alert type="info" title="Requirements">
|
||||
<p>Nextcloud integration requires:</p>
|
||||
<ul class="list-disc pl-4 mt-2 space-y-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 hover:text-blue-600"
|
||||
>
|
||||
Windmill integration app
|
||||
</a> to be installed on your Nextcloud instance.
|
||||
</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 hover:text-blue-600"
|
||||
>
|
||||
Pretty URLs
|
||||
</a>
|
||||
to be enabled on your Nextcloud instance.
|
||||
</li>
|
||||
</ul>
|
||||
</Alert>
|
||||
{/if}
|
||||
<OAuthClientConfig
|
||||
{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)
|
||||
showingConfig = null
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button unifiedSize="sm" variant="subtle" onclick={() => (showingConfig = null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if workspaceApp || usesInstanceApp}
|
||||
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 flex flex-col gap-1">
|
||||
<div class="border-t border-light pt-4 flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">Workspace connection</div>
|
||||
<div class="text-xs text-secondary mb-1">
|
||||
<div class="text-xs text-secondary">
|
||||
Members can create triggers that act as this account, without access to its token.
|
||||
</div>
|
||||
<NativeConnectionPicker
|
||||
@@ -442,10 +383,8 @@
|
||||
{/if}
|
||||
|
||||
{#if workspaceApp}
|
||||
<div
|
||||
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="border-t border-light pt-4 flex items-center justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xs font-semibold text-emphasis">
|
||||
Remove {config.displayName} from this workspace
|
||||
</div>
|
||||
@@ -463,6 +402,49 @@
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user