Files
windmill/frontend/src/lib/components/AuthSettings.svelte
T
hugocasaandClaude Opus 5 3f07a1a803 feat: let the global AI chat call connected MCP servers as the user (#10656)
* feat: let the global AI chat call connected MCP servers as the user

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

* fix: address review findings on the chat MCP tools

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

* feat: connect MCP servers from a predefined list in chat and agent steps

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

* feat: show the OAuth redirect URL in the instance connect settings

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

* fix: clarify the OAuth redirect URL copy in instance settings

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

* fix: match the instance settings warning style and drop the redirect tooltip

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

* fix: use the standard warning alert for the redirect url mismatch

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

* fix: correct the GitHub token guidance in the MCP registry

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

* feat: warn when an OAuth connect lacks the scopes an MCP server needs

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

* fix: request the connect's scopes when the oauth popup is opened directly

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

* feat: connect an oauth-app MCP server without leaving the panel

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

* fix: seed connect scopes from the instance config only

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

* feat: make the chat use only the MCP servers you turn on

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

* refactor: align the MCP connect UI with the design system

* feat: make a pasted url the default way to connect an mcp server

* feat: show provider icons on the suggested mcp servers

* fix: make both mcp sign-in paths behave the same and stop reloading on toggle

* fix: clarify the mcp tool step's server field and drop its info alert

* fix: name the mcp resource in the tool step and move the transport note into the connect box

* fix: drop the redundant description on the mcp resource field

* fix: make the mcp connections trigger icon-only

* fix: scope enabled mcp servers to the account and address review nits

* fix: wait for connect scopes and create session connections in the operating workspace

* feat: move mcp connections into the chat's plus menu and fix review findings

* fix: show mcp servers as checkboxes so off reads as a state

* feat: give menu rows an on/off switch and use it for mcp servers

* fix: lead the mcp menu rows with the switch

* feat: keep the menu open while toggling and simplify the connect card

* fix: ask for the server before the credential in the connect card

* fix: show one credential path at a time in the connect card

* fix: label the path field and move token guidance into its tooltip

* fix: open straight into connect and keep the server menu scannable

* feat: warn when an mcp connection lands outside your own space

* refactor: require the workspace on the mcp connect components and rename the oauth child

* fix: replace the oauth variable on reconnect and bound every mcp result

* feat: show a connected server's provider icon in the connections list

* feat: resolve mcp provider icons from the url and clarify the path field

* style: align the mcp connect card with the design system surfaces

* style: drop the redundant oauth support line and name the scopes oauth scopes

* feat: keep the mcp connect card open in the connections drawer

* feat: preopen the mcp connect card under the agent step resource picker

* feat: resolve a typed mcp url to its registry entry and describe the token field

* style: name both mcp connect actions connect

* style: name the mcp oauth actions connect with the provider

* style: say in the path description what the connect action will save

* style: name the resource type in the mcp connect path description

* feat: cache mcp provider icons and confirm disconnect in a modal

* fix: keep the mcp menu switches live and the disconnect modal above the drawer

* style: fall back to the plug icon in the mcp menu rows

* fix: never destroy a foreign variable or resource when connecting an mcp server

* fix: prove a token variable is ours before writing it and bound mcp search failures

* fix: pin an mcp oauth popup to the target it was opened for

* fix: bind an mcp credential to the server and popup it was requested for

* fix: bound mcp tool calls with a deadline and drop stale server listings

* fix: keep the disconnect confirmation handler returning void

* fix: tie the mcp tool cache to the resource revision and the grant to its scopes

* fix: verify mcp read-only server-side, keep oauth connector mounted

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:51:19 +02:00

741 lines
25 KiB
Svelte

<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import { Alert, Button, Tab, Tabs } from './common'
import OAuthSetting from '$lib/components/OAuthSetting.svelte'
import OktaSetting from './OktaSetting.svelte'
import Auth0Setting from './Auth0Setting.svelte'
import KeycloakSetting from './KeycloakSetting.svelte'
import CustomSso from './CustomSso.svelte'
import AuthentikSetting from '$lib/components/AuthentikSetting.svelte'
import AutheliaSetting from '$lib/components/AutheliaSetting.svelte'
import PocketIdSetting from '$lib/components/PocketIdSetting.svelte'
import KanidmSetting from '$lib/components/KanidmSetting.svelte'
import ZitadelSetting from '$lib/components/ZitadelSetting.svelte'
import NextcloudSetting from '$lib/components/NextcloudSetting.svelte'
import CustomOauth from './CustomOauth.svelte'
import Password from './Password.svelte'
import { capitalize, type Item } from '$lib/utils'
import ClipboardPanel from './details/ClipboardPanel.svelte'
import Toggle from './Toggle.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import DropdownV2 from './DropdownV2.svelte'
import { APP_TO_ICON_COMPONENT } from './icons'
import { ExternalLink, Plus, Circle, X } from 'lucide-svelte'
import AzureOauthSettings from './AzureOauthSettings.svelte'
import Tooltip from './Tooltip.svelte'
import { tick } from 'svelte'
import { Popover } from './meltComponents'
import SettingsPageHeader from './settings/SettingsPageHeader.svelte'
import oauthConnectRegistry from '$oauth_connect_registry'
interface Props {
// Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name
// keyed by provider, used to build their per-instance connect_config URLs.
// Required (and always bound by InstanceSettings) so it is never undefined.
instanceInputs: Record<string, string>
oauths?: Record<string, any>
requirePreexistingUserForOauth?: boolean
baseUrl?: string
scim?: import('svelte').Snippet
tab?: 'sso' | 'oauth' | 'scim'
hideTabs?: boolean
}
let {
instanceInputs = $bindable(),
oauths = $bindable(),
requirePreexistingUserForOauth = $bindable(),
baseUrl,
scim,
tab = $bindable('sso'),
hideTabs = false
}: Props = $props()
// The callback lands on a frontend route, so a base url that is not the origin
// the admin is browsing is almost always a misconfiguration.
let browserOrigin = typeof window !== 'undefined' ? window.location.origin : ''
let baseUrlMismatch = $derived(
!!baseUrl && !!browserOrigin && baseUrl.replace(/\/$/, '') !== browserOrigin
)
$effect(() => {
if (oauths == undefined) {
oauths = {}
}
if (requirePreexistingUserForOauth == undefined) {
requirePreexistingUserForOauth = false
}
})
const windmillBuiltinsBase = [
'azure_oauth',
'github',
'gitlab',
'bitbucket',
'slack',
'gsheets',
'gdrive',
'gmail',
'gcal',
'gforms',
'gcloud',
'gworkspace',
'basecamp',
'linkedin',
'quickbooks',
'visma',
'sage_intacct',
'spotify',
'teams',
'zoho',
'xero',
'apify',
'docusign',
'salesforce',
'outreach'
]
// Providers whose registry entry (`backend/oauth_connect.json`) carries a
// `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown
// entry and is treated as a builtin so we don't render the custom-URL form
// — the URLs come from the registry sandbox block. Derived at build time
// from the registry so adding a sandbox to a provider needs no frontend
// change.
const windmillBuiltinsWithSandbox = Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'sandbox' in cfg)
.map(([name]) => name)
// Per-instance providers (Snowflake, ServiceNow, …): registry entries that
// carry a `connect_config_template`. Derived from the registry so adding a
// new one needs only a JSON entry — they get a builtin tile + the generic
// instance-name input below, with no frontend change.
// Every per-instance templated provider gets a settings tile + instance input:
// authorization-code ones (ServiceNow) provide an `auth_url`, client-credentials-only
// ones (Coupa) provide only a `token_url`. The admin enters their instance host so
// the shared credentials point at the right endpoint.
const connectConfigTemplates: Record<string, any> = Object.fromEntries(
Object.entries(oauthConnectRegistry)
.filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg)
.map(([name, cfg]) => [name, (cfg as any).connect_config_template])
)
const windmillBuiltinsTemplated = Object.keys(connectConfigTemplates)
const windmillBuiltins = [
...windmillBuiltinsBase,
...windmillBuiltinsWithSandbox.map((n) => `${n}_sandbox`),
...windmillBuiltinsTemplated
]
/** Resolve a `<name>_sandbox` key to its parent registry entry (sandbox
* variants inherit the parent's grant_types), matching the connect dialog. */
function canonicalRegistryKey(name: string): string {
return name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name
}
/** The static registry declares client credentials for this provider */
function registryCcCapable(name: string): boolean {
return (
(oauthConnectRegistry as Record<string, any>)[
canonicalRegistryKey(name)
]?.grant_types?.includes('client_credentials') ?? false
)
}
/** The static registry supports authorization code for this provider. A
* provider with no explicit grant_types defaults to authorization code. */
function registryAuthCodeCapable(name: string): boolean {
const reg = (oauthConnectRegistry as Record<string, any>)[canonicalRegistryKey(name)]
if (!reg) return false
return reg.grant_types ? reg.grant_types.includes('authorization_code') : true
}
/** Built-in provider that only supports client credentials (e.g. Coupa): no
* authorization-code flow to choose, so the grant is fixed. */
function registryCcOnly(name: string): boolean {
return registryCcCapable(name) && !registryAuthCodeCapable(name)
}
/** Map the entry's grant_types to the single-select choice (so the segmented
* control always has exactly one selected and can never be empty) */
function grantChoice(name: string): string {
const gts = oauths?.[name]?.['grant_types'] ?? ['authorization_code']
const cc = gts.includes('client_credentials')
const ac = gts.includes('authorization_code')
if (cc && ac) return 'both'
if (cc) return 'client_credentials'
return 'authorization_code'
}
/** Set the grant types from the segmented choice. The instance credentials are
* then used for every selected grant — authorization-code popup and/or
* server-to-server. */
function setGrantChoice(name: string, choice: string) {
if (!oauths || !oauths[name]) return
oauths[name]['grant_types'] =
choice === 'both' ? ['authorization_code', 'client_credentials'] : [choice]
}
let showCustomOAuthForm = $state(false)
let customOAuthName = $state('')
let customNameInput = $state<HTMLInputElement>()
let dropdownOpen = $state(false)
let ssoPopoverOpen = $state(false)
let ssoClientName = $state('')
let ssoNameInput = $state<HTMLInputElement>()
function createOAuthClient(name: string) {
if (oauths && name) {
// Create a new object to ensure the new item is added at the end
const newOauths = { ...oauths }
newOauths[name] = {
id: '',
secret: '',
grant_types: registryCcOnly(name) ? ['client_credentials'] : ['authorization_code']
}
oauths = newOauths
dropdownOpen = false
}
}
function handleCustomOAuthClient() {
showCustomOAuthForm = true
customOAuthName = ''
dropdownOpen = false
// Focus the input on next tick
tick().then(() => {
customNameInput?.focus()
})
}
function submitCustomOAuthClient() {
const trimmedName = customOAuthName.trim()
if (trimmedName) {
createOAuthClient(trimmedName)
showCustomOAuthForm = false
customOAuthName = ''
}
}
function cancelCustomOAuthForm() {
showCustomOAuthForm = false
customOAuthName = ''
}
function handleCustomOAuthKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
submitCustomOAuthClient()
} else if (event.key === 'Escape') {
event.preventDefault()
cancelCustomOAuthForm()
}
}
function handleSsoPopoverOpen() {
ssoPopoverOpen = true
ssoClientName = ''
// Focus the input on next tick
tick().then(() => {
ssoNameInput?.focus()
})
}
function submitSsoClient() {
const trimmedName = ssoClientName.trim()
if (trimmedName && oauths) {
// Create a new object to ensure the new item is added at the end
const newOauths = { ...oauths }
newOauths[trimmedName] = { id: '', secret: '', login_config: {} }
oauths = newOauths
ssoPopoverOpen = false
ssoClientName = ''
}
}
function cancelSsoPopover() {
ssoPopoverOpen = false
ssoClientName = ''
}
function handleSsoKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
event.preventDefault()
submitSsoClient()
} else if (event.key === 'Escape') {
event.preventDefault()
cancelSsoPopover()
}
}
function getOAuthProviderIcon(name: string) {
// Sandbox variants share the parent provider's icon.
const lookup = name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name
// Handle special cases
if (lookup === 'teams') {
return APP_TO_ICON_COMPONENT.ms_teams_webhook
}
if (lookup === 'snowflake_oauth') {
return APP_TO_ICON_COMPONENT.snowflake
}
if (lookup === 'azure_oauth') {
return APP_TO_ICON_COMPONENT.azure
}
// Try direct mapping, fallback to Circle icon if not found
return APP_TO_ICON_COMPONENT[lookup as keyof typeof APP_TO_ICON_COMPONENT] || Circle
}
function generateOAuthDropdownItems(): Item[] {
const items: Item[] = []
// Add built-in providers that are not already configured
windmillBuiltinsBase.forEach((name) => {
// Only show providers that are not already in the oauths object
if (!oauths || !oauths[name]) {
const icon = getOAuthProviderIcon(name)
items.push({
displayName: capitalize(name),
action: () => createOAuthClient(name),
icon: icon
})
}
})
// Add sandbox variants for providers that have sandbox URLs in the registry
windmillBuiltinsWithSandbox.forEach((name) => {
const sandboxKey = `${name}_sandbox`
if (!oauths || !oauths[sandboxKey]) {
const icon = getOAuthProviderIcon(name)
items.push({
displayName: `${capitalize(name)} (sandbox)`,
action: () => createOAuthClient(sandboxKey),
icon: icon
})
}
})
// Add per-instance providers (registry entries with a connect_config_template)
windmillBuiltinsTemplated.forEach((name) => {
if (!oauths || !oauths[name]) {
const icon = getOAuthProviderIcon(name)
items.push({
// Prefer the template's display_name (properly cased, e.g. "ServiceNow")
// over capitalize(name) which yields "Servicenow"/"Snowflake_oauth".
displayName: connectConfigTemplates[name]?.display_name ?? capitalize(name),
action: () => createOAuthClient(name),
icon: icon
})
}
})
// Add custom option
items.push({
displayName: `Custom OAuth client ${!$enterpriseLicense ? '(requires ee)' : ''}`,
action: handleCustomOAuthClient,
disabled: !$enterpriseLicense
})
return items
}
</script>
{#if !hideTabs}
<div>
<Tabs bind:selected={tab} class="mb-4">
<Tab value="sso" label="SSO" />
<Tab value="oauth" label="OAuth" />
<Tab value="scim" label="SCIM/SAML" />
</Tabs>
</div>
{/if}
<div class="mb-6">
{#if oauths}
{#if tab === 'sso'}
<SettingsPageHeader
title="Single Sign-On"
description="Configure SSO providers to let users authenticate using their existing identity provider credentials. To test SSO, save the settings and try to login in an incognito window."
link="https://www.windmill.dev/docs/misc/setup_oauth#sso"
/>
{#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
<Alert type="info" title="Limited to 10 SSO users">
Without EE, the number of SSO users is limited to 10. SCIM/SAML is available on EE
</Alert>
<div class="mb-2"></div>
{/if}
<div class="flex gap-2 py-4">
<Toggle
options={{
right: 'Require users to have been added manually to Windmill to sign in through SSO'
}}
bind:checked={requirePreexistingUserForOauth}
/>
</div>
<div class="flex flex-col gap-4 py-4">
<OAuthSetting name="google" bind:value={oauths['google']} />
<OAuthSetting name="microsoft" bind:value={oauths['microsoft']} />
<OktaSetting bind:value={oauths['okta']} />
<Auth0Setting bind:value={oauths['auth0']} />
<OAuthSetting name="github" bind:value={oauths['github']} />
<OAuthSetting name="gitlab" bind:value={oauths['gitlab']} />
<OAuthSetting name="jumpcloud" bind:value={oauths['jumpcloud']} />
<KeycloakSetting bind:value={oauths['keycloak']} />
<AuthentikSetting bind:value={oauths['authentik']} />
<AutheliaSetting bind:value={oauths['authelia']} />
<PocketIdSetting bind:value={oauths['pocketid']} />
<KanidmSetting bind:value={oauths['kanidm']} />
<ZitadelSetting bind:value={oauths['zitadel']} />
<NextcloudSetting bind:value={oauths['nextcloud']} {baseUrl} />
{#each Object.keys(oauths) as k}
{#if !['authelia', 'authentik', 'google', 'microsoft', 'github', 'gitlab', 'jumpcloud', 'okta', 'auth0', 'keycloak', 'slack', 'kanidm', 'zitadel', 'nextcloud', 'pocketid'].includes(k) && oauths[k] && 'login_config' in oauths[k]}
{#if oauths[k]}
<div class="flex flex-col gap-2 pb-4">
<div class="flex flex-row items-center gap-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis">{k}</label>
<Button
variant="subtle"
destructive
iconOnly
unifiedSize="sm"
startIcon={{ icon: X }}
onclick={() => {
if (oauths) {
delete oauths[k]
oauths = { ...oauths }
}
}}
/>
</div>
<div class="p-4 rounded bg-surface-tertiary shadow-sm">
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Custom Name</span>
<input
type="text"
placeholder="Custom Name"
bind:value={oauths[k]['display_name']}
/>
</label>
<label class="block pb-6">
<span class="text-primary font-semibold text-xs">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={oauths[k]['id']} />
</label>
<label for="{k}_client_secret_sso" class="block pb-6">
<span class="text-primary font-semibold text-xs">Client Secret</span>
<Password
id="{k}_client_secret_sso"
placeholder="Client Secret"
bind:password={oauths[k]['secret']}
/>
</label>
{#if !windmillBuiltins.includes(k) && k != 'slack'}
<CustomSso bind:login_config={oauths[k]['login_config']} />
{/if}
</div>
</div>
{/if}
{/if}
{/each}
</div>
<div class="flex justify-start py-4">
<Popover placement="bottom-start" bind:isOpen={ssoPopoverOpen} onClose={cancelSsoPopover}>
{#snippet trigger()}
<Button
variant="default"
hover="yo"
size="sm"
endIcon={{ icon: Plus }}
disabled={!$enterpriseLicense}
onclick={handleSsoPopoverOpen}
>
Add custom SSO client {!$enterpriseLicense ? '(requires ee)' : ''}
</Button>
{/snippet}
{#snippet content()}
<div class="flex flex-col gap-2 p-4 min-w-64">
<div class="flex gap-2">
<input
type="text"
placeholder="Custom SSO client name"
bind:value={ssoClientName}
onkeydown={handleSsoKeydown}
class="flex-1 px-3 py-2 text-sm border rounded"
bind:this={ssoNameInput}
/>
<Button
size="sm"
variant="accent"
disabled={!ssoClientName.trim()}
onclick={submitSsoClient}
>
Add
</Button>
<Button size="sm" variant="subtle" onclick={cancelSsoPopover}>Cancel</Button>
</div>
</div>
{/snippet}
</Popover>
</div>
{:else if tab === 'oauth'}
<SettingsPageHeader
title="OAuth"
description="Connect third-party services like Slack, Teams or Google to let users authenticate directly from Windmill and automatically obtain access tokens."
link="https://www.windmill.dev/docs/misc/setup_oauth#oauth"
/>
<div class="h-1"></div>
<OAuthSetting login={false} name="slack" bind:value={oauths['slack']} />
<div class="h-6"></div>
<OAuthSetting login={false} name="teams" eeOnly={true} bind:value={oauths['teams']} />
<div class="h-6"></div>
{#each Object.keys(oauths) as k}
{#if oauths[k] && !(oauths[k] && 'login_config' in oauths[k])}
{#if !['slack', 'teams'].includes(k) && oauths[k]}
{@const IconComponent = getOAuthProviderIcon(k) as any}
{@const headerLabel = k.endsWith('_sandbox')
? `${k.slice(0, -'_sandbox'.length)} (sandbox)`
: k}
<div class="flex flex-col gap-2 pb-6">
<div class="flex flex-row items-center gap-2">
<IconComponent size={24} width="24" height="24" class="shrink-0" />
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-xs font-semibold text-emphasis">{headerLabel}</label>
<Button
variant="subtle"
destructive
onclick={() => {
if (oauths) {
delete oauths[k]
oauths = { ...oauths }
}
}}
iconOnly
unifiedSize="sm"
startIcon={{ icon: X }}
wrapperClasses="h-fit w-fit"
/>
</div>
<div class="p-4 border rounded-md flex flex-col gap-6">
<label>
<span class="text-primary font-semibold text-xs">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={oauths[k]['id']} />
</label>
<label for="{k}_client_secret_oauth">
<span class="text-primary font-semibold text-xs">Client Secret</span>
<Password
id="{k}_client_secret_oauth"
placeholder="Client Secret"
bind:password={oauths[k]['secret']}
/>
</label>
<div class="flex flex-col gap-1">
<span class="text-primary font-semibold text-xs">Redirect URL</span>
{#if !baseUrl}
<Alert type="warning" title="No instance base url configured" size="xs">
Set it in Core settings. The redirect url is built from it, and {k} needs the exact
value.
</Alert>
{:else}
<ClipboardPanel content="{baseUrl}/oauth/callback/{k}" size="sm" />
{/if}
{#if baseUrlMismatch}
<Alert
type="warning"
title="Does not match the url you are on ({browserOrigin})"
size="xs"
>
This is built from the instance base url. Update it in Core settings if it is
wrong, or {k} will reject the callback.
</Alert>
{/if}
</div>
<div class="flex flex-col gap-2 mb-2">
<span class="text-xs font-semibold text-emphasis">These credentials are for</span>
{#if !windmillBuiltins.includes(k) || (registryCcCapable(k) && registryAuthCodeCapable(k))}
<ToggleButtonGroup
selected={grantChoice(k)}
onSelected={(v) => setGrantChoice(k, v)}
>
{#snippet children({ item })}
<ToggleButton
value="authorization_code"
label="Authorization code"
showTooltipIcon
tooltip="Users sign in through a browser popup using this app's Client ID and Secret."
{item}
/>
<ToggleButton
value="client_credentials"
label="Client credentials"
showTooltipIcon
tooltip={`Server-to-server. Fill Client ID and Secret to share one service account for every connection, or leave them empty so each user brings their own.${!windmillBuiltins.includes(k) ? ' A Token URL is required below.' : ''}`}
{item}
/>
<ToggleButton
value="both"
label="Both"
showTooltipIcon
tooltip="Offer both flows; the same Client ID and Secret are used for each selected grant."
{item}
/>
{/snippet}
</ToggleButtonGroup>
{:else if registryCcCapable(k)}
<span class="text-xs text-secondary font-normal flex items-center gap-1">
Client credentials (server-to-server)
<Tooltip
>Fill Client ID and Secret to share one service account, or leave them empty
so each user brings their own.</Tooltip
>
</span>
{:else}
<span class="text-xs text-secondary font-normal"
>Authorization code (browser sign-in)</span
>
{/if}
</div>
{#if k === 'azure_oauth'}
<AzureOauthSettings bind:connect_config={oauths[k]['connect_config']} />
{:else if !windmillBuiltins.includes(k) && k != 'slack'}
<CustomOauth bind:connect_config={oauths[k]['connect_config']} />
{/if}
{#if connectConfigTemplates[k]}
{@const tmpl = connectConfigTemplates[k]}
<label class="block pb-2">
<span class="text-primary font-semibold text-xs flex gap-2 items-center">
{#if tmpl.help_url}
<a href={tmpl.help_url} target="_blank">{tmpl.label}</a><ExternalLink
size={12}
/>
{:else}
{tmpl.label}
{/if}
</span>
<input
type="text"
placeholder={tmpl.placeholder}
required={true}
bind:value={instanceInputs[k]}
/>
</label>
{/if}
{#if k === 'gworkspace'}
<div>
<Toggle
options={{
right:
'Allow workspace admins to setup Google native triggers using these credentials'
}}
checked={oauths[k]?.share_with_workspaces ?? false}
on:change={(e) => {
if (oauths && oauths[k]) {
oauths[k] = { ...oauths[k], share_with_workspaces: e.detail }
}
}}
/>
{#if oauths[k]?.share_with_workspaces}
<p class="text-xs text-tertiary mt-1">
Workspace admins will be able to connect Google native triggers without
configuring their own OAuth client. The credentials are not exposed to them.
</p>
<p class="text-xs text-tertiary mt-2">
Add the following redirect URI to
<a
href="https://console.cloud.google.com/apis/credentials"
target="_blank"
class="underline">Google Cloud Console</a
>:
</p>
<div class="mt-1">
<ClipboardPanel
content="{baseUrl}/workspace_settings?tab=native_triggers&service=google"
size="sm"
/>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}
{/if}
{/each}
{#if showCustomOAuthForm}
<div class="flex flex-col gap-2 p-4 border rounded-lg bg-surface">
<div class="flex gap-2">
<input
type="text"
placeholder="Custom OAuth client name"
bind:value={customOAuthName}
onkeydown={handleCustomOAuthKeydown}
class="flex-1 px-3 py-2 text-sm border rounded"
bind:this={customNameInput}
/>
<Button
size="sm"
variant="accent"
disabled={!customOAuthName.trim()}
onclick={submitCustomOAuthClient}
>
Add
</Button>
<Button size="sm" variant="subtle" onclick={cancelCustomOAuthForm}>Cancel</Button>
</div>
</div>
{:else}
<div class="flex justify-start">
<DropdownV2
placement="bottom-start"
items={generateOAuthDropdownItems}
btnText="Add OAuth client"
maxHeight="25vh"
customMenu={true}
bind:open={dropdownOpen}
>
{#snippet buttonReplacement()}
<Button variant="default" hover="yo" size="sm" endIcon={{ icon: Plus }}>
Add OAuth client
</Button>
{/snippet}
{#snippet menu()}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
style="max-height: 25vh;"
>
{#each generateOAuthDropdownItems() as item}
{@const IconComponent = item.icon}
<button
class="w-full px-4 py-2 text-left hover:bg-surface-hover transition-colors flex items-center gap-2"
class:opacity-50={item.disabled}
disabled={item.disabled}
onclick={item.action}
>
<IconComponent
size={14}
width="14"
height="14"
class="shrink-0 w-[14px] h-[14px]"
/>
<span class="text-xs font-normal text-primary truncate grow min-w-0">
{item.displayName}
</span>
</button>
{/each}
</div>
{/snippet}
</DropdownV2>
</div>
{/if}
{:else if tab == 'scim'}
<SettingsPageHeader
title="SCIM/SAML"
description="Set up SAML and SCIM to authenticate users using your identity provider."
link="https://www.windmill.dev/docs/misc/saml_and_scim"
/>
{@render scim?.()}
{/if}
{/if}
</div>