Files
windmill/frontend/src/lib/components/AppConnectInner.svelte
T
Ruben FiszelandClaude Opus 4.8 07d4b674f1 fix: manual resource type sync fetches from hub first, cache as fallback (#10269)
* fix: manual resource type sync fetches from hub first, cache as fallback

The superadmin "Synchronize resource types" endpoint
(POST /api/settings/sync_cached_resource_types) was cache-first: it read the
on-disk hub_rt cache and only fell back to the hub when no cache file existed.
Since that cache is refreshed by a daily cron, a newly-published hub type could
not be pulled on demand, the button replayed the stale cache and reported
"Synced 0", so the type never landed in the admins workspace.

The manual endpoint is now hub-first: it fetches the live list and upserts it
into admins, falling back to reading the on-disk cache only when the hub is
unreachable (airgapped install / network error), logging which path it took.
The startup/offline sync in main.rs (SYNC_CACHED_RT + the cache-rt cron) stays
cache-based and owns writing the cache, so this endpoint never touches it.

Adds an optional `name` query param: when a specific type is requested and is
still absent from the hub after syncing, the endpoint returns an explicit
not-found instead of a silent "Synced 0". The three not-found frontend call
sites (ResourceForm, AppConnectInner, ApiConnectForm) thread the type name
through SyncResourceTypes; the global instance-settings button stays name-less.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: run whole-list sync before the targeted not-found check, word 404 by source

Address review nits: the optional `name` check ran before the upsert loop, so a
`?name=<absent>` request skipped the whole-list refresh; move it after the loop so
the sync always happens. Also word the not-found 404 by source, the cache-fallback
path (hub unreachable) no longer claims it checked the hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:09:24 +02:00

1288 lines
41 KiB
Svelte

<script lang="ts">
import { run } from 'svelte/legacy'
import { userStore, workspaceStore } from '$lib/stores'
import LabelsInput from './LabelsInput.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import {
OauthService,
ResourceService,
WorkspaceService,
VariableService,
type TokenResponse,
type ResourceType
} from '$lib/gen'
import { emptyString, truncateRev, urlize } from '$lib/utils'
import oauthConnectRegistry from '$oauth_connect_registry'
import { createEventDispatcher, onDestroy } from 'svelte'
import Path from './Path.svelte'
import { Button, RadioCard, Skeleton } from './common'
import ApiConnectForm from './ApiConnectForm.svelte'
import SearchItems from './SearchItems.svelte'
import WhitelistIp from './WhitelistIp.svelte'
import { sendUserToast } from '$lib/toast'
import OauthScopes from './OauthScopes.svelte'
import Markdown from 'svelte-exmarkdown'
import autosize from '$lib/autosize'
import { base } from '$lib/base'
import Required from './Required.svelte'
import Toggle from './Toggle.svelte'
import { Pen, Search } from 'lucide-svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import { apiTokenApps, forceSecretValue, linkedSecretValue } from './app_connect'
import type { SchemaProperty } from '$lib/common'
import TextInput from './text_input/TextInput.svelte'
import { sameTopDomainOrigin } from '$lib/cookies'
import SyncResourceTypes from './SyncResourceTypes.svelte'
import Label from './Label.svelte'
interface Props {
step?: number
resourceType?: string
isGoogleSignin?: boolean
disabled?: boolean
manual?: boolean
express?: boolean
workspace?: string
}
let {
step = $bindable(1),
resourceType = $bindable(''),
isGoogleSignin = $bindable(false),
disabled = $bindable(false),
manual = $bindable(true),
express = false,
workspace = undefined
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let isValid = $state(true)
const nativeLanguagesCategory = [
'postgresql',
'mysql',
'bigquery',
'snowflake',
'mssql',
'graphql',
'oracledb'
]
let filter = $state('')
let value: string = $state('')
let valueToken: TokenResponse | undefined = undefined
let connects: string[] | undefined = $state(undefined)
/** Per-provider instance-entry metadata, keyed by provider name. */
let connectsInfo: Record<
string,
{ supports_client_credentials: boolean; has_shared_credentials: boolean }
> = $state({})
/** An instance entry with shared credentials (admin id+secret): connect with
* no input. Shown under "Instance-configured"; bring-your-own-only providers
* (no shared creds) are shown under "Others" instead. */
function isSharedConnect(key: string): boolean {
return connectsInfo[key]?.has_shared_credentials ?? false
}
const SANDBOX_SUFFIX = '_sandbox'
function stripSandboxSuffix(name: string): string {
return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name
}
// `resourceType` is always the canonical type (e.g. `docusign`) so resource
// rows are uniform. `connectClient` carries the suffixed OAuth client name
// (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime
// and stored on `account.client` so token refresh hits the right endpoint.
let connectClient: string = $state('')
let connectsManual: { key: string; img?: string; instructions: string[] }[] | undefined =
$state(undefined)
let args: any = $state({})
let renderDescription = $state(true)
function computeCandidates(resourceType: string, argsKeys: string[], passwords: string[]) {
return apiTokenApps[resourceType]?.linkedSecret
? ([apiTokenApps[resourceType]?.linkedSecret] as string[])
: argsKeys.filter(
(x) =>
passwords.includes(x) ||
['token', 'secret', 'key', 'pass', 'private'].some((y) => x.toLowerCase().includes(y))
)
}
let linkedSecrets: string[] = $state([])
let linkedSecretCandidates: string[] | undefined = $state(undefined)
function computeDefaultLinkedSecrets(
resourceType: string,
argsKeys: string[],
passwords: string[]
): string[] {
linkedSecretCandidates = computeCandidates(resourceType, argsKeys, passwords)
const forced = forceSecretValue(resourceType)
if (forced) {
return [forced]
}
const best = linkedSecretCandidates?.sort(
(ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua)
)?.[0]
return best ? [best] : []
}
let scopes: string[] = $state([])
/** The authorization-code default scopes (instance entry / registry), kept so
* toggling back from client-credentials can restore them. */
let instanceScopes: string[] = $state([])
let extra_params: [string, string][] = []
let responseExtra: Record<string, string> = $state({})
let path: string = $state('')
let description = $state('')
let labels: string[] | undefined = $state(undefined)
let wsSpecific = $state(false)
let deployTo: string | undefined = $state(undefined)
/**
* Client credentials OAuth flow support
* @description Determines if the selected OAuth provider supports client_credentials grant type
* alongside the traditional authorization_code flow
*/
let supportsClientCredentials = $state(false)
/**
* OAuth flow selection
* @description Controls which OAuth flow to use:
* - false: authorization_code flow (interactive, requires user consent)
* - true: client_credentials flow (server-to-server, no user interaction)
*/
let useClientCredentials = $state(false)
/**
* Client credentials for resource-level OAuth
*/
let clientId = $state('')
let clientSecret = $state('')
let ccInstance = $state('')
/** Bring-your-own resource-level token endpoint override (optional). Only sent
* for non-instance-templated providers, where it isn't host-pinned. */
let tokenUrl = $state('')
let resourceTypeInfo: ResourceType | undefined = $state(undefined)
let resourceTypeNotFound = $state(false)
function registryEntry(): any {
const reg = oauthConnectRegistry as Record<string, any>
// Resolve `_sandbox` clients to their parent registry entry (e.g.
// salesforce_sandbox -> salesforce) so sandbox connections see CC metadata.
return reg[stripSandboxSuffix(connectClient)] ?? reg[stripSandboxSuffix(resourceType)]
}
/** The static registry declares this provider supports client credentials */
function registryCcCapable(): boolean {
return registryEntry()?.grant_types?.includes('client_credentials') ?? false
}
/** Instance-name metadata for providers whose token URL is instance-templated
* (carried in `connect_config_template`): the user enters an instance name
* instead of a full token URL, and the backend substitutes it into the
* fixed-host template so the exchange host stays pinned. */
let ccInstanceMeta = $derived(
registryEntry()?.connect_config_template as
| { label: string; placeholder: string; help_url?: string }
| undefined
)
/** Instance entry declares client credentials but not authorization_code
* (custom provider configured with only a token URL) */
let authCodeUnavailable = $state(false)
/** Instance entry carries shared client-credentials (id + secret); the user
* doesn't enter their own — the exchange runs server-side with those creds */
let ccInstanceConfigured = $state(false)
/** The user wants their own credentials (picked the provider from the "Others"
* section) — overrides the shared instance credentials for this connection */
let ccBringYourOwn = $state(false)
/** Connect with the shared instance credentials (no form) rather than the
* bring-your-own form */
let useSharedInstanceCreds = $derived(ccInstanceConfigured && !ccBringYourOwn)
/** Connectable via client credentials only: registry-declared provider with
* no instance OAuth client, or instance provider without an authorize URL */
let ccOnly = $derived.by(
() =>
authCodeUnavailable ||
(registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false))
)
/** Clear CC inputs and scopes so a previous selection never leaks into a new one */
function resetClientCredentialsState() {
supportsClientCredentials = false
useClientCredentials = false
authCodeUnavailable = false
ccInstanceConfigured = false
ccBringYourOwn = false
clientId = ''
clientSecret = ''
ccInstance = ''
tokenUrl = ''
scopes = []
}
/** Default scopes for the client-credentials grant. Registry providers use
* their `cc_scopes` (auth-code scopes are invalid in a 2-legged request);
* custom (non-registry) providers configured at the instance level have no
* registry entry, so they keep their admin-configured scopes (`instanceScopes`)
* instead of being zeroed. */
function defaultCcScopes(): string[] {
const entry = registryEntry()
return entry ? (entry.cc_scopes ?? []) : instanceScopes
}
function enableClientCredentials() {
manual = false
supportsClientCredentials = true
if (!useClientCredentials) {
// Switching into client-credentials: default to the CC scopes (never the
// authorization-code scopes — most providers reject member/consent scopes
// in a 2-legged request). Only reset on the transition so edits made while
// already in CC mode are preserved.
scopes = defaultCcScopes()
}
useClientCredentials = true
}
/** Switch to the browser sign-in (authorization-code) grant, restoring its
* default scopes when coming from the client-credentials grant. */
function selectAuthCodeGrant() {
if (useClientCredentials) {
scopes = instanceScopes
}
useClientCredentials = false
}
/** Static registry declares client-credentials support for `key`. */
function isCcCapable(key: string): boolean {
return (
(oauthConnectRegistry as Record<string, any>)[stripSandboxSuffix(key)]?.grant_types?.includes(
'client_credentials'
) ?? false
)
}
/** Step-1 "Others" selection: CC-capable resource types open the client-
* credentials form with the user's own credentials — even when the instance
* has shared ones (the "Instance-configured OAuth APIs" section is the entry
* point for those). Every other type opens the raw manual form. */
function selectFromOthers(key: string) {
connectClient = key
resourceType = key
resetClientCredentialsState()
// Registry CC providers and instance-configured providers that declare the
// client-credentials grant (incl. custom providers set up with only a token
// URL and no shared creds) open the bring-your-own form. Everything else is
// a manual resource.
if (isCcCapable(key) || (connectsInfo[key]?.supports_client_credentials ?? false)) {
ccBringYourOwn = true
enableClientCredentials()
} else {
manual = true
}
next()
}
let pathError = $state('')
export async function open(rt?: string) {
if (!rt) {
loadResourceTypes()
}
step = 1 //express && !manual ? 3 : 1
value = ''
description = ''
labels = undefined
wsSpecific = false
const rawRt = rt ?? ''
connectClient = rawRt
resourceType = stripSandboxSuffix(rawRt)
valueToken = undefined
resetClientCredentialsState()
await loadConnects()
const inConnects = connects?.includes(connectClient) ?? false
// Registry-declared client-credentials providers are connectable even
// without an instance OAuth client
manual = !inConnects && !(rt && registryCcCapable())
if (manual && express) {
dispatch('error', 'Express OAuth setup is not available for non OAuth resource types')
return
}
if (!inConnects && !manual && express) {
// Client-credentials connections need interactive credential entry
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
return
}
if (!inConnects && !manual) {
enableClientCredentials()
}
if (rt) {
if (!manual && express) {
await getScopesAndParams()
if (authCodeUnavailable) {
// No popup flow to drive express setup with
dispatch('error', 'Express OAuth setup is not available for client credentials providers')
return
}
step = 2
}
next()
}
}
async function loadConnects() {
if (!connects) {
try {
const list = (await OauthService.listOauthConnects())
.filter((x) => x.name != 'supabase_wizard')
.sort((a, b) => a.name.localeCompare(b.name))
connects = list.map((x) => x.name)
connectsInfo = Object.fromEntries(list.map((x) => [x.name, x]))
} catch (e) {
connects = []
connectsInfo = {}
console.error('Error loading OAuth connects', e)
}
}
}
run(() => {
isGoogleSignin =
step == 1 &&
(resourceType == 'google' ||
resourceType == 'gmail' ||
resourceType == 'gcal' ||
resourceType == 'gdrive' ||
resourceType == 'gsheets')
})
run(() => {
disabled =
(step == 1 && resourceType == '') ||
(step == 2 &&
(manual
? value == '' &&
args &&
args['token'] == '' &&
args['password'] == '' &&
args['api_key'] == '' &&
args['key'] == '' &&
linkedSecrets.length > 0
: useClientCredentials &&
!useSharedInstanceCreds &&
(clientId.trim() == '' ||
clientSecret.trim() == '' ||
(!!ccInstanceMeta && ccInstance.trim() == '')))) ||
step == 3 ||
(step == 4 && pathError != '') ||
!isValid
})
export async function loadResourceTypes() {
if (connectsManual) {
return
}
const availableRts = await ResourceService.listResourceTypeNames({
workspace: effectiveWorkspace
})
// "Others" lists every resource type — including instance-configured OAuth
// providers — so any of them can also be connected with the user's own
// credentials or manually, not only via the shared instance setup (same as
// the authorization-code behavior).
connectsManual = availableRts
.map(
(x) =>
({
key: x,
...(apiTokenApps[x] ?? {
instructions: '',
img: undefined,
linkedSecret: undefined
})
}) as { key: string; img?: string; instructions: string[] }
)
.sort((a, b) => a.key.localeCompare(b.key))
const filteredNativeLanguages = filteredConnectsManual?.filter(
(o) => nativeLanguagesCategory?.includes(o[0]) ?? false
)
try {
filteredConnectsManual = [
...(filteredNativeLanguages ?? []),
...(filteredConnectsManual ?? []).filter(
({ key }) => !nativeLanguagesCategory.includes(key)
)
]
} catch (e) {}
}
function popupListener(event) {
console.log('Received oauth popup message', event)
let data = event.data
if (!sameTopDomainOrigin(event.origin, window.location.origin)) {
console.log(
'Received oauth popup message from different origin',
event.origin,
window.location.origin
)
return
}
if (data.type == 'success' || data.type == 'error') {
window.removeEventListener('message', popupListener)
processPopupData(data)
}
}
function handleStorageEvent(event) {
if (event.key === 'oauth-callback') {
try {
processPopupData(JSON.parse(event.newValue))
console.log('OAuth from storage', event.newValue)
// Clean up
localStorage.removeItem('oauth-callback')
window.removeEventListener('storage', handleStorageEvent)
} catch (e) {
console.error('Error processing oauth-callback', e)
}
} else {
console.log('Storage event', event.key)
}
}
onDestroy(() => {
window.removeEventListener('message', popupListener)
window.removeEventListener('storage', handleStorageEvent)
})
$effect(() => {
if (!effectiveWorkspace) {
deployTo = undefined
return
}
WorkspaceService.getDeployTo({ workspace: effectiveWorkspace }).then((x) => {
deployTo = x.deploy_to
})
})
function processPopupData(data) {
console.log('Processing oauth popup data')
if (data.type === 'error') {
sendUserToast(data.error, true)
step = 2
} else if (data.type === 'success') {
connectClient = data.resource_type
resourceType = stripSandboxSuffix(connectClient)
value = data.res.access_token!
valueToken = data.res
responseExtra = data.extra ?? {}
step = 4
if (express) {
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
next()
}
}
}
async function getScopesAndParams() {
if (!connects?.includes(connectClient)) {
// No instance OAuth client (registry-declared CC-only provider):
// defaults come from the static registry instead.
instanceScopes = registryEntry()?.scopes ?? []
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
extra_params = []
supportsClientCredentials = registryCcCapable()
return
}
const connect = await OauthService.getOauthConnect({ client: connectClient })
instanceScopes = connect.scopes ?? []
extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][]
/**
* The CC flow is offered when the static registry declares it for the
* provider, or the admin enabled it on the instance entry (custom
* providers)
*/
supportsClientCredentials =
registryCcCapable() || (connect.grant_types?.includes('client_credentials') ?? false)
// Shared instance credentials: the user connects without entering any creds
ccInstanceConfigured = connect.client_credentials_configured ?? false
// Custom provider configured with only a token URL: no popup flow possible
authCodeUnavailable =
supportsClientCredentials && !(connect.grant_types?.includes('authorization_code') ?? true)
if (authCodeUnavailable) {
useClientCredentials = true
}
// Default scopes to the active grant: client-credentials uses the registry's
// cc_scopes (auth-code scopes are invalid in a 2-legged request), every other
// path keeps the instance entry's scopes. Applies to shared instance creds,
// not just bring-your-own. Switching grants resets to these defaults.
scopes = useClientCredentials ? defaultCcScopes() : instanceScopes
}
async function getResourceTypeInfo() {
try {
resourceTypeNotFound = false
resourceTypeInfo = await ResourceService.getResourceType({
workspace: effectiveWorkspace,
path: resourceType
})
const props: Record<string, SchemaProperty> = resourceTypeInfo?.schema?.['properties'] ?? {}
const newArgsKeys = Object.keys(props).filter((x) => props?.[x]?.type == 'string') ?? []
const passwords = newArgsKeys.filter((x) => {
return props?.[x]?.password
})
if (linkedSecrets.length === 0) {
linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords)
}
} catch (err) {
resourceTypeInfo = undefined
resourceTypeNotFound = true
}
}
export async function next() {
if (step == 1) {
linkedSecrets = []
if (manual) {
getResourceTypeInfo()
args = {}
} else {
getResourceTypeInfo()
getScopesAndParams()
}
step += 1
} else if (step == 2 && !manual) {
if (useClientCredentials) {
/**
* Client credentials flow: Direct API call to backend
* No popup window or user interaction required — the resource-level
* credentials are exchanged directly against the token URL
*/
try {
// Trim whitespace from credentials to avoid false negatives
const trimmedClientId = clientId.trim()
const trimmedClientSecret = clientSecret.trim()
const trimmedInstance = ccInstance.trim()
// Instance-templated providers collect an instance name; the backend
// builds the host-pinned token URL from it. Other registry providers
// need no URL input (the token URL comes from the registry).
const needsInstance = !!ccInstanceMeta
// Bring-your-own credentials are required unless the provider has
// shared instance credentials, in which case the exchange runs
// server-side with those and no input is collected here.
if (
!useSharedInstanceCreds &&
(!trimmedClientId || !trimmedClientSecret || (needsInstance && !trimmedInstance))
) {
sendUserToast(
needsInstance
? `Client ID, Client Secret and ${ccInstanceMeta?.label} are required for client credentials flow`
: 'Client ID and Client Secret are required for client credentials flow',
true
)
return
}
const tokenResponse = await OauthService.connectClientCredentials({
workspace: effectiveWorkspace,
client: connectClient,
requestBody: useSharedInstanceCreds
? { scopes: scopes }
: {
scopes: scopes,
cc_client_id: trimmedClientId,
cc_client_secret: trimmedClientSecret,
// Instance-templated providers are host-pinned via the instance
// name; only other providers accept a free-form token URL override.
...(needsInstance
? { cc_instance: trimmedInstance }
: tokenUrl.trim()
? { cc_token_url: tokenUrl.trim() }
: {})
}
})
// Process the token response like in popup flow
value = tokenResponse.access_token!
valueToken = {
...tokenResponse,
grant_type: 'client_credentials' // Mark this token as client_credentials
}
step = 4
if (express) {
path = `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}`
next()
}
} catch (error) {
sendUserToast(
`Failed to connect with client credentials: ${error.body || error.message}`,
true
)
}
} else {
/**
* Authorization code flow: Traditional OAuth popup window
* Requires user interaction and consent
* Opens popup for user to authenticate with OAuth provider
*/
const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin)
url.searchParams.append('scopes', scopes.join('+'))
if (extra_params.length > 0) {
extra_params.forEach(([key, value]) => url.searchParams.append(key, value))
}
window.addEventListener('message', popupListener)
window.addEventListener('storage', handleStorageEvent)
console.log('opening popup', url.toString())
window.open(url.toString(), '_blank', 'popup=true')
step += 1
}
} else {
if (!path) {
if (step == 2) return
throw Error('Path is not set')
}
// Check if variable paths already exist
if (!manual || linkedSecrets.length <= 1) {
const exists = await VariableService.existsVariable({
workspace: effectiveWorkspace,
path
})
if (exists) {
throw Error(`Variable at path ${path} already exists. Delete it or pick another path`)
}
} else {
for (const secretField of linkedSecrets) {
const varPath = `${path}_${secretField}`
const exists = await VariableService.existsVariable({
workspace: effectiveWorkspace,
path: varPath
})
if (exists) {
throw Error(
`Variable at path ${varPath} already exists. Delete it or pick another path`
)
}
}
}
let exists = await ResourceService.existsResource({
workspace: effectiveWorkspace,
path
})
if (exists) {
throw Error(`Resource at path ${path} already exists. Delete it or pick another path`)
}
// Per-instance OAuth providers (Snowflake, ServiceNow, …): fill the
// resource args from the connection's instance, per the registry
// template's resource_mapping (e.g. ServiceNow -> instance_url:
// https://{instance}.service-now.com). Bring-your-own carries the instance
// the user entered in `ccInstance` (raw, possibly a full host); the shared
// path carries it (already normalized) in the connect entry's extra_params.
// Prefer the user-entered one so the saved resource matches the exchange.
const connectTemplate = (oauthConnectRegistry as Record<string, any>)[resourceType]
?.connect_config_template
if (connectTemplate?.resource_mapping) {
const instanceKey = connectTemplate.extra_params_key ?? 'instance'
let instanceValue = extra_params.find(([key, _]) => key === instanceKey)?.[1] ?? ''
if (ccInstance.trim()) {
const stripSuffix = connectTemplate.strip_suffix as string | undefined
let v = ccInstance
.trim()
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
if (stripSuffix && v.endsWith(stripSuffix)) {
v = v.slice(0, -stripSuffix.length)
}
instanceValue = v.replace(/\.+$/, '')
}
if (instanceValue) {
for (const [argField, valueTemplate] of Object.entries(
connectTemplate.resource_mapping as Record<string, string>
)) {
args[argField] = valueTemplate.replaceAll('{instance}', instanceValue)
}
}
}
if (resourceType === 'quickbooks' && responseExtra['realmId']) {
args['realmId'] = responseExtra['realmId']
}
let account: number | undefined = undefined
if (valueToken?.expires_in != undefined) {
const accountData: any = {
refresh_token: valueToken.refresh_token ?? '',
expires_in: valueToken.expires_in,
client: connectClient,
grant_type: valueToken.grant_type || 'authorization_code'
}
// Store scopes so token refresh uses the same scopes
if (scopes.length > 0) {
accountData.scopes = scopes
}
// Client-credentials accounts are self-contained: the refresh worker
// re-exchanges using only what is stored on the account row. With
// shared instance credentials the backend copies them onto the row,
// so nothing is sent from here.
if (useClientCredentials && !useSharedInstanceCreds) {
accountData.cc_client_id = clientId.trim()
accountData.cc_client_secret = clientSecret.trim()
// Instance-templated providers send an instance name; the backend
// resolves and stores the host-pinned token URL. Other providers may
// send an optional token URL override (stored for refresh); without
// it the token URL comes from the registry/instance config.
if (ccInstanceMeta) {
accountData.cc_instance = ccInstance.trim()
} else if (tokenUrl.trim()) {
accountData.cc_token_url = tokenUrl.trim()
}
}
account = Number(
await OauthService.createAccount({
workspace: effectiveWorkspace,
requestBody: accountData
})
)
}
const resourceValue = args
let savedVariableCount = 0
if (!manual) {
// OAuth flow: single secret variable for the token
if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: effectiveWorkspace,
requestBody: {
path,
value: value,
is_secret: true,
description: emptyString(description)
? `OAuth token for ${resourceType}`
: description,
is_oauth: true,
account: account,
ws_specific: wsSpecific
}
})
resourceValue['token'] = `$var:${path}`
}
} else if (linkedSecrets.length === 1) {
// Single secret: use the resource path as variable name (original behavior)
const secretField = linkedSecrets[0]
const v = args[secretField]
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
savedVariableCount++
await VariableService.createVariable({
workspace: effectiveWorkspace,
requestBody: {
path,
value: v,
is_secret: true,
description: emptyString(description) ? `Token for ${resourceType}` : description,
is_oauth: false,
ws_specific: wsSpecific
}
})
resourceValue[secretField] = `$var:${path}`
}
} else if (linkedSecrets.length > 1) {
// Multiple secrets: append _field_name to each variable path
for (const secretField of linkedSecrets) {
const v = args[secretField]
if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) {
const varPath = `${path}_${secretField}`
savedVariableCount++
await VariableService.createVariable({
workspace: effectiveWorkspace,
requestBody: {
path: varPath,
value: v,
is_secret: true,
description: emptyString(description)
? `${secretField} for ${resourceType}`
: description,
is_oauth: false,
ws_specific: wsSpecific
}
})
resourceValue[secretField] = `$var:${varPath}`
}
}
}
await ResourceService.createResource({
workspace: effectiveWorkspace,
requestBody: {
resource_type: resourceType,
path,
value: resourceValue,
description,
labels,
ws_specific: wsSpecific
}
})
dispatch('refresh', path)
dispatch('close')
sendUserToast(
`Saved resource${savedVariableCount > 0 ? ` and ${savedVariableCount} variable${savedVariableCount > 1 ? 's' : ''}` : ''} path: ${path}`
)
step = 1
resourceType = ''
connectClient = ''
}
}
export async function back() {
if (step == 4) {
step -= 2
} else if (step > 1) {
step -= 1
}
if (step == 1) {
loadConnects()
loadResourceTypes()
}
}
const dispatch = createEventDispatcher<{ error: string; refresh: string; close: void }>()
let filteredConnects: { key: string }[] = $state([])
let filteredConnectsManual: { key: string; img?: string; instructions: string[] }[] = $state([])
let editScopes = $state(false)
</script>
{#if !express}
<SearchItems
{filter}
items={connects
? connects.filter(isSharedConnect).map((key) => ({
key
}))
: undefined}
bind:filteredItems={filteredConnects}
f={(x) => x.key}
/>
<SearchItems
{filter}
items={connectsManual}
bind:filteredItems={filteredConnectsManual}
f={(x) => x.key}
/>
{#if step == 1}
<div class="pb-2 my-1">
<div class="relative w-full">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 text-tertiary" size={14} />
<TextInput
inputProps={{ placeholder: 'Search resource type', id: 'search-resource-type' }}
bind:value={filter}
class="pl-7 text-xs w-full"
/>
</div>
</div>
<h2 class="mb-4 text-sm font-semibold text-emphasis">Instance-configured OAuth APIs</h2>
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center">
{#if filteredConnects}
{#each filteredConnects as { key }}
<Button
unifiedSize="md"
variant="default"
selected={key === connectClient}
on:click={() => {
manual = false
connectClient = key
resourceType = stripSandboxSuffix(key)
resetClientCredentialsState()
next()
}}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/each}
{:else}
{#each new Array(3) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
{#if connects && connects.filter(isSharedConnect).length == 0}
<div class="text-secondary text-xs w-full"
>No OAuth APIs have been set up on this instance. To add OAuth APIs, first sync the resource
types with the hub, then add OAuth configuration. See <a
href="https://www.windmill.dev/docs/misc/setup_oauth">documentation</a
>
</div>
{/if}
<h2 class="mt-8 mb-2 text-sm font-semibold text-emphasis">Others</h2>
{#if connectsManual && connectsManual?.length < 10}
<div class="text-secondary text-xs p-2">
Resource types have not been synced with the hub
</div>
{/if}
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as { key }}
{#if nativeLanguagesCategory.includes(key)}
<Button
unifiedSize="md"
variant="default"
selected={key === resourceType}
on:click={() => selectFromOthers(key)}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{/if}
{#if filteredConnectsManual}
{#each filteredConnectsManual as { key }}
{#if !nativeLanguagesCategory.includes(key)}
<!-- Exclude specific items -->
<Button
aiId={`app-connect-inner-${key}`}
aiDescription={`Connect to ${key}`}
unifiedSize="md"
variant="default"
selected={key === resourceType}
on:click={() => selectFromOthers(key)}
>
<IconedResourceType name={key} after={true} width="20px" height="20px" />
</Button>
{/if}
{/each}
{:else}
{#each new Array(9) as _}
<Skeleton layout={[[2]]} />
{/each}
{/if}
</div>
<div class="mt-6">
<SyncResourceTypes
onSynced={async () => {
connectsManual = undefined
await loadResourceTypes()
connects = undefined
await loadConnects()
}}
/>
</div>
{:else if step == 2 && manual}
<div class="flex flex-col gap-8">
<Label label="Path">
<Path
bind:error={pathError}
bind:path
initialPath=""
namePlaceholder={resourceType}
kind="resource"
/>
</Label>
<LabelsInput bind:labels class="-mt-5" />
{#if deployTo}
<Label
label="Workspace specific"
tooltip="Prevents this resource from being deployed to prod/staging"
>
<Toggle bind:checked={wsSpecific} />
</Label>
{/if}
{#if apiTokenApps[resourceType]}
<h2 class="mt-4 mb-2">Instructions</h2>
<div class="pl-10">
<ol class="list-decimal">
{#each apiTokenApps[resourceType].instructions as step}
<li>
{@html step}
</li>
{/each}
</ol>
</div>
{#if apiTokenApps[resourceType].img}
<div class="mt-4 w-full overflow-hidden">
<img
class="m-auto max-h-60"
alt="connect"
src={base + apiTokenApps[resourceType].img}
/>
</div>
{/if}
{:else if !emptyString(resourceTypeInfo?.description)}
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-emphasis">
{resourceTypeInfo?.name} description
</span>
<div class="text-xs text-primary font-normal">
<Markdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} />
</div>
</label>
{/if}
{#if resourceType == 'postgresql' || resourceType == 'mysql' || resourceType == 'mongodb'}
<WhitelistIp />
{/if}
<div class="flex flex-col gap-1">
<label class="inline-flex items-center gap-2" for="resource-description">
<span class="text-xs font-semibold text-emphasis">Resource description</span>
<Required required={false} />
<div class="flex gap-1 items-center">
<Toggle size="xs" bind:checked={renderDescription} />
<Pen size={14} />
</div>
</label>
{#if renderDescription}
<div>
<div class="flex flex-row-reverse text-2xs text-primary -mt-4">GH Markdown</div>
<textarea
id="resource-description"
use:autosize
bind:value={description}
placeholder={'Resource description'}
></textarea>
</div>
{:else if description == undefined || description == ''}
<div class="text-xs text-primary font-normal">No description provided</div>
{:else}
<GfmMarkdown md={description} />
{/if}
</div>
{#if resourceTypeNotFound}
<div class="flex flex-col gap-2 mb-4">
<p class="text-red-500 dark:text-red-400 text-xs">
Resource type '{resourceType}' not found in your workspace
</p>
<SyncResourceTypes {resourceType} onSynced={getResourceTypeInfo} />
</div>
{/if}
{#if registryCcCapable()}
<button
onclick={() => enableClientCredentials()}
class="text-xs font-normal text-accent w-fit -mt-4"
>
Acquire the token automatically via client credentials instead
</button>
{/if}
{#key resourceTypeInfo}
<ApiConnectForm
bind:linkedSecrets
bind:description
{linkedSecretCandidates}
{resourceType}
{resourceTypeInfo}
bind:args
bind:isValid
onSynced={getResourceTypeInfo}
/>
{/key}
</div>
{:else if step == 2 && !manual}
{#if manual == false && resourceType != ''}
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-1">
<h2 class="text-lg font-semibold text-emphasis">{resourceType}</h2>
<div class="text-primary font-normal text-xs"
>Create a resource backed by an OAuth connection, whose token is fetched from the
external services and refreshed automatically if needed before expiration.</div
>
{#if ccBringYourOwn}
<button
onclick={() => {
manual = true
useClientCredentials = false
}}
class="text-xs font-normal text-accent w-fit mt-2"
>
Create resource manually instead
</button>
{/if}
</div>
{#if resourceTypeInfo?.description}
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis">Description</h3>
<div class="text-xs text-primary font-normal">
<Markdown md={urlize(resourceTypeInfo?.description ?? '', 'md')} />
</div>
</div>
{/if}
<LabelsInput bind:labels class="-mt-5" />
{#if supportsClientCredentials}
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis mb-1">Authentication</h3>
{#if ccOnly || ccBringYourOwn}
<div class="text-xs text-secondary font-normal mb-2">
{#if useSharedInstanceCreds}
{resourceType} connects server-to-server using the credentials configured for this
instance. The token is acquired and refreshed automatically.
{:else}
{resourceType} connects server-to-server. Enter a client ID and secret; the token is
acquired and refreshed automatically.
{/if}
</div>
{:else}
<div class="flex flex-col gap-2 mb-2">
<RadioCard
label={`Sign in through ${resourceType}`}
description="Opens a browser window to log in and authorize. Connects as you."
selected={!useClientCredentials}
onSelect={selectAuthCodeGrant}
/>
<RadioCard
label={useSharedInstanceCreds
? 'Use the configured instance credentials'
: 'Use a client ID and secret'}
description={useSharedInstanceCreds
? "Runs server-to-server with this instance's credentials. No input needed."
: 'Runs server-to-server. Best for automation or service accounts.'}
selected={useClientCredentials}
onSelect={() => enableClientCredentials()}
/>
</div>
{/if}
{#if useClientCredentials && !useSharedInstanceCreds}
<form class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Client ID</span>
<TextInput
bind:value={clientId}
inputProps={{ placeholder: 'Enter OAuth client ID', required: true }}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Client secret</span>
<TextInput
inputProps={{
type: 'password',
placeholder: 'Enter OAuth client secret',
required: true
}}
bind:value={clientSecret}
/>
</label>
{#if ccInstanceMeta}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">{ccInstanceMeta.label}</span>
<div class="text-xs text-secondary font-normal">
Used to build this provider's token endpoint, stored with the connection for
automatic token refresh
</div>
<TextInput
inputProps={{ placeholder: ccInstanceMeta.placeholder, required: true }}
bind:value={ccInstance}
/>
</label>
{:else}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis"
>Token URL override (optional)</span
>
<div class="text-xs text-secondary font-normal">
Override the provider's token endpoint for this resource, stored with the
connection and reused on token refresh
</div>
<TextInput
inputProps={{
type: 'url',
placeholder: 'https://provider.example.com/oauth/token'
}}
bind:value={tokenUrl}
/>
</label>
{/if}
</form>
{/if}
</div>
{/if}
<div class="flex flex-col gap-1">
<h3 class="text-xs font-semibold text-emphasis flex gap-4"
>Scopes <button
onclick={() => {
editScopes = !editScopes
}}><Pen size={14} /></button
></h3
>
{#if editScopes}
<OauthScopes bind:scopes />
{:else}
<div class="flex flex-col gap-1">
{#each scopes as scope}
<div class="py-0.5 pl-2 text-xs">- {scope}</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
{:else if step == 3 && !manual && !express}
{#if useClientCredentials}
<span class="text-xs text-primary font-normal"> Connecting with client credentials... </span>
{:else}
<span class="text-xs text-primary font-normal"> Finish connection in popup window </span>
{/if}
{:else}
<Label label="Path">
<Path
initialPath=""
namePlaceholder={resourceType}
bind:error={pathError}
bind:path
kind="resource"
/>
</Label>
<LabelsInput bind:labels class="-mt-5" />
{#if deployTo}
<Label
label="Workspace specific"
tooltip="Prevents this resource from being deployed to prod/staging"
>
<Toggle bind:checked={wsSpecific} />
</Label>
{/if}
{#if apiTokenApps[resourceType] || !manual}
<ul class="mt-6">
<li class="text-xs text-primary font-normal">
1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? 'token'}
<span class="font-semibold text-emphasis">{truncateRev(value, 5, '*****')}</span>
will be stored a
<span class="font-mono whitespace-nowrap text-emphasis">{path}</span>.
</li>
<li class="mt-2 text-xs text-primary font-normal">
2. The resource containing that token will be stored at the same path <span
class="font-mono whitespace-nowrap text-emphasis">{path}</span
>. The Variable and Resource will be "linked together", they will be deleted and renamed
together.
</li></ul
>
{/if}
{/if}
{/if}