fix(frontend): settings redesign (#7406)

* improve collapsible link

* do not show superadmin ws link when already in it

* improve OAuth UI

* sso/oauth instance settings ui

* refactor instance settings alerts WIP

* Indexer and Oauth to brand guidelines

* refactor ws error handler page

* Create a tab SMTP in the Instance Settings

* Ractivity isssue fix for tabs

* nit

* Add smtp settings status in Error handler

* Add smtp configuration status

* Display teams connection status for instance alerts

* nit

* Add critical alerts description

* nit

* nit

* improve ee display

* nit

* nit

* fix typo

* nit

* restore vit config

---------

Co-authored-by: Alexander Petric <alex@windmill.dev>
This commit is contained in:
Guilhem
2025-12-19 21:33:03 +01:00
committed by GitHub
parent 077995064f
commit edd64be52d
41 changed files with 3145 additions and 1726 deletions
+73 -28
View File
@@ -6,6 +6,7 @@
import Tooltip from './Tooltip.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import TextInput from './text_input/TextInput.svelte'
interface Props {
value: any
@@ -57,9 +58,9 @@
/></label
>
{#if enabled}
<div class="p-2 rounded border">
<label class="block pb-2">
<div class="flex gap-2 items-end">
<div class="p-4 rounded-md border flex flex-col gap-6">
<label>
<div class="flex gap-2 items-start">
<div>
<ToggleButtonGroup
selected={value['custom'] ? 'custom' : 'org'}
@@ -73,44 +74,88 @@
{/snippet}
</ToggleButtonGroup>
</div>
<div class="grow">
<span class="text-primary font-semibold text-sm"
<div class="grow flex flex-col gap-1">
<input type="text" placeholder="yourorg" bind:value={value['domain']} />
<span class="text-hint font-normal text-2xs"
>{#if value['custom']}Custom ({'https://<domain>'}){:else}
Org ({'https://<your org>.auth0.com'}){/if}</span
>
<input type="text" placeholder="yourorg" bind:value={value['domain']} />
</div>
</div>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Client ID <Tooltip>Client ID credential of the auth0 service configuration</Tooltip
></span
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client ID </span>
<span class="text-secondary font-normal text-xs"
>Client ID credential of the auth0 service configuration</span
>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Client Secret <Tooltip>Client Secret of the auth0 service configuration</Tooltip></span
>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
From your Admin page, setup a Windmill application<br />
Create a new application<br />
For "application type" select "Regular Web Application"<br />
Copy down the "Client ID" and "Client Secret" and paste them into the fields above <br />
Under "Application URIs", set the following:<br />
a. Application Login URI: `BASE_URL/user/login`<br />
b. Allowed Callback URLs: `BASE_URL/user/login_callback/auth0`<br />
c. Allowed Logout URLs: `BASE_URL/auth/logout`<br />
d. Allowed Web Origins: `BASE_URL`<br />
e. Allowed Origins (CORS): `BASE_URL`<br />
<div class="text-xs text-primary border rounded-md p-4 space-y-3">
<div>
<strong>1. Create Application</strong>
<div class="ml-4 mt-1">
From your auth0 Admin page, setup a Windmill application:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li>Create a new application</li>
<li>For "application type" select <strong>Regular Web Application</strong></li>
<li
>Copy down the "Client ID" and "Client Secret" and paste them into the fields
above</li
>
</ul>
</div>
</div>
<div>
<strong>2. Application URIs Configuration</strong>
<div class="ml-4 mt-1">
Under "Application URIs", set the following:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li
><strong>Application Login URI:</strong>
<code class="bg-surface px-1 rounded text-xs">BASE_URL/user/login</code></li
>
<li
><strong>Allowed Callback URLs:</strong>
<code class="bg-surface px-1 rounded text-xs"
>BASE_URL/user/login_callback/auth0</code
></li
>
<li
><strong>Allowed Logout URLs:</strong>
<code class="bg-surface px-1 rounded text-xs">BASE_URL/auth/logout</code></li
>
<li
><strong>Allowed Web Origins:</strong>
<code class="bg-surface px-1 rounded text-xs">BASE_URL</code></li
>
<li
><strong>Allowed Origins (CORS):</strong>
<code class="bg-surface px-1 rounded text-xs">BASE_URL</code></li
>
</ul>
</div>
</div>
</div>
</CollapseLink>
</div>
+295 -96
View File
@@ -5,7 +5,6 @@
import OAuthSetting from '$lib/components/OAuthSetting.svelte'
import OktaSetting from './OktaSetting.svelte'
import Auth0Setting from './Auth0Setting.svelte'
import CloseButton from './common/CloseButton.svelte'
import KeycloakSetting from './KeycloakSetting.svelte'
import CustomSso from './CustomSso.svelte'
import AuthentikSetting from '$lib/components/AuthentikSetting.svelte'
@@ -14,11 +13,15 @@
import ZitadelSetting from '$lib/components/ZitadelSetting.svelte'
import NextcloudSetting from '$lib/components/NextcloudSetting.svelte'
import CustomOauth from './CustomOauth.svelte'
import { capitalize } from '$lib/utils'
import { capitalize, type Item } from '$lib/utils'
import Toggle from './Toggle.svelte'
import { ExternalLink, Plus } from 'lucide-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'
interface Props {
snowflakeAccountIdentifier?: string
@@ -74,16 +77,142 @@
'apify'
]
let oauth_name = $state(undefined)
let showCustomOAuthForm = $state(false)
let customOAuthName = $state('')
let customNameInput = $state<HTMLInputElement>()
let dropdownOpen = $state(false)
let clientName = $state('')
let resourceName = $state('')
let ssoPopoverOpen = $state(false)
let ssoClientName = $state('')
let ssoNameInput = $state<HTMLInputElement>()
let tab: 'sso' | 'oauth' | 'scim' = $state('sso')
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: ['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) {
// Handle special cases
if (name === 'teams') {
return APP_TO_ICON_COMPONENT.ms_teams_webhook
}
if (name === 'snowflake_oauth') {
return APP_TO_ICON_COMPONENT.snowflake
}
if (name === 'azure_oauth') {
return APP_TO_ICON_COMPONENT.azure
}
// Try direct mapping, fallback to Circle icon if not found
return APP_TO_ICON_COMPONENT[name as keyof typeof APP_TO_ICON_COMPONENT] || Circle
}
function generateOAuthDropdownItems(): Item[] {
const items: Item[] = []
// Add built-in providers that are not already configured
windmillBuiltins.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 custom option
items.push({
displayName: `Custom OAuth client ${!$enterpriseLicense ? '(requires ee)' : ''}`,
action: handleCustomOAuthClient,
disabled: !$enterpriseLicense
})
return items
}
</script>
<div>
<Tabs bind:selected={tab} class="mt-2 mb-4">
<Tabs bind:selected={tab} class="mb-4">
<Tab value="sso" label="SSO" />
<Tab value="oauth" label="OAuth" />
<Tab value="scim" label="SCIM/SAML" />
@@ -97,18 +226,26 @@
<Alert type="warning" 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="py-1"></div>
<div class="mb-2">
<span class="text-primary text-xs"
<div class="text-primary text-xs"
>When at least one of the below options is set, users will be able to login to Windmill
via their third-party account.
<br /> To test SSO, the recommended workflow is to to save the settings and try to login
in an incognito window.
<a target="_blank" href="https://www.windmill.dev/docs/misc/setup_oauth#sso">Learn more</a
></span
>
>
</div>
</div>
<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']} />
@@ -125,14 +262,19 @@
<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'].includes(k) && 'login_config' in oauths[k]}
{#if !['authelia', 'authentik', 'google', 'microsoft', 'github', 'gitlab', 'jumpcloud', 'okta', 'auth0', 'keycloak', 'slack', 'kanidm', 'zitadel', 'nextcloud'].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-md font-semibold text-primary">{k}</label>
<CloseButton
on:close={() => {
<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 }
@@ -140,7 +282,7 @@
}}
/>
</div>
<div class="p-2 border rounded">
<div class="p-4 border rounded">
<label class="block pb-2">
<span class="text-primary font-semibold text-xs">Custom Name</span>
<input
@@ -170,78 +312,101 @@
{/if}
{/each}
</div>
<div class="flex gap-2 py-4 whitespace-nowrap">
<input type="text" placeholder="client_id" bind:value={clientName} />
<Button
variant="accent"
hover="yo"
size="sm"
endIcon={{ icon: Plus }}
disabled={clientName == ''}
on:click={() => {
if (oauths) {
oauths[clientName] = { id: '', secret: '', login_config: {} }
}
clientName = ''
}}
>
Add custom SSO client {!$enterpriseLicense ? '(requires ee)' : ''}
</Button>
</div>
<div class="flex gap-2 py-4">
<Toggle
options={{
right: 'Require users to have been added manually to Windmill to sign in through OAuth'
}}
bind:checked={requirePreexistingUserForOauth}
/>
<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'}
<div class="mb-2">
<span class="text-primary text-xs"
>When one of the below options is set, you will be able to create a specific resource
containing a token automatically generated by the third-party provider.
<br />
To test it after setting an oauth client, go to the Resources menu and create a new one of
the type of your oauth client (i.e. a 'github' resource if you set Github OAuth).
<br /><a target="_blank" href="https://www.windmill.dev/docs/misc/setup_oauth#oauth"
>Learn more</a
></span
<div class="text-primary text-xs"
>Connect third-party services like Slack, Teams or Google to let users authenticate
directly from Windmill and automatically obtain access tokens. Once configured, users can
create resources of the corresponding type (e.g. a 'github' resource) and authenticate via
OAuth without manually handling credentials.
<a
target="_blank"
href="https://www.windmill.dev/docs/misc/setup_oauth#oauth"
class="inline-flex items-center whitespace-nowrap"
>Learn more&nbsp;<ExternalLink size={12} /></a
></div
>
</div>
<div class="py-1"></div>
<div class="h-1"></div>
<OAuthSetting login={false} name="slack" bind:value={oauths['slack']} />
<div class="py-1"></div>
<div class="h-6"></div>
<OAuthSetting login={false} name="teams" eeOnly={true} bind:value={oauths['teams']} />
<div class="py-1"></div>
<div class="h-6"></div>
{#each Object.keys(oauths) as k}
{#if oauths[k] && !('login_config' in oauths[k])}
{#if oauths[k] && !(oauths[k] && 'login_config' in oauths[k])}
{#if !['slack', 'teams'].includes(k) && oauths[k]}
<div class="flex flex-col gap-2 pb-4">
{@const IconComponent = getOAuthProviderIcon(k) as any}
<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-md font-semibold text-primary">{k}</label>
<CloseButton
on:close={() => {
<label class="text-xs font-semibold text-emphasis">{k}</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-2 border rounded">
<label class="block pb-2">
<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 class="block pb-2">
<label>
<span class="text-primary font-semibold text-xs">Client Secret</span>
<input type="text" placeholder="Client Secret" bind:value={oauths[k]['secret']} />
</label>
{#if k === 'visma' || !windmillBuiltins.includes(k)}
<div style="margin-bottom: 8px;">
<div class="mb-8">
<div style="display: flex; align-items: center; gap: 8px;">
<input
type="checkbox"
@@ -263,13 +428,13 @@
}
} else {
oauths[k]['grant_types'] = oauths[k]['grant_types'].filter(
(gt) => gt !== 'client_credentials'
(gt: string) => gt !== 'client_credentials'
)
}
}
}}
/>
<span style="font-size: 14px; font-weight: 600;"
<span class="text-xs font-semibold text-emphasis"
>Support Client Credentials Flow</span
>
<Tooltip>
@@ -310,38 +475,72 @@
{/if}
{/each}
<div class="flex gap-2">
<select name="oauth_name" id="oauth_name" bind:value={oauth_name}>
<option value={undefined}>Select an OAuth client</option>
<option value="custom">Fully Custom (requires ee)</option>
{#each windmillBuiltins as name}
<option value={name}>{capitalize(name)}</option>
{/each}
</select>
{#if oauth_name == 'custom'}
<input type="text" placeholder="client_id" bind:value={resourceName} />
{:else}
<input type="text" value={oauth_name ?? ''} disabled />
{/if}
<Button
variant="accent"
hover="yo"
size="sm"
endIcon={{ icon: Plus }}
disabled={!oauth_name ||
(oauth_name == 'custom' && resourceName == '') ||
(oauth_name == 'custom' && !$enterpriseLicense)}
on:click={() => {
if (oauths) {
let name = oauth_name == 'custom' ? resourceName : oauth_name
oauths[name ?? ''] = { id: '', secret: '', grant_types: ['authorization_code'] }
}
resourceName = ''
}}
>
Add OAuth client {oauth_name == 'custom' && !$enterpriseLicense ? '(requires ee)' : ''}
</Button>
</div>
{#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'}
{@render scim?.()}
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
export let value: any
@@ -45,24 +46,34 @@
/></label
>
{#if enabled}
<div class="border rounded p-2">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Authelia Url ({'AUTHELIA_URL/api/oidc/authorization'})</span
<div class="border rounded p-4 flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Authelia Url</span>
<span class="text-secondary font-normal text-xs"
>{'AUTHELIA_URL/api/oidc/authorization'}</span
>
<input type="text" placeholder="yourorg" bind:value={org} />
<TextInput inputProps={{ type: 'text', placeholder: 'yourorg' }} bind:value={org} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Id</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Secret </span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
</div>
{/if}
@@ -46,23 +46,24 @@
/></label
>
{#if enabled}
<div class="border rounded p-2">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Authentik Url ({'AUTHENTIK_HOST/application/o/authorize/'})</span
<div class="border rounded p-4 flex flex-col gap-6">
<label>
<span class="text-emphasis font-semibold text-xs">Authentik Url</span>
<span class="text-secondary font-normal text-xs"
>({'AUTHENTIK_HOST/application/o/authorize/'})</span
>
<input type="text" placeholder="Authentik base url" bind:value={org} required />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<label>
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
<label>
<span class="text-emphasis font-semibold text-xs">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Secret </span>
<label>
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
</label>
</div>
@@ -35,15 +35,19 @@
}
</script>
<label class="block pb-2" for="tenant-id">
<span class="text-primary font-semibold text-sm flex gap-2 items-center">
<label class="flex flex-col gap-1" for="tenant-id">
<span class="text-primary font-semibold text-xs flex gap-2 items-center"> Azure tenant id </span>
<span class="text-secondary font-normal text-xs">
Identifies the specific Microsoft Entra ID tenant for authentication. Controls who can sign into
the application and determines the organizational boundary for OAuth requests.
<a
href="https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app"
target="_blank"
class="inline-flex items-center gap-1 whitespace-nowrap"
>
Azure Tenant Id
Learn more
<ExternalLink size={12} />
</a>
<ExternalLink size={12} />
</span>
<input
id="tenant-id"
@@ -53,8 +57,8 @@
bind:value={connect_config.extra_params.tenant_id}
/>
</label>
<label class="block pb-2" for="scopes-label">
<span class="text-primary font-semibold text-sm">Scopes</span>
<label class="flex flex-col gap-1" for="scopes-label">
<span class="text-primary font-semibold text-xs">Scopes</span>
<div id="scopes-label">
<OauthScopes bind:scopes={connect_config.scopes} />
</div>
@@ -3,6 +3,7 @@
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { RefreshCcw } from 'lucide-svelte'
import { Button } from './common'
interface ChannelItem {
channel_id?: string
@@ -45,7 +46,10 @@
let displayChannels = $derived.by(() => {
const baseChannels = channels || loadedChannels
if (selectedChannel && !baseChannels.find((c) => c.channel_id === selectedChannel?.channel_id)) {
if (
selectedChannel &&
!baseChannels.find((c) => c.channel_id === selectedChannel?.channel_id)
) {
return [selectedChannel, ...baseChannels]
}
return baseChannels
@@ -117,7 +121,7 @@
<div class={containerClass}>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<div class="flex-grow" style="min-width: {minWidth};">
{#if searchMode}
<Select
@@ -130,7 +134,8 @@
}))}
placeholder={isFetching ? 'Loading...' : teamId ? placeholder : 'Select a team first'}
clearable
disabled={disabled || isFetching || !teamId}
disabled={disabled || !teamId}
loading={isFetching}
bind:value={selectedChannelId}
/>
{:else}
@@ -151,14 +156,15 @@
</div>
{#if showRefreshButton && searchMode}
<button
<Button
onclick={refreshChannels}
disabled={isFetching || disabled || !teamId}
class="flex items-center justify-center p-1.5 rounded hover:bg-surface-hover focus:bg-surface-hover disabled:opacity-50"
title="Refresh channels"
>
<RefreshCcw size={16} class={isFetching ? 'animate-spin' : ''} />
</button>
startIcon={{ icon: RefreshCcw, props: { class: isFetching ? 'animate-spin' : '' } }}
unifiedSize="sm"
variant="subtle"
iconOnly
/>
{/if}
</div>
+22 -14
View File
@@ -1,24 +1,32 @@
<script lang="ts">
import { Button } from './common'
import { slide } from 'svelte/transition'
import { twMerge } from 'tailwind-merge'
import { ChevronDown } from 'lucide-svelte'
export let open = false
export let text: string
export let small = false
interface Props {
open?: boolean
text: string
class?: string
children?: import('svelte').Snippet
}
let { open = $bindable(false), text, class: clazz = undefined, children }: Props = $props()
</script>
<div class={twMerge('flex', $$props.class)}>
<Button
variant="subtle"
btnClasses="text-primary {small ? 'text-xs' : ''} "
on:click={() => (open = !open)}
endIcon={{ icon: ChevronDown, classes: open ? 'transform rotate-180' : '' }}
<div class="flex flex-col gap-1">
<button
class={twMerge('font-medium text-xs text-accent items-center mb-1 flex gap-1', clazz)}
onclick={() => (open = !open)}
type="button"
>
{text}
</Button>
<ChevronDown
class={twMerge('transition-transform', open ? 'transform rotate-180' : '')}
size={12}
/>
</button>
{#if open}
<div transition:slide|local={{ duration: 100 }}>{@render children?.()}</div>
{/if}
</div>
{#if open}
<div transition:slide|local={{ duration: 100 }}><slot /></div>
{/if}
@@ -1,33 +0,0 @@
<script lang="ts">
import { Button } from './common'
import { Check, X } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
export let confirmation: string = 'Are you sure?'
let firstClick = false
const dispatch = createEventDispatcher()
</script>
<div class="p-2 flex flex-row w-full gap-2">
{#if !firstClick}
<Button
on:click={() => {
firstClick = true
}}><slot /></Button
>
{:else}
{confirmation}
<Button
color="red"
on:click={() => {
firstClick = false
dispatch('click')
}}><Check /></Button
>
<Button
on:click={() => {
firstClick = false
}}><X /></Button
>
{/if}
</div>
@@ -1,7 +1,6 @@
<script lang="ts">
import { Badge, Button } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import { Slack, Code2 } from 'lucide-svelte'
import { Slack, Code2, Unplug, Plug } from 'lucide-svelte'
import MsTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { hubBaseUrlStore, workspaceStore, enterpriseLicense } from '$lib/stores'
@@ -9,6 +8,7 @@
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/utils'
import TeamSelector from './TeamSelector.svelte'
import CollapseLink from './CollapseLink.svelte'
interface TeamItem {
team_id: string
@@ -30,7 +30,9 @@
documentationLink,
onLoadSettings,
workspaceConfig,
hideConnectButton = false
hideConnectButton = false,
isOAuthEnabled = false,
workspaceSpecificConnection = false
}: {
platform: 'slack' | 'teams'
teamName: string | undefined
@@ -47,6 +49,8 @@
onLoadSettings: () => void
workspaceConfig?: import('svelte').Snippet
hideConnectButton?: boolean
isOAuthEnabled?: boolean
workspaceSpecificConnection?: boolean
} = $props()
let selectedTeam: TeamItem | undefined = $state(undefined)
@@ -80,89 +84,90 @@
console.error('Error connecting to Teams:', error)
}
}
const capitalizedPlatform = $derived(platform.charAt(0).toUpperCase() + platform.slice(1))
</script>
<div class="flex flex-col gap-1">
<div class="text-primary font-semibold"
>Connect Workspace to {platform.charAt(0).toUpperCase() + platform.slice(1)}</div
>
<Description link={documentationLink}>
Connect your Windmill workspace to your {platform} workspace to trigger a script or a flow with a
'/windmill' command.
</Description>
<div class="text-xs font-semibold text-emphasis">{capitalizedPlatform} connection</div>
<div class="rounded-md border p-4 flex flex-col gap-6">
{#if workspaceConfig}
{@render workspaceConfig()}
{/if}
{#if teamName || workspaceSpecificConnection}
<div class="flex flex-col gap-2 max-w-sm">
<div class="flex flex-row gap-2 items-center">
{#if display_name}
<Badge color="green">
<Plug size={14} />
Workspace connected to {capitalizedPlatform} team '{display_name}'</Badge
>
{/if}
<Button
unifiedSize="md"
startIcon={{ icon: Unplug }}
disabled={!$enterpriseLicense && platform === 'teams'}
onclick={onDisconnect}
destructive
variant="subtle"
>
Disconnect {capitalizedPlatform}
{!$enterpriseLicense && platform === 'teams' ? '(EE only)' : ''}
</Button>
</div>
</div>
{:else if !hideConnectButton}
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 items-center">
{#if platform === 'teams'}
{#if $enterpriseLicense && isOAuthEnabled}
<TeamSelector
bind:selectedTeam
minWidth="180px"
disabled={!$enterpriseLicense}
onError={(e) => {
const errorMsg =
typeof (e as any)?.body === 'string'
? (e as any).body
: e?.message || 'Unknown error'
sendUserToast('Failed to load teams: ' + errorMsg, true)
}}
/>
{/if}
<Button
unifiedSize="md"
variant="accent"
onclick={connectTeams}
endIcon={{ icon: MsTeamsIcon }}
disabled={!selectedTeam || !$enterpriseLicense}
>
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
{$enterpriseLicense ? '' : '(EE only)'}
</Button>
{:else}
<Button
size="xs"
variant="accent"
href={connectHref}
startIcon={{ icon: Slack }}
disabled={!isOAuthEnabled}
>
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
</Button>
{/if}
</div>
</div>
{/if}
</div>
</div>
{#if workspaceConfig}
{@render workspaceConfig()}
{/if}
<div class="flex flex-col gap-1">
<div class="text-primary text-xs font-semibold"> Script or flow to run on /windmill command </div>
<span class="text-xs text-secondary mb-2"
>Pick a script or flow meant to be triggered when the `/windmill` command is invoked.</span
>
{#if teamName}
<div class="flex flex-col gap-2 max-w-sm">
<div class="flex flex-row gap-2">
<Button
size="sm"
endIcon={{ icon: platform === 'slack' ? Slack : MsTeamsIcon }}
btnClasses="mt-2"
disabled={!$enterpriseLicense && platform === 'teams'}
onclick={onDisconnect}
>
Disconnect {platform.charAt(0).toUpperCase() + platform.slice(1)}
{!$enterpriseLicense && platform === 'teams' ? '(EE only)' : ''}
</Button>
{#if display_name}
<Badge class="mt-2" color="green">Connected to Team '{display_name}'</Badge>
{/if}
</div>
{#if $enterpriseLicense || platform === 'slack'}
<Button size="sm" endIcon={{ icon: Code2 }} href={createScriptHref}>
Create a script to handle {platform} commands
</Button>
<Button size="sm" endIcon={{ icon: BarsStaggered }} href={createFlowHref}>
Create a flow to handle {platform} commands
</Button>
{/if}
</div>
{:else if !hideConnectButton}
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 items-center">
{#if platform === 'teams'}
<Button
unifiedSize="md"
onclick={connectTeams}
startIcon={{ icon: MsTeamsIcon }}
disabled={!selectedTeam || !$enterpriseLicense}
>
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
{$enterpriseLicense ? '' : '(EE only)'}
</Button>
{#if $enterpriseLicense}
<TeamSelector
bind:selectedTeam
minWidth="180px"
disabled={!$enterpriseLicense}
onError={(e) => {
const errorMsg = typeof (e as any)?.body === 'string' ? (e as any).body : (e?.message || 'Unknown error')
sendUserToast('Failed to load teams: ' + errorMsg, true)
}}
/>
{/if}
{:else}
<Button size="xs" variant="accent" href={connectHref} startIcon={{ icon: Slack }}>
Connect to {platform.charAt(0).toUpperCase() + platform.slice(1)}
</Button>
{/if}
<Badge color="red">Not connected</Badge>
</div>
</div>
{/if}
<div class="bg-surface-disabled p-4 rounded-md flex flex-col gap-1">
<div class="text-primary font-md font-semibold"> Script or flow to run on /windmill command </div>
<div class="relative">
{#if !teamName || (!$enterpriseLicense && platform === 'teams')}
<div class="absolute top-0 right-0 bottom-0 left-0 bg-surface-disabled z-40"></div>
{/if}
<div class="flex flex-row gap-2">
<ScriptPicker
kinds={['script']}
allowFlow
@@ -170,34 +175,57 @@
bind:scriptPath
{initialPath}
on:select={onSelect}
disabled={!teamName || (!$enterpriseLicense && platform === 'teams')}
clearable
/>
{#if teamName && ($enterpriseLicense || platform === 'slack') && (scriptPath === '' || scriptPath === undefined)}
{#if itemKind === 'script'}
<Button size="sm" endIcon={{ icon: Code2 }} href={createScriptHref}>
Create a script from template to handle {platform} commands
</Button>
{:else if itemKind === 'flow'}
<Button size="sm" endIcon={{ icon: BarsStaggered }} href={createFlowHref}>
Create a flow from template to handle {platform} commands
</Button>
{/if}
{/if}
</div>
<div class="prose text-2xs text-primary">
Pick a script or flow meant to be triggered when the `/windmill` command is invoked. Upon
connection, templates for a <a href="{$hubBaseUrlStore}/scripts/{platform}/1405/">script</a>
and <a href="{$hubBaseUrlStore}/flows/28/">flow</a> are available.
{#if !teamName}
<div class="text-red-500 text-xs"
>Please connect your workspace to {capitalizedPlatform} to use this feature</div
>
{/if}
<br /><br />
<CollapseLink text="How to use">
<div class="prose text-2xs text-primary">
Upon connection, templates for a <a href="{$hubBaseUrlStore}/scripts/{platform}/1405/"
>script</a
>
and <a href="{$hubBaseUrlStore}/flows/28/">flow</a> are available.
The script or flow chosen is passed the parameters `response_url: string` and `text: string`
respectively the url to reply directly to the trigger and the text of the command.
<br /><br />
<br /><br />
The script or flow chosen is passed the parameters `response_url: string` and `text: string`
respectively the url to reply directly to the trigger and the text of the command.
It can take additionally the following args: channel_id, user_name, user_id, command,
trigger_id, api_app_id
<br /><br />
<br /><br />
It can take additionally the following args: channel_id, user_name, user_id, command,
trigger_id, api_app_id
<span class="font-bold text-xs">
The script or flow is permissioned as group "{platform}" that will be automatically created
after connection to {platform.charAt(0).toUpperCase() + platform.slice(1)}.
</span>
<br /><br />
<br /><br />
<span class="font-bold text-xs">
The script or flow is permissioned as group "{platform}" that will be automatically created
after connection to {platform.charAt(0).toUpperCase() + platform.slice(1)}.
</span>
See more on
<a href={documentationLink}>documentation</a>.
</div>
<br /><br />
See more on
<a href={documentationLink}>documentation</a>.
</div>
</CollapseLink>
</div>
+56 -53
View File
@@ -25,56 +25,59 @@
}
</script>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Auth URL</span>
<input
type="text"
placeholder="https://github.com/login/oauth/authorize"
bind:value={connect_config.auth_url}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Token URL</span>
<input
type="text"
placeholder="https://github.com/login/oauth/access_token"
bind:value={connect_config.token_url}
/>
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Scopes</span>
<OauthScopes bind:scopes={connect_config.scopes} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Extra Query Args for Authorize Request&nbsp;<Tooltip
>Not needed in most cases. Examples of uses: google apis require the 2 extra args
"access_type=offline&prompt=consent"</Tooltip
></span
>
<OauthExtraParams bind:extra_params={connect_config.extra_params} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Extra Query Args for Token request <Tooltip>Not needed in most cases</Tooltip></span
>
<OauthExtraParams bind:extra_params={connect_config.extra_params_callback} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Payload <Tooltip
>Auth (client id/client secret) is passed as basic auth most commonly but can be passed in the
body x-www-form-urlencoded. Some LinkedIn is an example of OAuth using x-www-form-urlencoded
</Tooltip></span
>
<div>
<Toggle
options={{ left: 'in query args', right: 'in body x-www-form-urlencoded' }}
bind:checked={connect_config.req_body_auth}
/></div
>
</label>
<div class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Auth URL</span>
<input
type="text"
placeholder="https://github.com/login/oauth/authorize"
bind:value={connect_config.auth_url}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"> Token URL</span>
<input
type="text"
placeholder="https://github.com/login/oauth/access_token"
bind:value={connect_config.token_url}
/>
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Scopes</span>
<OauthScopes bind:scopes={connect_config.scopes} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Extra Query Args for Authorize Request&nbsp;<Tooltip
>Not needed in most cases. Examples of uses: google apis require the 2 extra args
"access_type=offline&prompt=consent"</Tooltip
></span
>
<OauthExtraParams bind:extra_params={connect_config.extra_params} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Extra Query Args for Token request <Tooltip>Not needed in most cases</Tooltip></span
>
<OauthExtraParams bind:extra_params={connect_config.extra_params_callback} />
</label>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs"
>Payload <Tooltip
>Auth (client id/client secret) is passed as basic auth most commonly but can be passed in
the body x-www-form-urlencoded. Some LinkedIn is an example of OAuth using
x-www-form-urlencoded
</Tooltip></span
>
<div>
<Toggle
options={{ left: 'in query args', right: 'in body x-www-form-urlencoded' }}
bind:checked={connect_config.req_body_auth}
/></div
>
</label>
</div>
@@ -40,6 +40,7 @@
btnText?: string
buttonReplacement?: import('svelte').Snippet
menu?: import('svelte').Snippet
maxHeight?: string | undefined
}
let {
@@ -60,7 +61,8 @@
size = 'md',
btnText = '',
buttonReplacement,
menu
menu,
maxHeight = undefined
}: Props = $props()
let buttonEl: HTMLButtonElement | undefined = $state(undefined)
@@ -169,8 +171,8 @@
{@render menu?.()}
{:else}
<div
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
style={customWidth ? `width: ${customWidth}px` : ''}
class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1"
style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '50vh'};`}
>
<DropdownV2Inner {aiId} items={computeItems} meltItem={item} />
</div>
@@ -17,7 +17,6 @@
'flex text-xs items-center gap-1 text-yellow-500 whitespace-nowrap px-1',
className
)}
title="Enterprise Edition only feature"
aria-label="Enterprise Edition only feature"
role="tooltip"
>
@@ -20,10 +20,13 @@
</script>
<script lang="ts">
import { Alert, Button, Tab, Tabs, Badge } from '$lib/components/common'
import { Alert, Button } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import SlackConnectionStatus from '$lib/components/common/slack/SlackConnectionStatus.svelte'
import TeamsConnectionStatus from '$lib/components/common/teams/TeamsConnectionStatus.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import ChannelSelector from '$lib/components/ChannelSelector.svelte'
import type { Schema, SupportedLanguage } from '$lib/common'
@@ -31,7 +34,6 @@
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import MsTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte'
import { emptySchema, emptyString, sendUserToast, tryEvery } from '$lib/utils'
import Description from '$lib/components/Description.svelte'
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
import {
FlowService,
@@ -45,9 +47,20 @@
import { inferArgs } from '$lib/infer'
import { hubBaseUrlStore } from '$lib/stores'
import { CheckCircle2, Loader2, RotateCw, XCircle } from 'lucide-svelte'
import {
CheckCircle2,
CircleCheck,
CircleX,
ExternalLink,
Loader2,
RotateCw,
XCircle
} from 'lucide-svelte'
import { hubPaths } from '$lib/hub'
import { isCloudHosted } from '$lib/cloud'
import SmtpConfigurationStatus from './common/smtp/SmtpConfigurationStatus.svelte'
import { SettingService } from '$lib/gen'
import { isSmtpSettingsValid } from './instanceSettings/SmtpSettings.svelte'
const slackRecoveryHandler = hubPaths.slackRecoveryHandler
const slackHandlerScriptPath = hubPaths.slackErrorHandler
@@ -86,9 +99,11 @@
let slackHandlerSchema: Schema | undefined = $state()
let teams_team_name: string | undefined = $state(undefined)
let teams_team_guid: string | undefined = $state(undefined)
let slack_team_name: string | undefined = $state(undefined)
let workspaceConnectedToSlack: boolean | undefined = $state(undefined)
let workspaceConnectedToTeams: boolean | undefined = $state(undefined)
let hasSmtpConfig: boolean = $state(false)
let connectionTestJob: { uuid: string; is_success: boolean; in_progress: boolean } | undefined =
$state()
@@ -99,8 +114,10 @@
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
if (!emptyString(settings.slack_name) && !emptyString(settings.slack_team_id)) {
workspaceConnectedToSlack = true
slack_team_name = settings.slack_name
} else {
workspaceConnectedToSlack = false
slack_team_name = undefined
}
}
@@ -117,6 +134,18 @@
}
}
async function loadSmtpConfiguration() {
try {
const smtpSettings = (await SettingService.getGlobal({ key: 'smtp_settings' })) as Record<
string,
any
> | null
hasSmtpConfig = smtpSettings ? isSmtpSettingsValid(smtpSettings) : false
} catch (error) {
hasSmtpConfig = false
}
}
async function sendMessage(channel: string, platform: 'teams' | 'slack'): Promise<void> {
const testJobFunction =
platform === 'slack'
@@ -251,6 +280,7 @@
loadSlackResources()
loadTeamsResources()
}
loadSmtpConfiguration()
})
$effect(() => {
@@ -313,291 +343,287 @@
})
</script>
<div>
<Tabs bind:selected={handlerSelected} class="mt-2 mb-4">
<Tab value="slack" disabled={!isEditable} label="Slack" />
<Tab value="teams" disabled={!isEditable} label="Teams" />
<Tab value="email" disabled={!isEditable} label="Email" />
<Tab value="custom" disabled={!isEditable} label="Custom" extra={customTabTooltip} />
</Tabs>
</div>
{#if handlerSelected === 'custom'}
<div class="flex flex-row mb-6">
<ScriptPicker
disabled={!isEditable || !$enterpriseLicense}
kinds={['script', 'failure']}
allowFlow={true}
bind:scriptPath={handlerPath}
bind:itemKind={customHandlerKind}
allowRefresh={isEditable}
clearable
/>
{#if !handlerPath}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
size="xs"
href={customScriptTemplate}
<div class="mt-2 space-y-2">
<ToggleButtonGroup bind:selected={handlerSelected} disabled={!isEditable}>
{#snippet children({ item })}
<ToggleButton label="Slack" value="slack" {item} disabled={!isEditable} />
<ToggleButton label="Teams" value="teams" {item} disabled={!isEditable} />
<ToggleButton label="Email" value="email" {item} disabled={!isEditable} />
<ToggleButton
label="Custom"
value="custom"
{item}
disabled={!isEditable}
target="_blank"
>
Create from template
</Button>
tooltip={customTabTooltip ? 'Custom error handler with script or flow' : undefined}
/>
{/snippet}
</ToggleButtonGroup>
<div class="flex flex-col gap-6 p-4 rounded-md border">
{#if handlerSelected === 'custom'}
<div class="flex flex-row mb-6">
<ScriptPicker
disabled={!isEditable || !$enterpriseLicense}
kinds={['script', 'failure']}
allowFlow={true}
bind:scriptPath={handlerPath}
bind:itemKind={customHandlerKind}
allowRefresh={isEditable}
clearable
/>
{#if !handlerPath}
<Button
btnClasses="ml-4 whitespace-nowrap"
variant="default"
size="xs"
href={customScriptTemplate}
disabled={!isEditable}
target="_blank"
>
Create from template
</Button>
{/if}
</div>
{#if showScriptHelpText}
<div class="text-2xs text-secondary">
Example of error handler scripts can be found on <a
target="_blank"
href="{$hubBaseUrlStore}/failures"
>
Windmill Hub</a
>
</div>
{/if}
{#if handlerPath}
<p class="font-semibold text-xs mt-6 mb-1">Extra arguments</p>
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!isEditable}
schema={customHandlerSchema}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0}
<div class="text-xs texg-gray-700">This error handler takes no extra arguments</div>
{/if}
{/if}
{:else if handlerSelected === 'slack'}
<!-- Slack Connection Status -->
<SlackConnectionStatus
isConnected={workspaceConnectedToSlack}
slackTeamName={slack_team_name}
mode="workspace"
onRefresh={loadSlackResources}
/>
{#if workspaceConnectedToSlack}
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isSlackHandler(handlerPath)}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = slackHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = slackRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = slackSuccessHandler
} else {
handlerPath = undefined
}
}}
/>
{/if}
{#if workspaceConnectedToSlack && isSlackHandler(handlerPath)}
<div class="flex flex-col gap-2">
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!$enterpriseLicense}
schema={slackHandlerSchema}
hiddenArgs={['slack']}
schemaFieldTooltip={{
channel: 'Slack channel name without the "#" - example: "windmill-alerts"'
}}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if $enterpriseLicense && isSlackHandler(handlerPath)}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
wrapperClasses="w-fit"
variant="default"
on:click={() => sendSlackMessage(handlerExtraArgs['channel'])}
unifiedSize="md">Send test message</Button
>
{#if connectionTestJob !== undefined}
<div class="flex items-center gap-2 p-4 rounded-md bg-surface-tertiary">
<p class="text-normal text-2xs flex items-center gap-4">
{#if connectionTestJob.in_progress}
<RotateCw size={14} class="animate-spin" />
Sending message...
{:else if connectionTestJob.is_success}
<CircleCheck size={14} class="text-green-600" />
Message sent via Windmill job
{:else}
<CircleX size={14} class="text-red-700" />
Message not sent
{/if}
<a
target="_blank"
href={`${base}/run/${connectionTestJob.uuid}?workspace=${$workspaceStore}`}
class="inline-flex items-center gap-1"
>
{connectionTestJob.uuid}
<ExternalLink size={12} class="inline-block" />
</a>
</p>
</div>
{/if}
{/if}
</div>
{:else if workspaceConnectedToSlack == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{:else if handlerSelected === 'teams'}
<!-- Teams Connection Status -->
<TeamsConnectionStatus
isConnected={workspaceConnectedToTeams}
teamsTeamName={teams_team_name}
mode="workspace"
onRefresh={loadTeamsResources}
/>
{#if workspaceConnectedToTeams}
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isTeamsHandler(handlerPath)}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = teamsHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = teamsRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = teamsSuccessHandler
} else {
handlerPath = undefined
}
}}
/>
{/if}
{#if workspaceConnectedToTeams}
<div class="flex flex-col gap-2">
<div class="w-2/3 flex flex-col gap-2">
<div class="flex flex-row items-center gap-2">
<p class="text-xs text-emphasis font-semibold">Teams Channel</p>
<div class="flex-shrink-0">
<MsTeamsIcon size={14} />
</div>
</div>
<div class="flex flex-row gap-2 items-start">
<ChannelSelector
containerClass="flex-grow"
minWidth="200px"
placeholder="Search Teams channels"
teamId={teams_team_guid}
selectedChannel={handlerExtraArgs['channel']
? {
channel_id: handlerExtraArgs['channel'],
channel_name: handlerExtraArgs['channel_name']
}
: undefined}
onSelectedChannelChange={(channel) => {
handlerExtraArgs['channel'] = channel?.channel_id
handlerExtraArgs['channel_name'] = channel?.channel_name
}}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
</div>
</div>
{#if $enterpriseLicense && isTeamsHandler(handlerPath) && workspaceConnectedToTeams}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
btnClasses="w-32 text-center whitespace-nowrap"
variant="default"
on:click={() => sendTeamsMessage(handlerExtraArgs['channel'] ?? '')}
size="xs">Send test message</Button
>
{#if connectionTestJob !== undefined}
<p class="text-normal text-2xs mt-1 gap-2">
{#if connectionTestJob.in_progress}
<RotateCw size={14} class="animate-spin" />
{:else if connectionTestJob.is_success}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Message sent via Windmill job
<a
target="_blank"
href={`${base}/run/${connectionTestJob.uuid}?workspace=${$workspaceStore}`}
>
{connectionTestJob.uuid}
</a>
</p>
{/if}
{/if}
</div>
{:else if workspaceConnectedToTeams == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{:else if handlerSelected === 'email'}
{#if isCloudHosted()}
<Alert type="info" title="Email notifications are not available in Cloud">
Email notifications for trigger failures are only available in self-hosted Windmill
instances.
</Alert>
{:else}
<SmtpConfigurationStatus {hasSmtpConfig} />
<div class="flex flex-col gap-2">
<MultiSelect
items={[] as { label: string; value: string }[]}
bind:value={
() => handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [],
(recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients)
}
placeholder="Enter email addresses..."
onCreateItem={(email) => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(email)) {
sendUserToast('Invalid email format', true)
return
}
const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? []
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email]
}}
class="w-full"
/>
{#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0}
<span class="text-xs text-secondary">
{handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[
EMAIL_RECIPIENTS_KEY
]?.length === 1
? ''
: 's'} configured
</span>
{/if}
</div>
{/if}
{/if}
</div>
{#if showScriptHelpText}
<div class="text-2xs text-secondary">
Example of error handler scripts can be found on <a
target="_blank"
href="{$hubBaseUrlStore}/failures"
>
Windmill Hub</a
>
</div>
{/if}
{#if handlerPath}
<p class="font-semibold text-xs mt-6 mb-1">Extra arguments</p>
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!isEditable}
schema={customHandlerSchema}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0}
<div class="text-xs texg-gray-700">This error handler takes no extra arguments</div>
{/if}
{/if}
{:else if handlerSelected === 'slack'}
<span class="w-full flex mb-3">
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isSlackHandler(handlerPath)}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = slackHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = slackRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = slackSuccessHandler
} else {
handlerPath = undefined
}
}}
/>
</span>
{#if workspaceConnectedToSlack}
{#await import('$lib/components/SchemaForm.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
disabled={!$enterpriseLicense || !isSlackHandler(handlerPath)}
schema={slackHandlerSchema}
hiddenArgs={['slack']}
schemaFieldTooltip={{
channel: 'Slack channel name without the "#" - example: "windmill-alerts"'
}}
bind:args={handlerExtraArgs}
shouldHideNoInputs
className="text-xs"
/>
{/await}
{:else if workspaceConnectedToSlack == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{#if $enterpriseLicense && isSlackHandler(handlerPath)}
{#if workspaceConnectedToSlack == false}
<Alert type="error" title="Workspace not connected to Slack">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
The workspace needs to be connected to Slack to use this feature. You can <a
target="_blank"
href="{base}/workspace_settings?tab=slack">configure it here</a
>.
</p>
<Button variant="default" on:click={loadSlackResources} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{:else}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
btnClasses="w-32 text-center whitespace-nowrap"
variant="default"
on:click={() => sendSlackMessage(handlerExtraArgs['channel'])}
unifiedSize="md">Send test message</Button
>
{#if connectionTestJob !== undefined}
<p class="text-normal text-2xs mt-1 gap-2">
{#if connectionTestJob.in_progress}
<RotateCw size={14} />
{:else if connectionTestJob.is_success}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Message sent via Windmill job
<a
target="_blank"
href={`${base}/run/${connectionTestJob.uuid}?workspace=${$workspaceStore}`}
>
{connectionTestJob.uuid}
</a>
</p>
{/if}
{/if}
{/if}
{:else if handlerSelected === 'teams'}
<span class="w-full flex mb-3">
<Toggle
disabled={!$enterpriseLicense || !isEditable}
checked={isTeamsHandler(handlerPath)}
options={{ right: toggleText }}
on:change={async (e) => {
if (e.detail && errorOrRecovery === 'error') {
handlerPath = teamsHandlerScriptPath
} else if (e.detail && errorOrRecovery === 'recovery') {
handlerPath = teamsRecoveryHandler
} else if (e.detail && errorOrRecovery === 'success') {
handlerPath = teamsSuccessHandler
} else {
handlerPath = undefined
}
}}
/>
</span>
{#if workspaceConnectedToTeams}
<div class="w-2/3 flex flex-col gap-2">
<div class="flex flex-row items-center gap-2">
<div class="pt-1 flex-shrink-0">
<MsTeamsIcon size={24} />
</div>
<p class="text-sm">Teams Channel</p>
</div>
<div class="flex flex-row gap-2 items-start">
<ChannelSelector
containerClass="flex-grow"
minWidth="200px"
placeholder="Search Teams channels"
teamId={teams_team_guid}
selectedChannel={handlerExtraArgs['channel']
? {
channel_id: handlerExtraArgs['channel'],
channel_name: handlerExtraArgs['channel_name']
}
: undefined}
onSelectedChannelChange={(channel) => {
handlerExtraArgs['channel'] = channel?.channel_id
handlerExtraArgs['channel_name'] = channel?.channel_name
}}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
</div>
</div>
<div class="flex flex-row gap-2 pb-4">
<p class="text-sm">
This workspace is connected to Team: <Badge color="blue" size="xs" class="mt-2"
>{teams_team_name}</Badge
>
</p>
<Tooltip>
Each workspace can only be connected to one Microsoft Teams team. You can configure it under <a
target="_blank"
href="{base}/workspace_settings?tab=teams">workspace settings</a
>.
</Tooltip>
</div>
{:else if workspaceConnectedToTeams == undefined}
<Loader2 class="animate-spin" size={10} />
{/if}
{#if $enterpriseLicense && isTeamsHandler(handlerPath)}
{#if workspaceConnectedToTeams == false}
<Alert type="error" title="Workspace not connected to Teams">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
The workspace needs to be connected to Teams to use this feature. You can configure it
under <a target="_blank" href="{base}/workspace_settings?tab=teams"
>workspace settings</a
>.
</p>
<Button variant="default" on:click={loadTeamsResources} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{:else}
<Button
disabled={emptyString(handlerExtraArgs['channel'])}
btnClasses="w-32 text-center mt-2 whitespace-nowrap"
variant="default"
on:click={() => sendTeamsMessage(handlerExtraArgs['channel'] ?? '')}
size="xs">Send test message</Button
>
{#if connectionTestJob !== undefined}
<p class="text-normal text-2xs mt-1 gap-2">
{#if connectionTestJob.in_progress}
<RotateCw size={14} class="animate-spin" />
{:else if connectionTestJob.is_success}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Message sent via Windmill job
<a
target="_blank"
href={`${base}/run/${connectionTestJob.uuid}?workspace=${$workspaceStore}`}
>
{connectionTestJob.uuid}
</a>
</p>
{/if}
{/if}
{/if}
{:else if handlerSelected === 'email'}
{#if isCloudHosted()}
<Alert type="info" title="Email notifications are not available in Cloud">
Email notifications for trigger failures are only available in self-hosted Windmill instances.
</Alert>
{:else}
<div class="flex flex-col gap-4 my-4">
<Description>
Configure email addresses to receive notifications when jobs fail. This feature requires
SMTP to be configured.
</Description>
</div>
<div class="flex flex-col gap-2 my-4">
<MultiSelect
items={[] as { label: string; value: string }[]}
bind:value={
() => handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [],
(recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients)
}
placeholder="Enter email addresses..."
onCreateItem={(email) => {
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(email)) {
sendUserToast('Invalid email format', true)
return
}
const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? []
handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email]
}}
class="w-full"
/>
{#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0}
<span class="text-sm text-primary">
{handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[
EMAIL_RECIPIENTS_KEY
]?.length === 1
? ''
: 's'} configured
</span>
{/if}
</div>
{/if}
{/if}
</div>
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@
import AuthSettings from './AuthSettings.svelte'
import InstanceSetting from './InstanceSetting.svelte'
import { writable, type Writable } from 'svelte/store'
import { ExternalLink } from 'lucide-svelte'
interface Props {
tab?: string
@@ -244,136 +245,158 @@
value.channel_name.trim() !== ''
)
}
function openSmtpSettings() {
tab = 'SMTP'
}
</script>
<div class="pb-8">
<div class="pb-12">
<!-- svelte-ignore a11y_label_has_associated_control -->
<Tabs {hideTabs} bind:selected={tab}>
{#each settingsKeys as category}
<Tab value={category} label={category}></Tab>
{/each}
{#snippet content()}
{#each Object.keys(settings) as category}
<TabContent value={category}>
{#if category == 'SMTP'}
<div class="text-secondary pb-4 text-xs"
>Setting SMTP unlocks sending emails upon adding new users to the workspace or the
instance or sending critical alerts.
<a
target="_blank"
href="https://www.windmill.dev/docs/advanced/instance_settings#smtp">Learn more</a
></div
>
{:else if category == 'Indexer/Search'}
<div class="text-secondary pb-4 text-xs"
>The indexer service unlocks full text search across jobs and service logs. It
requires spinning up its own separate container
<a target="_blank" href="https://www.windmill.dev/docs/core_concepts/search_bar#setup"
>Learn how to</a
></div
>
{:else if category == 'Registries'}
<div class="text-secondary pb-4 text-xs">
Add private registries for Pip, Bun and npm. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/imports">Learn more</a
>
</div>
{:else if category == 'Slack'}
<div class="text-secondary pb-4 text-xs">
Connecting your instance to a Slack workspace enables critical alerts to be sent to a
Slack channel.
<a target="_blank" href="https://www.windmill.dev/docs/misc/saml_and_scim"
>Learn more</a
>
</div>
{:else if category == 'SCIM/SAML'}
<div class="text-secondary pb-4 text-xs">
Setting up SAML and SCIM allows you to authenticate users using your identity
provider.
<a
target="_blank"
href="https://www.windmill.dev/docs/advanced/instance_settings#slack">Learn more</a
>
</div>
{:else if category == 'Debug'}
<div class="text-secondary pb-4 text-xs">
Enable debug mode to get more detailed logs.
</div>
{:else if category == 'Telemetry'}
<div class="text-primary pb-4 text-xs">
Anonymous usage data is collected to help improve Windmill.
<br />The following information is collected:
<ul class="list-disc list-inside pl-2">
<li>version of your instances</li>
<li>instance base URL</li>
<li>job usage (language, total duration, count)</li>
<li>login type usage (login type, count)</li>
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
<li>user usage (author count, operator count)</li>
<li>superadmin email addresses</li>
<li>vCPU usage</li>
<li>memory usage</li>
<li>development instance status</li>
</ul>
</div>
{#if $enterpriseLicense}
<div class="text-primary pb-4 text-xs">
On Enterprise Edition, you must send data to check that usage is in line with the
terms of the subscription. You can either enable telemetry or regularly send usage
data by clicking the button below.
</div>
<Button
on:click={sendStats}
variant="default"
btnClasses="w-auto"
wrapperClasses="mb-4"
loading={sendingStats}
size="xs"
>
Send usage
</Button>
{/if}
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
bind:oauths
bind:snowflakeAccountIdentifier
bind:requirePreexistingUserForOauth
baseUrl={$values?.base_url}
>
{#snippet scim()}
<div class="flex-col flex gap-2 pb-4">
{#each scimSamlSetting as setting}
<InstanceSetting
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
{values}
{version}
/>
{/each}
</div>
{/snippet}
</AuthSettings>
{/if}
<div>
<div class="flex-col flex gap-4 pb-4">
{#each settings[category] as setting}
<InstanceSetting
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
{values}
{version}
/>
{/each}
</div>
</div>
</TabContent>
{#if hideTabs}
{@render tabsContent()}
{:else}
<Tabs bind:selected={tab}>
{#each settingsKeys as category}
<Tab value={category} label={category}></Tab>
{/each}
{/snippet}
</Tabs>
{#snippet content()}
<div class="pt-4"></div>
{@render tabsContent()}
{/snippet}
</Tabs>
{/if}
{#snippet tabsContent()}
{#each Object.keys(settings) as category}
<TabContent value={category}>
{#if category == 'SMTP'}
<div class="text-secondary pb-4 text-xs">
Setting SMTP unlocks sending emails upon adding new users to the workspace or the
instance or sending critical alerts via email.
<a target="_blank" href="https://www.windmill.dev/docs/advanced/instance_settings#smtp"
>Learn more <ExternalLink size={12} class="inline-block" /></a
>
</div>
{:else if category == 'Indexer/Search'}
<div class="text-secondary pb-4 text-xs"
>The indexer service unlocks full text search across jobs and service logs. It requires
spinning up its own separate container
<a target="_blank" href="https://www.windmill.dev/docs/core_concepts/search_bar#setup"
>Learn how to <ExternalLink size={12} class="inline-block" /></a
></div
>
{:else if category == 'Alerts'}
<div class="text-secondary pb-4 text-xs">
Critical alerts automatically notify administrators about system events like job crashes,
license issues, worker failures, and queue delays through email, Slack, or Teams.
<a target="_blank" href="https://www.windmill.dev/docs/core_concepts/critical_alerts"
>Learn more <ExternalLink size={12} class="inline-block" /></a
>
</div>
{:else if category == 'Registries'}
<div class="text-secondary pb-4 text-xs">
Add private registries for Pip, Bun and npm. <a
target="_blank"
href="https://www.windmill.dev/docs/advanced/imports">Learn more</a
>
</div>
{:else if category == 'Slack'}
<div class="text-secondary pb-4 text-xs">
Connecting your instance to a Slack workspace enables critical alerts to be sent to a
Slack channel.
<a target="_blank" href="https://www.windmill.dev/docs/misc/saml_and_scim">Learn more</a
>
</div>
{:else if category == 'SCIM/SAML'}
<div class="text-secondary pb-4 text-xs">
Setting up SAML and SCIM allows you to authenticate users using your identity provider.
<a target="_blank" href="https://www.windmill.dev/docs/advanced/instance_settings#slack"
>Learn more</a
>
</div>
{:else if category == 'Debug'}
<div class="text-secondary pb-4 text-xs">
Enable debug mode to get more detailed logs.
</div>
{:else if category == 'Telemetry'}
<div class="text-primary pb-4 text-xs">
Anonymous usage data is collected to help improve Windmill.
<br />The following information is collected:
<ul class="list-disc list-inside pl-2">
<li>version of your instances</li>
<li>instance base URL</li>
<li>job usage (language, total duration, count)</li>
<li>login type usage (login type, count)</li>
<li>worker usage (worker, worker instance, vCPUs, memory)</li>
<li>user usage (author count, operator count)</li>
<li>superadmin email addresses</li>
<li>vCPU usage</li>
<li>memory usage</li>
<li>development instance status</li>
</ul>
</div>
{#if $enterpriseLicense}
<div class="text-primary pb-4 text-xs">
On Enterprise Edition, you must send data to check that usage is in line with the
terms of the subscription. You can either enable telemetry or regularly send usage
data by clicking the button below.
</div>
<Button
on:click={sendStats}
variant="default"
btnClasses="w-auto"
wrapperClasses="mb-4"
loading={sendingStats}
size="xs"
>
Send usage
</Button>
{/if}
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
bind:oauths
bind:snowflakeAccountIdentifier
bind:requirePreexistingUserForOauth
baseUrl={$values?.base_url}
>
{#snippet scim()}
<div class="flex-col flex gap-6 pb-4">
{#each scimSamlSetting as setting}
<InstanceSetting
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
{values}
{version}
{oauths}
/>
{/each}
</div>
{/snippet}
</AuthSettings>
{/if}
<div class="flex-col flex gap-6 pb-4">
{#each settings[category] as setting}
<!-- slack connect is handled with the alert channels settings, smtp_connect is handled in InstanceSetting -->
{#if setting.fieldType != 'slack_connect'}
<InstanceSetting
{openSmtpSettings}
on:closeDrawer={() => closeDrawer?.()}
{loading}
{setting}
{values}
{version}
{oauths}
/>
{/if}
{/each}
</div>
</TabContent>
{/each}
{/snippet}
</div>
{#if !hideSave}
@@ -1,5 +1,6 @@
<script lang="ts">
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
export let value: any
@@ -55,23 +56,32 @@
/></label
>
{#if enabled}
<div class="border rounded p-2">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Kanidm Url ({'KANIDM_URL/ui/oauth2'})</span
>
<input type="text" placeholder="Base URL" bind:value={baseUrl} />
<div class="border rounded p-4 flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Kanidm Url</span>
<span class="text-secondary font-normal text-xs">{'KANIDM_URL/ui/oauth2'}</span>
<TextInput inputProps={{ type: 'text', placeholder: 'Base URL' }} bind:value={baseUrl} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Id</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Secret </span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
</div>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
export let value: any
@@ -45,24 +46,34 @@
/></label
>
{#if enabled}
<div class="border rounded p-2">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Realm Url ({'REALM_URL/protocol/openid-connect/auth'})</span
<div class="border rounded p-4 flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Realm Url </span>
<span class="text-secondary font-normal text-xs"
>{'REALM_URL/protocol/openid-connect/auth'}</span
>
<input type="text" placeholder="yourorg" bind:value={org} />
<TextInput inputProps={{ type: 'text', placeholder: 'yourorg' }} bind:value={org} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Id</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Secret </span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
</div>
{/if}
@@ -1,8 +1,8 @@
<script lang="ts">
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
interface Props {
value: any
@@ -59,31 +59,43 @@
/></label
>
{#if enabled}
<div class="p-2 rounded border">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Nextcloud Instance Domain</span>
<input type="text" placeholder="example.nextcloud.com" bind:value={value['domain']} />
<div class="p-4 rounded-md border flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Nextcloud Instance Domain</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'example.nextcloud.com' }}
bind:value={value['domain']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Client Id <Tooltip>Client ID from your Nextcloud OAuth2 app configuration</Tooltip></span
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Id </span>
<span class="text-secondary font-normal text-xs"
>Client ID from your Nextcloud OAuth2 app configuration</span
>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Client Secret <Tooltip
>Client Secret from your Nextcloud OAuth2 app configuration</Tooltip
></span
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<span class="text-secondary font-normal text-xs"
>Client Secret from your Nextcloud OAuth2 app configuration</span
>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
<div class="text-xs text-primary border rounded-md p-4">
1. Go to your Nextcloud instance as an administrator<br />
2. Navigate to <strong>Administration settings → Security → OAuth 2.0 clients</strong><br
/>
+137 -57
View File
@@ -1,20 +1,23 @@
<script lang="ts">
import { X } from 'lucide-svelte'
import { ExternalLink, Plus, X } from 'lucide-svelte'
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
import { onMount } from 'svelte'
import { onMount, untrack } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
import Button from './common/button/Button.svelte'
import TextInput from './text_input/TextInput.svelte'
export let name: string
export let value: any
export let login = true
export let eeOnly = false
interface Props {
name: string
value: any
login?: boolean
eeOnly?: boolean
}
$: enabled = value != undefined && !(eeOnly && !$enterpriseLicense)
let { name, value = $bindable(), login = true, eeOnly = false }: Props = $props()
let tenant: string = ''
$: (name == 'microsoft' || name == 'teams') && changeTenantId(tenant)
let tenant: string = $state('')
onMount(() => {
try {
@@ -60,15 +63,22 @@
}
}
}
let enabled = $derived(value != undefined && !(eeOnly && !$enterpriseLicense))
$effect(() => {
if (name == 'microsoft' || name == 'teams') {
untrack(() => changeTenantId(tenant))
}
})
</script>
<div class="flex flex-col">
<!-- svelte-ignore a11y-label-has-associated-control -->
<div class="flex flex-col gap-2">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label
class="text-xs flex gap-4 items-center font-semibold text-emphasis {enabled
? 'rounded py-2'
: ''}"
><div class="w-[120px]"><IconedResourceType {name} after={true} /></div><Toggle
class="text-xs flex gap-4 items-center font-semibold text-emphasis {enabled ? 'rounded' : ''}"
><div class="w-[120px]"><IconedResourceType {name} after={true} /></div>
<Toggle
checked={enabled}
disabled={eeOnly && !$enterpriseLicense}
on:change={(e) => {
@@ -88,29 +98,38 @@
{/if}
</label>
{#if enabled}
<div class="p-2 rounded border mb-4">
<div class="p-4 rounded border mb-4 flex flex-col gap-6">
{#if name != 'slack' && name != 'teams'}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
/>
</label>
{/if}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Id</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Secret</span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
{#if name == 'microsoft' || name == 'teams'}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Tenant Id</span>
<input type="text" placeholder="Tenant Id" bind:value={tenant} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Tenant Id</span>
<TextInput inputProps={{ type: 'text', placeholder: 'Tenant Id' }} bind:value={tenant} />
</label>
{:else if login}
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Allowed domains</span>
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Allowed domains</span>
<div class="flex flex-col gap-1">
{#each value?.['allowed_domains'] ?? [] as domain, idx}
<div class="flex gap-2">
@@ -118,7 +137,7 @@
class="max-w-96 w-full"
type="text"
bind:value={value['allowed_domains'][idx]}
on:keyup={(e) => {
onkeyup={(e) => {
if (domain == '') {
value['allowed_domains'] = value['allowed_domains']?.filter(
(d) => d != domain
@@ -128,7 +147,7 @@
/>
<button
class="text-primary text-xs rounded hover:bg-surface-hover"
on:click={() => {
onclick={() => {
value['allowed_domains'] = value['allowed_domains']?.filter((d) => d != domain)
if (value['allowed_domains'].length == 0) {
value['allowed_domains'] = undefined
@@ -140,19 +159,22 @@
</div>
{/each}
<div class="flex gap-2">
<button
class="text-primary text-sm border rounded p-1"
on:click={() => {
<Button
variant="default"
unifiedSize="md"
startIcon={{ icon: Plus }}
onclick={() => {
value['allowed_domains'] = [...(value['allowed_domains'] ?? []), 'mydomain.com']
}}>+ Add domain</button
>
}}
>Add domain
</Button>
</div>
</div>
</label>
{/if}
{#if name == 'google'}
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
<div class="helper">
Create a new OAuth 2.0 Client <a
href="https://console.cloud.google.com/apis/credentials"
target="_blank">in Google console</a
@@ -162,35 +184,87 @@
</div>
</CollapseLink>
{:else if name == 'slack'}
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
Create a new App <a href="https://api.slack.com/apps?new_app=1" target="_blank"
>in Slack API Console</a
>. Pick "From an app manifest", then YAML and paste manifest template found on
<CollapseLink text="Set up slack">
<div class="helper">
To use Slack OAuth, create a new Slack app <a
href="https://api.slack.com/apps?new_app=1"
target="_blank"
>in slack API console
<ExternalLink size={12} class="inline-block" />
</a>. Pick "From a manifest", then YAML and paste manifest template found on
<a href="https://www.windmill.dev/docs/misc/setup_oauth#slack" target="_blank"
>Windmill Docs</a
>Windmill docs <ExternalLink size={12} class="inline-block" /></a
> and then paste Client ID and Client Secret here.
</div>
</CollapseLink>
{:else if name == 'microsoft'}
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
Create a new OAuth 2.0 Client <a
href="https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
target="_blank">in Microsoft portal</a
>
"Add" {'->'} "App Registration" -> "Accounts in this organizational directory only (Default
Directory only - Single tenant)", and in the "Authentication" tab, set the redirect URI to
Web and
<code>BASE_URL/user/login_callback/microsoft</code>. Then copy the "Directory (tenant
ID)" in the tenant ID field. Then copy the Client ID from "Application (client) ID" and
create a secret in "Client credentials". Last, include "Sign in" and "read user profile"
under "Delegated Permissions".
<div class="text-xs text-primary border rounded-md p-4 space-y-3">
<div>
<strong>1. Create App Registration</strong>
<div class="ml-4 mt-1">
Create a new OAuth 2.0 Client <a
href="https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
target="_blank"
class="inline-flex items-center gap-1 whitespace-nowrap">in Microsoft portal</a
>:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li>Click <strong>"Add"</strong><strong>"App Registration"</strong></li>
<li
>Select <strong
>"Accounts in this organizational directory only (Default Directory only -
Single tenant)"</strong
></li
>
</ul>
</div>
</div>
<div>
<strong>2. Authentication Configuration</strong>
<div class="ml-4 mt-1">
In the <strong>"Authentication"</strong> tab:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li>Set the redirect URI to <strong>Web</strong></li>
<li
>Add redirect URI: <code class="bg-surface px-1 rounded text-xs"
>BASE_URL/user/login_callback/microsoft</code
></li
>
</ul>
</div>
</div>
<div>
<strong>3. Copy Credentials</strong>
<div class="ml-4 mt-1">
Copy the following values to Windmill:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li>Copy <strong>"Directory (tenant ID)"</strong> to the tenant ID field</li>
<li>Copy <strong>"Application (client) ID"</strong> to the Client ID field</li>
<li
>Create a secret in <strong>"Client credentials"</strong> and copy to Client Secret
field</li
>
</ul>
</div>
</div>
<div>
<strong>4. API Permissions</strong>
<div class="ml-4 mt-1">
Under <strong>"Delegated Permissions"</strong>, include:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li>Sign in</li>
<li>Read user profile</li>
</ul>
</div>
</div>
</div>
</CollapseLink>
{:else if name == 'teams'}
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
<div class="helper">
Follow this guide on <a
href="https://www.windmill.dev/docs/misc/setup_oauth#microsoft-teams"
target="_blank">Windmill Docs</a
@@ -202,3 +276,9 @@
</div>
{/if}
</div>
<style>
.helper {
@apply text-xs text-primary rounded-md;
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts">
import { Button } from './common'
import { Minus, Plus } from 'lucide-svelte'
import { X, Plus } from 'lucide-svelte'
export let extra_params: Record<string, string> = {}
@@ -12,19 +12,18 @@
</script>
{#each extra_params_vec as o}
<div class="flex flex-row max-w-md mb-2">
<div class="flex flex-row max-w-md mb-2 gap-2">
<input type="text" on:keyup={sync} bind:value={o[0]} />
<input type="text" on:keyup={sync} bind:value={o[1]} />
<Button
variant="default"
variant="subtle"
destructive
size="xs"
btnClasses="mx-6"
unifiedSize="md"
on:click={() => {
extra_params_vec = extra_params_vec.filter((e) => e[0] != o[0])
sync()
}}
startIcon={{ icon: Minus }}
startIcon={{ icon: X }}
iconOnly
/>
</div>
@@ -42,7 +41,9 @@
>
Add item
</Button>
<span class="ml-2 text-sm text-primary">
({(extra_params_vec ?? []).length} item{(extra_params_vec ?? []).length > 1 ? 's' : ''})
</span>
{#if (extra_params_vec ?? []).length > 0}
<span class="ml-2 text-2xs text-secondary">
({(extra_params_vec ?? []).length} item{(extra_params_vec ?? []).length > 1 ? 's' : ''})
</span>
{/if}
</div>
+68 -32
View File
@@ -3,7 +3,6 @@
import CollapseLink from './CollapseLink.svelte'
import IconedResourceType from './IconedResourceType.svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -58,9 +57,9 @@
/></label
>
{#if enabled}
<div class="p-2 rounded border">
<label class="block pb-2">
<div class="flex gap-2 items-end">
<div class="p-4 rounded border flex flex-col gap-6">
<label>
<div class="flex gap-2 items-start">
<div>
<ToggleButtonGroup
selected={value['custom'] ? 'custom' : 'org'}
@@ -74,49 +73,86 @@
{/snippet}
</ToggleButtonGroup>
</div>
<div class="grow">
<span class="text-primary font-semibold text-sm"
<div class="grow flex flex-col gap-1">
<input type="text" placeholder="yourorg" bind:value={value['domain']} />
<span class="text-hint font-normal text-2xs"
>{#if value['custom']}Custom ({'https://<domain>'}){:else}
Org ({'https://<your org>.okta.com'}){/if}</span
>
<input type="text" placeholder="yourorg" bind:value={value['domain']} />
</div>
</div>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<label>
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Client Id <Tooltip
>Client credential from the client ID section of the okta service configuration</Tooltip
></span
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Id </span>
<span class="text-secondary font-normal text-xs"
>Client credential from the client ID section of the okta service configuration</span
>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Client Secret <Tooltip
>from the CLIENT SECRETS section of the okta service configuration</Tooltip
></span
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<span class="text-secondary font-normal text-xs"
>from the CLIENT SECRETS section of the okta service configuration</span
>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
</label>
<CollapseLink text="Instructions">
<div class="text-sm text-secondary border p-2">
From your Admin page, setup windmill using the service flow <br />Create a new app
integration <br />a. For "sign-in method" select "OIDC - Open ID Connect" <br />
b. For "application type" select "Web Appliction" <br />
Select all of the following options for Grant type of "Client acting on behalf of a user":
<br /> Authorization Code Refresh Token Implicit (hybrid) <br />
Allow ID Token with implicit grant type <br />
Allow Access Token with implicit grant type <br />
For Refresh Token, select "Rotate token after every use" <br />
Under "LOGIN", set the following: <br />"Sign-in redirect URIs"
`BASE_URL/user/login_callback/okta`<br />
"Sign-out redirect URIs" `BASE_URL/auth/logout` <br />"Login initiated by" App Only <br />
"Initiate login URI" `BASE_URL/user/login`
<div class="text-xs text-primary border rounded-md p-4 space-y-3">
<div>
<strong>1. Create App Integration</strong>
<div class="ml-4 mt-1">
From your Admin page, setup windmill using the service flow and create a new app
integration:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li>For "sign-in method" select <strong>OIDC - Open ID Connect</strong></li>
<li>For "application type" select <strong>Web Application</strong></li>
</ul>
</div>
</div>
<div>
<strong>2. Grant Type Configuration</strong>
<div class="ml-4 mt-1">
Select all of the following options for Grant type of "Client acting on behalf of a
user":
<ul class="list-disc ml-4 mt-1 space-y-1">
<li>Authorization Code</li>
<li>Refresh Token</li>
<li>Implicit (hybrid)</li>
<li>Allow ID Token with implicit grant type</li>
<li>Allow Access Token with implicit grant type</li>
</ul>
For Refresh Token, select <strong>"Rotate token after every use"</strong>
</div>
</div>
<div>
<strong>3. Login Configuration</strong>
<div class="ml-4 mt-1">
Under "LOGIN", set the following:
<ul class="list-disc ml-4 mt-1 space-y-1">
<li
><strong>Sign-in redirect URIs:</strong>
<code class="bg-surface px-1 rounded text-xs"
>BASE_URL/user/login_callback/okta</code
></li
>
<li
><strong>Sign-out redirect URIs:</strong>
<code class="bg-surface px-1 rounded text-xs">BASE_URL/auth/logout</code></li
>
<li><strong>Login initiated by:</strong> App Only</li>
<li
><strong>Initiate login URI:</strong>
<code class="bg-surface px-1 rounded text-xs">BASE_URL/user/login</code></li
>
</ul>
</div>
</div>
</div>
</CollapseLink>
</div>
@@ -11,6 +11,7 @@
disabled?: boolean
required?: boolean
small?: boolean
id?: string
onKeyDown?: (event: KeyboardEvent) => void
onBlur?: (event: FocusEvent) => void
}
@@ -21,6 +22,7 @@
disabled = false,
required = false,
small = false,
id,
onKeyDown,
onBlur
}: Props = $props()
@@ -46,6 +48,7 @@
error={red}
bind:value={password}
inputProps={{
id,
disabled,
placeholder,
autocomplete: 'new-password',
@@ -56,6 +59,7 @@
},
type: hideValue ? 'password' : 'text'
}}
class="pr-8"
/>
</div>
{#if red}
+2 -2
View File
@@ -100,9 +100,9 @@
transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }}
>
{#if description}
<div class="text-xs text-primary mt-1">{description}</div>
<div class="text-xs text-primary mt-1">{@html description}</div>
{/if}
<div class={twMerge('flex flex-col gap-6 h-full', description ? 'mt-4' : 'mt-2')}>
<div class="flex flex-col gap-6 h-full mt-6">
<div class={twMerge('grow min-h-0', clazz)}>
{@render children?.()}
</div>
@@ -13,7 +13,7 @@
}
</script>
<Drawer bind:this={drawer} size="1200px">
<Drawer bind:this={drawer} size="1000px">
<DrawerContent overflow_y={true} title="Instance settings" on:close={closeDrawer}>
<SuperadminSettingsInner {closeDrawer} />
</DrawerContent>
@@ -15,7 +15,7 @@
import { truncate } from '$lib/utils'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
import { userStore, workspaceStore } from '$lib/stores'
import { ExternalLink } from 'lucide-svelte'
import { settingsKeys } from './instanceSettings'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
@@ -116,16 +116,18 @@
<div><Uptodate /></div></div
>
</div>
<div class="flex flex-row-reverse">
<Button
variant="default"
target="_blank"
href="{base}/?workspace=admins"
endIcon={{ icon: ExternalLink }}
>
Admins workspace
</Button>
</div>
{#if $workspaceStore !== 'admins'}
<div class="flex flex-row-reverse">
<Button
variant="default"
target="_blank"
href="{base}/?workspace=admins"
endIcon={{ icon: ExternalLink }}
>
Admins workspace
</Button>
</div>
{/if}
<div class="pt-4 h-full">
<Tabs bind:selected={tab}>
<Tab
@@ -371,7 +373,7 @@
</div>
</TabContent>
<TabContent value="" values={settingsKeys}>
<InstanceSettings bind:this={instanceSettings} hideTabs hideSave {tab} {closeDrawer} />
<InstanceSettings bind:this={instanceSettings} hideTabs hideSave bind:tab {closeDrawer} />
</TabContent>
{/snippet}
</Tabs>
@@ -4,6 +4,7 @@
import { WorkspaceService } from '$lib/gen'
import Select from './select/Select.svelte'
import { debounce } from '$lib/utils'
import { Button } from './common'
interface TeamItem {
team_id: string
@@ -62,7 +63,9 @@
})
$effect(() => {
const newTeam = selectedTeamId ? displayTeams.find((t) => t.team_id === selectedTeamId) : undefined
const newTeam = selectedTeamId
? displayTeams.find((t) => t.team_id === selectedTeamId)
: undefined
if (newTeam?.team_id !== selectedTeam?.team_id) {
selectedTeam = newTeam
@@ -257,6 +260,7 @@
placeholder={isFetching ? 'Loading...' : 'Search teams...'}
clearable
disabled={disabled || isFetching}
loading={isFetching}
bind:filterText={searchFilterText}
bind:value={selectedTeamId}
/>
@@ -276,14 +280,13 @@
</div>
{#if showRefreshButton}
<button
<Button
onclick={refreshTeams}
disabled={isFetching || disabled}
class="flex items-center justify-center p-1.5 rounded hover:bg-surface-hover focus:bg-surface-hover disabled:opacity-50"
title={searchMode ? 'Refresh teams' : 'Refresh teams from Microsoft'}
>
<RefreshCcw size={16} class={isFetching ? 'animate-spin' : ''} />
</button>
startIcon={{ icon: RefreshCcw, props: { class: isFetching ? 'animate-spin' : '' } }}
/>
{/if}
</div>
@@ -294,7 +297,7 @@
</span>
<button
type="button"
class="text-2xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
class="text-xs text-accent cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onclick={loadMoreTeams}
disabled={isLoadingMore}
>
+1 -1
View File
@@ -20,7 +20,7 @@
</script>
{#if uptodate}
<span class="text-blue-400">
<span class="text-accent text-xs">
{uptodate} &nbsp;
<Tooltip>
{#if isCloudHosted()}
@@ -1,5 +1,6 @@
<script lang="ts">
import IconedResourceType from './IconedResourceType.svelte'
import TextInput from './text_input/TextInput.svelte'
import Toggle from './Toggle.svelte'
export let value: any
@@ -45,24 +46,32 @@
/></label
>
{#if enabled}
<div class="border rounded p-2">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm"
>Zitadel Url ({'ZITADEL_URL/oauth/v2/authorize'})</span
>
<input type="text" placeholder="yourorg" bind:value={org} />
<div class="border rounded p-4 flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Zitadel Url</span>
<span class="text-secondary font-normal text-xs">{'ZITADEL_URL/oauth/v2/authorize'}</span>
<TextInput inputProps={{ type: 'text', placeholder: 'yourorg' }} bind:value={org} />
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Custom Name</span>
<input type="text" placeholder="Custom Name" bind:value={value['display_name']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Custom Name</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Custom Name' }}
bind:value={value['display_name']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Id</span>
<input type="text" placeholder="Client Id" bind:value={value['id']} />
<label class="flex flex-col gap-1">
<span class="text-emphasis font-semibold text-xs">Client Id</span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Id' }}
bind:value={value['id']}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client Secret </span>
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
<label>
<span class="text-emphasis font-semibold text-xs">Client Secret </span>
<TextInput
inputProps={{ type: 'text', placeholder: 'Client Secret' }}
bind:value={value['secret']}
/>
</label>
</div>
{/if}
@@ -80,6 +80,7 @@
forceOverflowVisible ? '!overflow-visible' : ''
)}
class:overflow-y-auto={overflow_y}
style={overflow_y ? 'scrollbar-gutter: stable;' : ''}
>
{@render children?.()}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import { Badge, Button } from '$lib/components/common'
import { Plug, RotateCw, Unplug } from 'lucide-svelte'
import { base } from '$lib/base'
interface Props {
isConnected: boolean | undefined
slackTeamName?: string
mode: 'instance' | 'workspace'
onRefresh?: () => void
onDisconnect?: () => void
}
let { isConnected, slackTeamName, mode, onRefresh, onDisconnect }: Props = $props()
// Connection URLs based on mode
let connectUrl = $derived(
mode === 'instance'
? `${base}/api/oauth/connect_slack?instance=true`
: `${base}/workspace_settings?tab=slack`
)
</script>
<div class="flex items-center gap-2">
{#if isConnected === undefined}
<!-- Loading State -->
<RotateCw size={14} class="animate-spin text-secondary" />
{#if onRefresh}
<Button
variant="default"
unifiedSize="sm"
onclick={onRefresh}
startIcon={{ icon: RotateCw }}
/>
{/if}
{:else if isConnected}
<!-- Connected State - Show status indicator -->
<div class="flex items-center gap-2">
<Badge color="green">
<Plug size={14} />
<span class="text-xs text-primary">
{#if slackTeamName}
{mode === 'instance' ? 'Instance' : 'Workspace'} connected to the Slack workspace '{slackTeamName}'
{:else}
{mode === 'instance' ? 'Instance' : 'Workspace'} connected to Slack workspace
{/if}
</span>
</Badge>
</div>
<!-- Disconnect Button -->
{#if onDisconnect}
<Button
variant="default"
unifiedSize="sm"
onclick={onDisconnect}
startIcon={{ icon: Unplug }}
destructive
>
Disconnect slack
</Button>
{/if}
{:else}
<!-- Not Connected - Show connect button -->
<Badge color="red">
<Unplug size={14} />
<span class="text-xs text-primary"
>{mode === 'instance' ? 'Instance' : 'Workspace'} not connected to Slack</span
>
</Badge>
<a href={connectUrl} class="text-xs"
>{mode === 'instance' ? 'Connect instance to Slack' : 'Open workspace slack settings'}</a
>
{/if}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
import { Badge, Button } from '$lib/components/common'
import { Settings } from 'lucide-svelte'
interface Props {
hasSmtpConfig: boolean
openSmtpSettings?: () => void
}
let { hasSmtpConfig, openSmtpSettings }: Props = $props()
</script>
<div class="flex items-center gap-2">
{#if hasSmtpConfig}
<Badge color="green">SMTP configured</Badge>
{:else}
<Badge color="red">SMTP not configured</Badge>
{/if}
{#if openSmtpSettings}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Settings }}
onclick={openSmtpSettings}>Configure SMTP</Button
>
{:else}
<span class="text-xs text-secondary">SMTP is configured by the instance admin</span>
{/if}
</div>
@@ -0,0 +1,75 @@
<script lang="ts">
import { Badge, Button } from '$lib/components/common'
import { Plug, RotateCw, Unplug } from 'lucide-svelte'
import { base } from '$lib/base'
interface Props {
isConnected: boolean | undefined
teamsTeamName?: string
mode: 'instance' | 'workspace'
onRefresh?: () => void
onDisconnect?: () => void
}
let { isConnected, teamsTeamName, mode, onRefresh, onDisconnect }: Props = $props()
// Connection URLs based on mode
let connectUrl = $derived(
mode === 'instance' ? `${base}/#superadmin-settings` : `${base}/workspace_settings?tab=teams`
)
</script>
<div class="flex items-center gap-2">
{#if isConnected === undefined}
<!-- Loading State -->
<RotateCw size={14} class="animate-spin text-secondary" />
{#if onRefresh}
<Button
variant="default"
unifiedSize="sm"
onclick={onRefresh}
startIcon={{ icon: RotateCw }}
/>
{/if}
{:else if isConnected}
<!-- Connected State - Show status indicator -->
<Badge color="green">
<Plug size={14} />
<span class="text-xs text-primary">
{#if teamsTeamName}
Connected to the Teams workspace '{teamsTeamName}'
{:else}
Connected to Teams workspace
{/if}
</span>
</Badge>
<!-- Disconnect Button -->
{#if onDisconnect}
<Button
variant="default"
unifiedSize="sm"
onclick={onDisconnect}
startIcon={{ icon: Unplug }}
destructive
>
Disconnect teams
</Button>
{/if}
{:else}
<Badge color="red">
<Unplug size={14} />
<span class="text-xs text-primary"
>{mode === 'instance' ? 'Instance' : 'Workspace'} not connected to Teams</span
>
</Badge>
{#if mode === 'instance'}
<span class="text-xs text-secondary">
Configure Teams OAuth connection in instance settings
</span>
{:else}
<a href={connectUrl} class="text-xs">Open workspace teams settings</a>
{/if}
{/if}
</div>
@@ -1,3 +1,5 @@
import type { ButtonType } from './common/button/model'
export interface Setting {
label: string
description?: string
@@ -45,6 +47,11 @@ export interface Setting {
error?: string
defaultValue?: () => any
codeAreaLang?: string
actionButton?: {
label: string
onclick: (values: Record<string, any>) => Promise<void>
variant?: ButtonType.Variant
}
}
export type SettingStorage = 'setting'
@@ -245,6 +252,15 @@ export const settings: Record<string, Setting[]> = {
ee_only: ''
}
],
SMTP: [
{
label: 'SMTP',
key: 'smtp_settings',
fieldType: 'smtp_connect',
storage: 'setting',
ee_only: ''
}
],
'Auth/OAuth/SAML': [],
Registries: [
{
@@ -373,11 +389,27 @@ export const settings: Record<string, Setting[]> = {
{
label: 'Critical alert channels',
description:
'Channels to send critical alerts to. SMTP, Slack or Microsoft Teams must be configured below. <a href="https://www.windmill.dev/docs/core_concepts/critical_alerts">Learn more</a>',
'Channels to send critical alerts to. <a href="https://www.windmill.dev/docs/core_concepts/critical_alerts">Learn more</a>',
key: 'critical_error_channels',
fieldType: 'critical_error_channels',
storage: 'setting',
ee_only: 'Channels other than tracing are only available in the EE version'
ee_only: 'Channels other than tracing are only available in the EE version',
actionButton: {
label: 'Test all channels',
onclick: async (values) => {
const { SettingService } = await import('$lib/gen')
const { sendUserToast } = await import('$lib/toast')
try {
await SettingService.testCriticalChannels({
requestBody: values.critical_error_channels
})
sendUserToast('Test message sent successfully to critical channels', false)
} catch (error: any) {
sendUserToast('Failed to send test message: ' + error.message, true)
}
},
variant: 'accent'
}
},
{
label: 'Mute critical alerts in UI',
@@ -395,13 +427,6 @@ export const settings: Record<string, Setting[]> = {
storage: 'setting',
ee_only: ''
},
{
label: 'SMTP',
key: 'smtp_settings',
fieldType: 'smtp_connect',
storage: 'setting',
ee_only: ''
},
{
label: 'Alert on DB oversize',
key: 'critical_alerts_on_db_oversize',
@@ -0,0 +1,188 @@
<script lang="ts">
import { enterpriseLicense } from '$lib/stores'
import SlackChannelCard from './SlackChannelCard.svelte'
import TeamsChannelCard from './TeamsChannelCard.svelte'
import EmailChannelCard from './EmailChannelCard.svelte'
import type { Writable } from 'svelte/store'
import { isSmtpSettingsValid } from './SmtpSettings.svelte'
interface Props {
values: Writable<Record<string, any>>
openSmtpSettings?: () => void
oauths?: Record<string, any>
}
let { values, openSmtpSettings, oauths }: Props = $props()
// Derived state for each channel type
const slackChannels = $derived.by(() => {
const channels = $values?.critical_error_channels || []
return channels.filter((channel: any) => channel && 'slack_channel' in channel)
})
const teamsChannels = $derived.by(() => {
const channels = $values?.critical_error_channels || []
return channels.filter((channel: any) => channel && 'teams_channel' in channel)
})
const emailChannels = $derived.by(() => {
const channels = $values?.critical_error_channels || []
return channels.filter((channel: any) => channel && 'email' in channel)
})
const slackTeamName = $derived($values['slack']?.['team_name'])
// Teams OAuth validation function
function isTeamsOAuthConfigured(teamsConfig: any): boolean {
return (
teamsConfig &&
teamsConfig.id?.trim() &&
teamsConfig.secret?.trim() &&
teamsConfig.tenant?.trim()
)
}
const isTeamsConnected = $derived(isTeamsOAuthConfigured(oauths?.teams))
// Compute dynamic order based on channel presence
const slackOrder = $derived(slackChannels.length > 0 ? 1 : 4)
const teamsOrder = $derived(teamsChannels.length > 0 ? 2 : 5)
const emailOrder = $derived(emailChannels.length > 0 ? 3 : 6)
function addSlackChannel() {
if (
$values.critical_error_channels == undefined ||
!Array.isArray($values.critical_error_channels)
) {
$values.critical_error_channels = []
}
$values.critical_error_channels = $values.critical_error_channels.concat({ slack_channel: '' })
}
function addTeamsChannel() {
if (
$values.critical_error_channels == undefined ||
!Array.isArray($values.critical_error_channels)
) {
$values.critical_error_channels = []
}
$values.critical_error_channels = $values.critical_error_channels.concat({
teams_channel: undefined
})
}
function addEmailChannel() {
if (
$values.critical_error_channels == undefined ||
!Array.isArray($values.critical_error_channels)
) {
$values.critical_error_channels = []
}
$values.critical_error_channels = $values.critical_error_channels.concat({ email: '' })
}
function removeChannel(index: number) {
$values.critical_error_channels = $values.critical_error_channels.filter(
(_: any, i: number) => i !== index
)
}
function updateChannel(index: number, updatedChannel: any) {
$values.critical_error_channels[index] = updatedChannel
}
function findChannelIndex(channel: any): number {
return $values?.critical_error_channels?.indexOf(channel) ?? -1
}
function disconnectSlack() {
// Clear the entire critical_error_channels setting, same as original disconnect behavior
if ($values['slack']) {
$values.slack = undefined
}
}
function handleTeamChange(
teamItem: { team_id: string; team_name: string } | undefined,
channel: any
) {
const index = findChannelIndex(channel)
if (index === -1) return
const currentTeamChannel = channel?.teams_channel
const teamIdChanged = currentTeamChannel?.team_id !== teamItem?.team_id
$values.critical_error_channels[index] = {
teams_channel: teamItem
? {
team_id: teamItem.team_id,
team_name: teamItem.team_name,
// Preserve existing channel if team didn't actually change
channel_id: teamIdChanged ? undefined : currentTeamChannel?.channel_id,
channel_name: teamIdChanged ? undefined : currentTeamChannel?.channel_name
}
: undefined
}
}
function handleChannelChange(
channelItem: { channel_id?: string; channel_name?: string } | undefined,
channel: any
) {
const index = findChannelIndex(channel)
if (index === -1) return
const team = channel?.teams_channel
if (team) {
$values.critical_error_channels[index] = {
teams_channel: {
team_id: team?.team_id,
team_name: team?.team_name,
channel_id: channelItem?.channel_id,
channel_name: channelItem?.channel_name
}
}
}
}
</script>
<div class="gap-y-4 pt-2 flex flex-col">
<!-- Slack Card -->
<SlackChannelCard
channels={slackChannels}
disabled={!$enterpriseLicense}
onAddChannel={addSlackChannel}
onRemoveChannel={removeChannel}
onUpdateChannel={updateChannel}
{findChannelIndex}
onDisconnect={disconnectSlack}
{slackTeamName}
style="order: {slackOrder};"
/>
<!-- Teams Card -->
<TeamsChannelCard
channels={teamsChannels}
disabled={!$enterpriseLicense}
onAddChannel={addTeamsChannel}
onRemoveChannel={removeChannel}
onTeamChange={handleTeamChange}
onChannelChange={handleChannelChange}
{findChannelIndex}
{isTeamsConnected}
style="order: {teamsOrder};"
/>
<!-- Email Card -->
<EmailChannelCard
channels={emailChannels}
hasSmtpConfig={isSmtpSettingsValid($values['smtp_settings'])}
disabled={!$enterpriseLicense}
onAddChannel={addEmailChannel}
onRemoveChannel={removeChannel}
onUpdateChannel={updateChannel}
{openSmtpSettings}
{findChannelIndex}
style="order: {emailOrder};"
/>
</div>
@@ -0,0 +1,123 @@
<script lang="ts">
import { Mail, X, Plus } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import IntegrationCard from './IntegrationCard.svelte'
import { fade } from 'svelte/transition'
import TextInput from '../text_input/TextInput.svelte'
import SmtpConfigurationStatus from '../common/smtp/SmtpConfigurationStatus.svelte'
interface EmailChannel {
email: string
}
interface Props {
channels: EmailChannel[]
disabled?: boolean
onAddChannel: () => void
onRemoveChannel: (index: number) => void
onUpdateChannel: (index: number, updatedChannel: EmailChannel) => void
findChannelIndex: (channel: EmailChannel) => number
openSmtpSettings?: () => void
class?: string
style?: string
hasSmtpConfig: boolean
}
let {
channels,
disabled = false,
onAddChannel,
onRemoveChannel,
onUpdateChannel,
findChannelIndex,
openSmtpSettings,
class: clazz,
style,
hasSmtpConfig
}: Props = $props()
function handleEmailInput(channel: EmailChannel, value: string) {
const index = findChannelIndex(channel)
if (index !== -1) {
onUpdateChannel(index, { email: value })
}
}
function handleRemoveChannel(channel: EmailChannel) {
const index = findChannelIndex(channel)
if (index !== -1) {
onRemoveChannel(index)
}
}
</script>
{#if channels.length > 0}
<!-- Connected Email Card -->
<IntegrationCard title="Email" icon={Mail} isPlaceholder={false} class={clazz} {style}>
{#snippet actions()}
<SmtpConfigurationStatus {hasSmtpConfig} {openSmtpSettings} />
{/snippet}
{#snippet children()}
{#if channels.length > 0}
<span class="text-xs text-secondary"> Email addresses to send alerts to. </span>
{/if}
<!-- Email Inputs -->
<div class="space-y-2">
{#each channels as channel}
<div class="flex items-center gap-2 w-full" transition:fade|local={{ duration: 200 }}>
<TextInput
inputProps={{
type: 'email',
placeholder: 'Email address',
disabled,
oninput: (e) => {
const target = e.target as HTMLInputElement
handleEmailInput(channel, target.value)
}
}}
value={channel.email || ''}
/>
<Button
onclick={() => handleRemoveChannel(channel)}
title="Remove email"
{disabled}
startIcon={{ icon: X }}
iconOnly
unifiedSize="md"
variant="subtle"
destructive
/>
</div>
{/each}
</div>
<!-- Add Email Button -->
<div class="flex justify-start">
<Button
variant="default"
size="xs"
onclick={onAddChannel}
btnClasses="text-xs flex items-center gap-2"
{disabled}
>
<Plus size={14} />
Add email address
</Button>
</div>
{/snippet}
</IntegrationCard>
{:else}
<!-- Placeholder Card -->
<IntegrationCard
title="Email"
icon={Mail}
isPlaceholder={true}
onAdd={onAddChannel}
class={clazz}
{style}
>
{#snippet children()}{/snippet}
</IntegrationCard>
{/if}
@@ -0,0 +1,70 @@
<script lang="ts">
import { Plus } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
title: string
icon: any
children: any
onAdd?: () => void
isPlaceholder?: boolean
actions?: import('svelte').Snippet
class?: string
style?: string
}
let {
title,
icon: Icon,
children,
onAdd,
isPlaceholder = false,
actions,
class: clazz,
style
}: Props = $props()
</script>
{#if isPlaceholder}
<!-- Placeholder Card (dashed border) -->
<div
class={twMerge(
'border border-dashed border-gray-600 flex h-[67px] items-center justify-center px-4 py-4 rounded-md w-full bg-transparent hover:bg-surface-secondary/50 transition-colors cursor-pointer',
clazz
)}
{style}
onclick={onAdd}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onAdd?.()}
>
<div class="flex gap-2 items-center justify-center">
<Plus size={14} class="text-secondary" />
<span class="text-xs font-medium text-secondary text-center">
Add {title.toLowerCase()} channel
</span>
<Icon size={title === 'Microsoft Teams' ? 20 : 14} class="text-secondary" />
</div>
</div>
{:else}
<!-- Connected Card (solid background) -->
<div
class={twMerge('bg-surface-tertiary border flex flex-col gap-2 p-4 rounded-md w-full', clazz)}
{style}
>
<!-- Card Header -->
<div class="flex items-center justify-between w-full">
<div class="flex gap-2 items-center">
<Icon size={20} class="text-primary" />
<span class="text-xs font-semibold text-primary">{title}</span>
</div>
{@render actions?.()}
</div>
<!-- Card Content -->
<div class="space-y-2">
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,128 @@
<script lang="ts">
import { Slack, X, Plus } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import IntegrationCard from './IntegrationCard.svelte'
import SlackConnectionStatus from '../common/slack/SlackConnectionStatus.svelte'
import TextInput from '../text_input/TextInput.svelte'
interface SlackChannel {
slack_channel: string
}
interface Props {
channels: SlackChannel[]
disabled?: boolean
onAddChannel: () => void
onRemoveChannel: (index: number) => void
onUpdateChannel: (index: number, updatedChannel: SlackChannel) => void
findChannelIndex: (channel: SlackChannel) => number
onDisconnect: () => void
slackTeamName?: string
class?: string
style?: string
}
let {
channels,
disabled = false,
onAddChannel,
onRemoveChannel,
onUpdateChannel,
findChannelIndex,
onDisconnect,
slackTeamName,
class: clazz,
style
}: Props = $props()
function handleSlackChannelInput(channel: SlackChannel, value: string) {
const index = findChannelIndex(channel)
if (index !== -1) {
onUpdateChannel(index, { slack_channel: value })
}
}
function handleRemoveChannel(channel: SlackChannel) {
const index = findChannelIndex(channel)
if (index !== -1) {
onRemoveChannel(index)
}
}
</script>
{#if channels.length > 0}
<!-- Connected Slack Card -->
<IntegrationCard title="Slack" icon={Slack} isPlaceholder={false} class={clazz} {style}>
{#snippet actions()}
<SlackConnectionStatus
isConnected={slackTeamName ? true : false}
{slackTeamName}
mode="instance"
{onDisconnect}
/>
{/snippet}
{#snippet children()}
{#if channels.length > 0}
<span class="text-xs text-secondary"> Channels to send alerts to. </span>
{/if}
<!-- Channel Inputs -->
<div class="space-y-2">
{#each channels as channel}
<div class="flex items-center gap-2 w-full">
<div class="flex-1">
<TextInput
inputProps={{
type: 'text',
placeholder: 'Slack channel (e.g., #general)',
disabled: disabled,
oninput: (e) => {
const target = e.target as HTMLInputElement
handleSlackChannelInput(channel, target.value)
}
}}
value={channel.slack_channel || ''}
/>
</div>
<Button
onclick={() => handleRemoveChannel(channel)}
title="Remove channel"
iconOnly
{disabled}
startIcon={{ icon: X }}
unifiedSize="md"
variant="subtle"
destructive
/>
</div>
{/each}
</div>
<!-- Add Channel Button -->
<div class="flex justify-start">
<Button
variant="default"
size="xs"
onclick={onAddChannel}
btnClasses="text-xs flex items-center gap-2"
{disabled}
>
<Plus size={14} />
Add channel
</Button>
</div>
{/snippet}
</IntegrationCard>
{:else}
<!-- Placeholder Card -->
<IntegrationCard
title="Slack"
icon={Slack}
isPlaceholder={true}
onAdd={onAddChannel}
class={clazz}
{style}
>
{#snippet children()}{/snippet}
</IntegrationCard>
{/if}
@@ -0,0 +1,179 @@
<script lang="ts" module>
export function isSmtpSettingsValid(smtpSettings: Record<string, any>) {
return (
smtpSettings &&
smtpSettings.smtp_host &&
smtpSettings.smtp_host.trim() !== '' &&
smtpSettings.smtp_port &&
smtpSettings.smtp_username &&
smtpSettings.smtp_username.trim() !== '' &&
smtpSettings.smtp_password &&
smtpSettings.smtp_password.trim() !== '' &&
smtpSettings.smtp_from &&
smtpSettings.smtp_from.trim() !== ''
)
}
</script>
<script lang="ts">
import { Button } from '$lib/components/common'
import Password from '../Password.svelte'
import Toggle from '../Toggle.svelte'
import { SettingService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import TextInput from '../text_input/TextInput.svelte'
import { Mail } from 'lucide-svelte'
import type { Writable } from 'svelte/store'
interface Props {
values: Writable<Record<string, any>>
disabled?: boolean
}
let { values, disabled = false }: Props = $props()
let testEmail = $state('')
async function testSmtpSettings() {
try {
await SettingService.testSmtp({
requestBody: {
to: testEmail,
smtp: {
host: $values['smtp_settings'].smtp_host,
username: $values['smtp_settings'].smtp_username,
password: $values['smtp_settings'].smtp_password,
port: $values['smtp_settings'].smtp_port,
from: $values['smtp_settings'].smtp_from,
tls_implicit: $values['smtp_settings'].smtp_tls_implicit || false,
disable_tls: $values['smtp_settings'].smtp_disable_tls || false
}
}
})
sendUserToast('Test email sent successfully')
} catch (error) {
sendUserToast('Failed to send test email: ' + error.message, true)
}
}
</script>
<div class="space-y-6">
<!-- SMTP Settings Form -->
<div class="space-y-6">
<div class="grid grid-cols-2 grid-rows-2 gap-x-2 gap-y-6">
<div class="flex flex-col gap-1">
<label for="smtp_host" class="block text-xs font-semibold text-emphasis mb-1">Host</label>
<TextInput
inputProps={{
type: 'text',
id: 'smtp_host',
placeholder: 'smtp.gmail.com',
disabled: disabled
}}
bind:value={$values['smtp_settings'].smtp_host}
/>
</div>
<div class="flex flex-col gap-1">
<label for="smtp_port" class="block text-xs font-semibold text-emphasis mb-1">Port</label>
<TextInput
inputProps={{
type: 'number',
id: 'smtp_port',
placeholder: '587',
disabled: disabled
}}
bind:value={$values['smtp_settings'].smtp_port}
/>
</div>
<div>
<label for="smtp_username" class="block text-xs font-semibold text-emphasis mb-1">
Username
</label>
<TextInput
inputProps={{
type: 'text',
id: 'smtp_username',
placeholder: 'user@example.com',
disabled: disabled
}}
bind:value={$values['smtp_settings'].smtp_username}
/>
</div>
<div>
<label for="smtp_password" class="block text-xs font-semibold text-emphasis mb-1">
Password
</label>
<Password bind:password={$values['smtp_settings'].smtp_password} small {disabled} />
</div>
</div>
<div>
<label for="smtp_from" class="block text-xs font-semibold text-emphasis mb-1">
From Address
</label>
<TextInput
inputProps={{
type: 'email',
id: 'smtp_from',
placeholder: 'noreply@example.com',
disabled: disabled
}}
bind:value={$values['smtp_settings'].smtp_from}
/>
</div>
<div class="flex gap-4">
<Toggle
disabled={$values['smtp_settings'].smtp_disable_tls || disabled}
id="smtp_tls_implicit"
bind:checked={$values['smtp_settings'].smtp_tls_implicit}
size="xs"
options={{ right: 'Implicit TLS' }}
/>
<Toggle
id="smtp_disable_tls"
{disabled}
bind:checked={$values['smtp_settings'].smtp_disable_tls}
size="xs"
on:change={(e) => {
if (e.detail) {
$values['smtp_settings'].smtp_tls_implicit = false
}
}}
options={{ right: 'Disable TLS' }}
/>
</div>
<!-- Test Email -->
<div class="flex flex-col gap-1">
<label for="test_email" class="block text-xs font-semibold text-emphasis">Test Email</label>
<span class="text-xs text-secondary">
Enter a test email address to verify the SMTP settings.
</span>
<div class="flex gap-2">
<TextInput
inputProps={{
type: 'email',
placeholder: 'Test email address',
disabled: disabled,
id: 'test_email'
}}
bind:value={testEmail}
/>
<Button
unifiedSize="md"
variant="accent"
onclick={testSmtpSettings}
disabled={!testEmail || !isSmtpSettingsValid($values['smtp_settings']) || disabled}
btnClasses="text-xs"
startIcon={{ icon: Mail }}
>
Send test email
</Button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,173 @@
<script lang="ts">
import { X, Plus } from 'lucide-svelte'
import MSTeamsIcon from '$lib/components/icons/MSTeamsIcon.svelte'
import IntegrationCard from './IntegrationCard.svelte'
import TeamSelector from '../TeamSelector.svelte'
import ChannelSelector from '../ChannelSelector.svelte'
import { Button } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import TeamsConnectionStatus from '../common/teams/TeamsConnectionStatus.svelte'
interface TeamsChannelEntry {
teams_channel?: {
team_id: string
team_name: string
channel_id?: string
channel_name?: string
}
}
interface Props {
channels: TeamsChannelEntry[]
disabled?: boolean
onAddChannel: () => void
onRemoveChannel: (index: number) => void
onTeamChange: (
teamItem: { team_id: string; team_name: string } | undefined,
channel: TeamsChannelEntry
) => void
onChannelChange: (
channelItem: { channel_id?: string; channel_name?: string } | undefined,
channel: TeamsChannelEntry
) => void
findChannelIndex: (channel: TeamsChannelEntry) => number
isTeamsConnected?: boolean
class?: string
style?: string
}
let {
channels,
disabled = false,
onAddChannel,
onRemoveChannel,
onTeamChange,
onChannelChange,
findChannelIndex,
isTeamsConnected,
class: clazz,
style
}: Props = $props()
function handleRemoveChannel(channel: TeamsChannelEntry) {
const index = findChannelIndex(channel)
if (index !== -1) {
onRemoveChannel(index)
}
}
</script>
{#if channels.length > 0}
<!-- Connected Teams Card -->
<IntegrationCard
title="Microsoft Teams"
icon={MSTeamsIcon}
isPlaceholder={false}
class={clazz}
{style}
>
{#snippet actions()}
<TeamsConnectionStatus isConnected={isTeamsConnected} mode="instance" />
{/snippet}
{#snippet children()}
{#if channels.length > 0}
<span class="text-xs text-secondary"> Channels to send alerts to. </span>
{/if}
<!-- Channel Configuration -->
{#if channels.length > 0}
<!-- Column Headers -->
<div class="flex items-center gap-2 w-full">
<div class="flex flex-row gap-2 flex-1">
<div class="w-44">
<span class="block text-xs font-normal text-secondary">Team</span>
</div>
<div class="flex-1">
<span class="block text-xs font-normal text-secondary">Channel</span>
</div>
</div>
<div class="w-6"></div>
<!-- Space for remove button -->
</div>
{/if}
<div class="space-y-2">
{#each channels as channel}
{@const currentTeam = channel?.teams_channel
? {
team_id: channel.teams_channel.team_id,
team_name: channel.teams_channel.team_name
}
: undefined}
{@const currentChannel = channel?.teams_channel?.channel_id
? {
channel_id: channel.teams_channel.channel_id,
channel_name: channel.teams_channel.channel_name
}
: undefined}
<div class="flex items-start gap-2 w-full">
<div class="flex flex-row gap-2 flex-1">
<TeamSelector
containerClass="w-44"
minWidth="140px"
showRefreshButton={false}
selectedTeam={currentTeam}
onSelectedTeamChange={(team) => onTeamChange(team, channel)}
{disabled}
/>
{#if channel?.teams_channel?.team_id}
<ChannelSelector
containerClass="flex-1"
placeholder="Search channels"
teamId={channel.teams_channel.team_id}
selectedChannel={currentChannel}
onSelectedChannelChange={(channelItem) => onChannelChange(channelItem, channel)}
{disabled}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
{/if}
</div>
<Button
onclick={() => handleRemoveChannel(channel)}
title="Remove channel"
{disabled}
startIcon={{ icon: X }}
iconOnly
unifiedSize="md"
variant="subtle"
destructive
></Button>
</div>
{/each}
</div>
<!-- Add Channel Button -->
<div class="flex justify-start">
<Button
variant="default"
size="xs"
onclick={onAddChannel}
btnClasses="text-xs flex items-center gap-2"
{disabled}
>
<Plus size={14} />
Add Teams channel
</Button>
</div>
{/snippet}
</IntegrationCard>
{:else}
<!-- Placeholder Card -->
<IntegrationCard
title="Microsoft Teams"
icon={MSTeamsIcon}
isPlaceholder={true}
onAdd={onAddChannel}
class={clazz}
{style}
>
{#snippet children()}{/snippet}
</IntegrationCard>
{/if}
@@ -904,7 +904,7 @@
<div class="w-10">
{#if is_linked}
<Popover>
<Link />
<Link size={16} />
{#snippet text()}
<div>
This resource is linked with a variable of the same path. They are
@@ -3,7 +3,9 @@
import { page } from '$app/stores'
import { isCloudHosted } from '$lib/cloud'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Button, Skeleton, Tab, Tabs } from '$lib/components/common'
import { Alert, Button, Section, Skeleton, Tab, Tabs } from '$lib/components/common'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import DeployToSetting from '$lib/components/DeployToSetting.svelte'
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
@@ -31,7 +33,7 @@
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { clone, emptyString } from '$lib/utils'
import { RotateCw, Trash2, Slack, Save } from 'lucide-svelte'
import { RotateCw, Save, Slack } from 'lucide-svelte'
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
import Toggle from '$lib/components/Toggle.svelte'
@@ -73,6 +75,16 @@
let teams_team_id: string | undefined = $state()
let teams_team_name: string | undefined = $state()
let useCustomSlackApp: boolean = $state(false)
let slackAppType: 'instance' | 'workspace' = $state('instance')
// Keep slackAppType and useCustomSlackApp in sync
$effect(() => {
if (slackAppType === 'workspace') {
useCustomSlackApp = true
} else {
useCustomSlackApp = false
}
})
let slackOAuthClientId: string = $state('')
let slackOAuthClientSecret: string = $state('')
let slackOAuthConfigLoaded: boolean = $state(false)
@@ -126,11 +138,12 @@
let editedWorkspaceEncryptionKey: string | undefined = $state(undefined)
let workspaceReencryptionInProgress: boolean = $state(false)
let encryptionKeyRegex = /^[a-zA-Z0-9]{64}$/
let slack_tabs: 'slack_commands' | 'teams_commands' = $state('slack_commands')
let tab = $state(
($page.url.searchParams.get('tab') as
// All state derived from URL - no local state needed
let tab = $derived.by(() => {
const selectedTab = $page.url.searchParams.get('tab') as
| 'users'
| 'slack'
| 'teams'
| 'premium'
| 'general'
| 'webhook'
@@ -142,11 +155,39 @@
| 'git_sync'
| 'default_app'
| 'encryption'
| 'dependencies') ?? 'users'
| 'dependencies'
// Both 'slack' and 'teams' URLs map to 'slack' tab
if (selectedTab === 'teams') {
return 'slack'
}
return selectedTab || 'users'
})
let slack_tabs: 'slack_commands' | 'teams_commands' = $derived(
$page.url.searchParams.get('tab') === 'teams' ? 'teams_commands' : 'slack_commands'
)
let usingOpenaiClientCredentialsOauth = $state(false)
let loadedSettings = $state(false)
let oauths: Record<string, any> = $state({})
// OAuth validation functions
function isSlackOAuthConfigured(slackConfig: any): boolean {
return slackConfig && slackConfig.id?.trim() && slackConfig.secret?.trim()
}
function isTeamsOAuthConfigured(teamsConfig: any): boolean {
return (
teamsConfig &&
teamsConfig.id?.trim() &&
teamsConfig.secret?.trim() &&
teamsConfig.tenant?.trim()
)
}
const isSlackOAuthEnabled = $derived(isSlackOAuthConfigured(oauths?.slack))
const isTeamsOAuthEnabled = $derived(isTeamsOAuthConfigured(oauths?.teams))
async function editWorkspaceCommand(platform: 'slack' | 'teams'): Promise<void> {
if (platform === 'slack') {
@@ -352,6 +393,7 @@
workspace: $workspaceStore
})
useCustomSlackApp = !!config.slack_oauth_client_id
slackAppType = config.slack_oauth_client_id ? 'workspace' : 'instance'
slackOAuthClientId = config.slack_oauth_client_id || ''
slackOAuthClientSecret = config.slack_oauth_client_secret || ''
slackOAuthConfigLoaded = !!config.slack_oauth_client_id
@@ -360,6 +402,15 @@
}
}
async function loadGlobalOAuthSettings(): Promise<void> {
try {
oauths = (await SettingService.getGlobal({ key: 'oauths' })) ?? {}
} catch (e) {
console.error('Failed to load global OAuth config:', e)
oauths = {}
}
}
async function saveAndConnectSlack(): Promise<void> {
if (!$workspaceStore) return
@@ -403,6 +454,7 @@
}
useCustomSlackApp = false
slackAppType = 'instance'
slackOAuthClientId = ''
slackOAuthClientSecret = ''
slackOAuthConfigLoaded = false
@@ -434,6 +486,7 @@
untrack(() => {
loadSettings()
loadSlackOAuthConfig()
loadGlobalOAuthSettings()
})
}
})
@@ -478,19 +531,6 @@
}, 3000)
}
function updateFromSearchTab(searchTab: string | null, currentTab: string) {
if (searchTab && searchTab !== currentTab) {
tab = searchTab as typeof tab
}
}
$effect(() => {
updateFromSearchTab(
$page.url.searchParams.get('tab'),
untrack(() => tab)
)
})
// Function to check if there are unsaved changes in AI settings
function getAiSettingsInitialAndModifiedValues() {
// Only check for unsaved changes when on the AI tab
@@ -751,18 +791,24 @@
{:else if tab == 'premium'}
<PremiumInfo {customer_id} {plan} />
{:else if tab == 'slack'}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis"
>Workspace connections to Slack and Teams</div
>
<Description link="https://www.windmill.dev/docs/integrations/slack">
With workspace connections, you can trigger scripts or flows with a '/windmill' command
with your Slack or Teams bot.
</Description>
</div>
<Tabs bind:selected={slack_tabs}>
<div class="mt-4"></div>
<Section
label="Workspace connections to Slack and Teams"
description="With workspace connections, you can trigger scripts or flows with a '/windmill' command with your Slack or Teams bot or set the workspace error handler to send notifications to your Slack or Teams channel. <a href='https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler'>Learn more</a>."
class="space-y-6"
>
<Tabs
selected={slack_tabs}
on:selected={(e) => {
const params = new URLSearchParams($page.url.searchParams)
if (e.detail === 'teams_commands') {
params.set('tab', 'teams')
} else {
params.set('tab', 'slack')
}
goto(`?${params.toString()}`)
}}
>
<Tab value="slack_commands" label="Slack" />
<Tab value="teams_commands" label="Teams" />
</Tabs>
@@ -775,9 +821,13 @@
bind:initialPath={slackInitialPath}
bind:itemKind
onDisconnect={async () => {
await OauthService.disconnectSlack({ workspace: $workspaceStore ?? '' })
loadSettings()
sendUserToast('Disconnected Slack')
if (slackOAuthConfigLoaded) {
deleteSlackOAuthConfig()
} else {
await OauthService.disconnectSlack({ workspace: $workspaceStore ?? '' })
loadSettings()
sendUserToast('Disconnected Slack')
}
}}
onSelect={editSlackCommand}
connectHref="{base}/api/oauth/connect_slack"
@@ -787,93 +837,103 @@
onLoadSettings={loadSettings}
display_name={slack_team_name}
hideConnectButton={useCustomSlackApp && !slackOAuthConfigLoaded}
isOAuthEnabled={isSlackOAuthEnabled}
workspaceSpecificConnection={slackOAuthConfigLoaded}
>
{#snippet workspaceConfig()}
<!-- Workspace OAuth Configuration Section -->
<div class="flex flex-col">
{#if slackOAuthConfigLoaded}
<!-- Show saved config with delete button -->
<div class="flex flex-col gap-1 w-fit">
<div class="text-sm text-primary font-medium">Workspace specific Slack app</div>
<div
class="p-2 rounded-md border border-gray-200 dark:border-gray-700 bg-surface-secondary"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span class="text-sm text-primary">Client ID:</span>
<span class="text-xs text-secondary font-mono pt-1"
>{slackOAuthClientId}</span
>
</div>
<Button size="xs" onclick={deleteSlackOAuthConfig} btnClasses="w-fit">
<Trash2 size={14} class="mr-1" />
Delete
</Button>
</div>
</div>
</div>
{:else}
<!-- Show toggle and form to create config -->
<label class="text-sm flex gap-2 items-center font-medium text-primary">
<Toggle bind:checked={useCustomSlackApp} size="sm" />
<span class="text-xs text-secondary">Use workspace specific Slack app</span>
{#if !slack_team_name}
<div class="flex flex-col gap-1">
<!-- Show toggle buttons for app type selection -->
<ToggleButtonGroup bind:selected={slackAppType}>
{#snippet children({ item })}
<ToggleButton {item} value="instance" label="Instance specific Slack app" />
<ToggleButton {item} value="workspace" label="Workspace specific Slack app" />
{/snippet}
</ToggleButtonGroup>
<div class="text-2xs text-hint"
>Use the Slack app configured at the instance level if you want to use the same
Slack app for all workspaces. Configure your Slack app here if you want to use a
specific Slack app for this workspace.</div
>
</div>
{/if}
{#if slackOAuthConfigLoaded}
<!-- Show saved config with delete button -->
<div class="flex flex-col gap-1">
<div class="text-xs text-primary font-normal">Client ID</div>
<TextInput
inputProps={{
type: 'text',
readonly: true
}}
value={slackOAuthClientId}
/>
<div class="text-2xs text-hint"
>Client ID for the Slack app configured at the workspace level</div
>
</div>
{:else if slackAppType === 'workspace'}
<div class="flex flex-col gap-6">
<label class="flex flex-col gap-1">
<span class="text-primary font-semibold text-xs">Client ID</span>
<TextInput
inputProps={{
type: 'text',
placeholder: '1234567890.1234567890'
}}
bind:value={slackOAuthClientId}
/>
</label>
{#if useCustomSlackApp}
<div class="p-2 rounded border border-gray-200 dark:border-gray-700">
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client ID</span>
<input
class="windmill-input"
type="text"
placeholder="1234567890.1234567890"
bind:value={slackOAuthClientId}
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-primary font-semibold text-xs">Client secret</span>
<TextInput
inputProps={{
type: 'password',
placeholder: 'Enter client secret'
}}
bind:value={slackOAuthClientSecret}
/>
</label>
<label class="block pb-2">
<span class="text-primary font-semibold text-sm">Client secret</span>
<input
class="windmill-input"
type="password"
placeholder="Enter client secret"
bind:value={slackOAuthClientSecret}
/>
</label>
<CollapseLink text="Instructions">
<div class="text-xs text-secondary p-2">
Create a Slack app at{' '}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline"
>
Slack API
</a>. Set the redirect URI to:{' '}
<code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">
{window.location.origin}{base}/oauth/callback_slack
</code>
</div>
</CollapseLink>
<div class="pt-2">
<Button
size="xs"
variant="accent"
onclick={saveAndConnectSlack}
disabled={!slackOAuthClientId || !slackOAuthClientSecret}
startIcon={{ icon: Slack }}
btnClasses="w-fit"
>
Connect to Slack
</Button>
</div>
<CollapseLink text="Instructions">
<div class="text-xs text-secondary">
Create a Slack app at{' '}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:underline"
>
Slack API
</a>. Set the redirect URI to:{' '}
<code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">
{window.location.origin}{base}/oauth/callback_slack
</code>
</div>
{/if}
{/if}
</div>
</CollapseLink>
<div class="pt-2">
<Button
size="xs"
variant="accent"
onclick={saveAndConnectSlack}
disabled={!slackOAuthClientId || !slackOAuthClientSecret}
startIcon={{ icon: Slack }}
btnClasses="w-fit"
>
Connect to Slack
</Button>
</div>
</div>
{:else if !isSlackOAuthEnabled}
<Alert type="warning" title="Slack OAuth not configured">
Slack OAuth is not configured at the instance level. Please ask your administrator
to configure Slack OAuth settings in the instance settings before you can use
Slack features.
</Alert>
{/if}
{/snippet}
</ConnectionSection>
{:else if slack_tabs === 'teams_commands'}
@@ -884,28 +944,40 @@
/ Teams connection to run a custom script and send notifications.
</Alert>
<div class="pb-2"></div>
{:else}
<ConnectionSection
platform="teams"
teamName={teams_team_id}
bind:scriptPath={teamsScriptPath}
bind:initialPath={teamsInitialPath}
bind:itemKind
onDisconnect={async () => {
await OauthService.disconnectTeams({ workspace: $workspaceStore ?? '' })
loadSettings()
sendUserToast('Disconnected Teams')
}}
onSelect={editTeamsCommand}
connectHref={undefined}
createScriptHref="{base}/scripts/add?hub=hub%2F11591%2Fteams%2FExample%20of%20responding%20to%20a%20Microsoft%20Teams%20command"
createFlowHref="{base}/flows/add?hub=58"
documentationLink="https://www.windmill.dev/docs/integrations/teams"
onLoadSettings={loadSettings}
display_name={teams_team_name}
isOAuthEnabled={isTeamsOAuthEnabled}
>
{#snippet workspaceConfig()}
{#if !isTeamsOAuthEnabled}
<Alert type="warning" title="Teams OAuth not configured">
Teams OAuth is not configured at the instance level. Please ask your
administrator to configure Teams OAuth settings in the instance settings before
you can use Teams features.
</Alert>
{/if}
{/snippet}
</ConnectionSection>
{/if}
<ConnectionSection
platform="teams"
teamName={teams_team_id}
bind:scriptPath={teamsScriptPath}
bind:initialPath={teamsInitialPath}
bind:itemKind
onDisconnect={async () => {
await OauthService.disconnectTeams({ workspace: $workspaceStore ?? '' })
loadSettings()
sendUserToast('Disconnected Teams')
}}
onSelect={editTeamsCommand}
connectHref={undefined}
createScriptHref="{base}/scripts/add?hub=hub%2F11591%2Fteams%2FExample%20of%20responding%20to%20a%20Microsoft%20Teams%20command"
createFlowHref="{base}/flows/add?hub=58"
documentationLink="https://www.windmill.dev/docs/integrations/teams"
onLoadSettings={loadSettings}
display_name={teams_team_name}
/>
{/if}
</div>
</Section>
{:else if tab == 'general'}
<div class="flex flex-col gap-4 my-6">
<div class="flex flex-col gap-1">
@@ -1006,113 +1078,108 @@
</div>
{:else if tab == 'error_handler'}
{#if !$enterpriseLicense}
<div class="pt-4"></div>
<div class="pb-2"></div>
<Alert type="warning" title="Workspace error handler is an EE feature">
Workspace error handler is a Windmill EE feature. It enables using your current Slack
connection or a custom script to send notifications anytime any job would fail.
</Alert>
<div class="pb-2"></div>
{/if}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis"> Workspace Error Handler</div>
<Description
link="https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler"
>
Define a script or flow to be executed automatically in case of error in the workspace.
</Description>
</div>
</div>
<div class="flex flex-col gap-4 my-4">
<div class="flex flex-col gap-1">
<div class="text-xs font-semibold text-emphasis">
Script or flow to run as error handler</div
>
</div>
</div>
<ErrorOrRecoveryHandler
isEditable={true}
errorOrRecovery="error"
showScriptHelpText={true}
bind:handlerSelected={errorHandlerSelected}
bind:handlerPath={errorHandlerScriptPath}
customScriptTemplate="/scripts/add?hub=hub%2F9083%2Fwindmill%2Fworkspace_error_handler_template"
bind:customHandlerKind={errorHandlerItemKind}
bind:handlerExtraArgs={errorHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-sm">
The following args will be passed to the error handler:
<ul class="mt-1 ml-2">
<li><b>path</b>: The path of the script or flow that errored.</li>
<li>
<b>email</b>: The email of the user who ran the script or flow that errored.
</li>
<li><b>error</b>: The error details.</li>
<li><b>job_id</b>: The job id.</li>
<li><b>is_flow</b>: Whether the error comes from a flow.</li>
<li><b>workspace_id</b>: The workspace id of the failed script or flow.</li>
</ul>
<br />
The error handler will be executed by the automatically created group g/error_handler.
If your error handler requires variables or resources, you need to add them to the group.
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
<div class="flex flex-col mt-5 gap-5 items-start">
<Toggle
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
!emptyString(errorHandlerScriptPath) &&
emptyString(errorHandlerExtraArgs['channel']))}
bind:checked={errorHandlerMutedOnCancel}
options={{ right: 'Do not run error handler for canceled jobs' }}
/>
<Button
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
!emptyString(errorHandlerScriptPath) &&
emptyString(errorHandlerExtraArgs['channel']))}
size="sm"
on:click={editErrorHandler}
<div class="flex flex-col gap-12 py-4">
<Section
label="Workspace Error Handler"
description="Configure a centralized error handler that automatically executes when any script or flow in the workspace fails. On error, you can trigger a custom script or flow, send notifications via Slack or Microsoft Teams, or dispatch email alerts. The handler receives error details, job information, and context about the failed execution. <a href='https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler'>Learn more</a>"
class="space-y-6"
>
Save
</Button>
</div>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-sm font-semibold text-emphasis"> Workspace Critical Alerts</div>
<Description link="https://www.windmill.dev/docs/core_concepts/critical_alerts">
Critical alerts within the scope of a workspace are sent to the workspace admins through
a UI notification.
</Description>
<div class="flex flex-col mt-5 gap-5 items-start">
<Button
disabled={!$enterpriseLicense}
size="sm"
on:click={() => isCriticalAlertsUIOpen.set(true)}
>
Show critical alerts
</Button>
<ErrorOrRecoveryHandler
isEditable={true}
errorOrRecovery="error"
showScriptHelpText={true}
bind:handlerSelected={errorHandlerSelected}
bind:handlerPath={errorHandlerScriptPath}
customScriptTemplate="/scripts/add?hub=hub%2F9083%2Fwindmill%2Fworkspace_error_handler_template"
bind:customHandlerKind={errorHandlerItemKind}
bind:handlerExtraArgs={errorHandlerExtraArgs}
>
{#snippet customTabTooltip()}
<Tooltip>
<div class="flex gap-20 items-start mt-3">
<div class="text-sm">
The following args will be passed to the error handler:
<ul class="mt-1 ml-2">
<li><b>path</b>: The path of the script or flow that errored.</li>
<li>
<b>email</b>: The email of the user who ran the script or flow that errored.
</li>
<li><b>error</b>: The error details.</li>
<li><b>job_id</b>: The job id.</li>
<li><b>is_flow</b>: Whether the error comes from a flow.</li>
<li><b>workspace_id</b>: The workspace id of the failed script or flow.</li>
</ul>
<br />
The error handler will be executed by the automatically created group g/error_handler.
If your error handler requires variables or resources, you need to add them to the
group.
</div>
</div>
</Tooltip>
{/snippet}
</ErrorOrRecoveryHandler>
<div class="flex flex-col gap-6 items-start">
<Toggle
disabled={!$enterpriseLicense}
bind:checked={criticalAlertUIMuted}
options={{ right: 'Mute critical alerts UI for this workspace' }}
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
!emptyString(errorHandlerScriptPath) &&
emptyString(errorHandlerExtraArgs['channel']))}
bind:checked={errorHandlerMutedOnCancel}
options={{ right: 'Do not run error handler for canceled jobs' }}
/>
<Button
disabled={!$enterpriseLicense || criticalAlertUIMuted == initialCriticalAlertUIMuted}
size="sm"
on:click={editCriticalAlertMuteSetting}
disabled={!$enterpriseLicense ||
((errorHandlerSelected === 'slack' || errorHandlerSelected === 'teams') &&
!emptyString(errorHandlerScriptPath) &&
emptyString(errorHandlerExtraArgs['channel']))}
unifiedSize="md"
on:click={editErrorHandler}
startIcon={{ icon: Save }}
variant="accent"
>
Save mute setting
Save error handler
</Button>
</div>
</div>
</Section>
<hr class="border-t" />
<Section
label="Workspace Critical Alerts"
description="Critical alerts within the scope of a workspace are sent to the workspace admins through a UI notification. <a href='https://www.windmill.dev/docs/core_concepts/critical_alerts'>Learn more</a>"
class="flex flex-col gap-6"
>
<Toggle
disabled={!$enterpriseLicense}
bind:checked={criticalAlertUIMuted}
options={{ right: 'Mute critical alerts UI for this workspace' }}
/>
<Button
disabled={!$enterpriseLicense}
on:click={() => isCriticalAlertsUIOpen.set(true)}
btnClasses="w-fit"
>
Show critical alerts
</Button>
<Button
disabled={!$enterpriseLicense || criticalAlertUIMuted == initialCriticalAlertUIMuted}
size="sm"
on:click={editCriticalAlertMuteSetting}
variant="default"
startIcon={{ icon: Save }}
btnClasses="w-fit"
>
Save mute setting
</Button>
</Section>
</div>
{:else if tab == 'ai'}
<AISettings