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:
hugocasa
2026-10-16 14:36:10 +02:00
co-authored by Claude Opus 5.5
parent b27dfac402
commit e2174c8057
2 changed files with 158 additions and 216 deletions
@@ -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>