mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 16:02:24 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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 <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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 <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 <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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</script>
|
||||
|
||||
{#if uptodate}
|
||||
<span class="text-blue-400">
|
||||
<span class="text-accent text-xs">
|
||||
{uptodate}
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user