mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 00:03:08 +00:00
feat: msft teams support for critical alerts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
DELETE FROM global_settings WHERE name = 'teams';
|
||||
@@ -0,0 +1 @@
|
||||
INSERT INTO global_settings (name, value) VALUES ('teams', '{}');
|
||||
+4
-1
@@ -40,7 +40,7 @@ use windmill_common::{
|
||||
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, PIP_INDEX_URL_SETTING,
|
||||
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
|
||||
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING,
|
||||
TIMEOUT_WAIT_RESULT_SETTING,
|
||||
TIMEOUT_WAIT_RESULT_SETTING, TEAMS_SETTING
|
||||
},
|
||||
scripts::ScriptLang,
|
||||
stats_ee::schedule_stats,
|
||||
@@ -734,6 +734,9 @@ Windmill Community Edition {GIT_VERSION}
|
||||
SMTP_SETTING => {
|
||||
reload_smtp_config(&db).await;
|
||||
},
|
||||
TEAMS_SETTING => {
|
||||
tracing::info!("Teams setting changed.");
|
||||
},
|
||||
INDEXER_SETTING => {
|
||||
reload_indexer_config(&db).await;
|
||||
},
|
||||
|
||||
@@ -1479,7 +1479,7 @@ pub async fn reload_base_url_setting(db: &DB) -> error::Result<()> {
|
||||
#[cfg(feature = "oauth2")]
|
||||
{
|
||||
let mut l = windmill_api::OAUTH_CLIENTS.write().await;
|
||||
*l = windmill_api::oauth2_ee::build_oauth_clients(&base_url, oauths)
|
||||
*l = windmill_api::oauth2_ee::build_oauth_clients(&base_url, oauths, db).await
|
||||
.map_err(|e| tracing::error!("Error building oauth clients (is the oauth.json mounted and in correct format? Use '{}' as minimal oauth.json): {}", "{}", e))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -3183,6 +3183,22 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/teams/sync:
|
||||
post:
|
||||
operationId: syncTeams
|
||||
summary: synchronize Microsoft Teams information (teams/channels)
|
||||
tags:
|
||||
- teams
|
||||
responses:
|
||||
'200':
|
||||
description: Teams information successfully synchronized
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TeamInfo'
|
||||
|
||||
/w/{workspace}/resources/create:
|
||||
post:
|
||||
@@ -13936,3 +13952,49 @@ components:
|
||||
format: date-time
|
||||
required:
|
||||
- trigger_kind
|
||||
|
||||
TeamInfo:
|
||||
type: object
|
||||
required:
|
||||
- team_id
|
||||
- team_name
|
||||
- channels
|
||||
properties:
|
||||
team_id:
|
||||
type: string
|
||||
description: The unique identifier of the Microsoft Teams team
|
||||
example: "19:abc123def456@thread.tacv2"
|
||||
team_name:
|
||||
type: string
|
||||
description: The display name of the Microsoft Teams team
|
||||
example: "Engineering Team"
|
||||
channels:
|
||||
type: array
|
||||
description: List of channels within the team
|
||||
items:
|
||||
$ref: '#/components/schemas/ChannelInfo'
|
||||
|
||||
ChannelInfo:
|
||||
type: object
|
||||
required:
|
||||
- channel_id
|
||||
- channel_name
|
||||
- tenant_id
|
||||
- service_url
|
||||
properties:
|
||||
channel_id:
|
||||
type: string
|
||||
description: The unique identifier of the channel
|
||||
example: "19:channel123@thread.tacv2"
|
||||
channel_name:
|
||||
type: string
|
||||
description: The display name of the channel
|
||||
example: "General"
|
||||
tenant_id:
|
||||
type: string
|
||||
description: The Microsoft Teams tenant identifier
|
||||
example: "12345678-1234-1234-1234-123456789012"
|
||||
service_url:
|
||||
type: string
|
||||
description: The service URL for the channel
|
||||
example: "https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
use crate::{
|
||||
db::{ApiAuthed, DB},
|
||||
variables::decrypt,
|
||||
};
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use windmill_common::variables::decrypt;
|
||||
use anthropic::AnthropicCache;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
|
||||
@@ -13,10 +13,10 @@ use crate::{
|
||||
resources::get_resource_value_interpolated_internal,
|
||||
users::{require_owner_of_path, OptAuthed},
|
||||
utils::WithStarredInfoQuery,
|
||||
variables::encrypt,
|
||||
webhook_util::{WebhookMessage, WebhookShared},
|
||||
HTTP_CLIENT,
|
||||
};
|
||||
use windmill_common::variables::encrypt;
|
||||
#[cfg(feature = "parquet")]
|
||||
use crate::{
|
||||
job_helpers_ee::{
|
||||
|
||||
@@ -96,6 +96,8 @@ mod scripts;
|
||||
mod service_logs;
|
||||
mod settings;
|
||||
mod slack_approvals;
|
||||
#[cfg(feature = "enterprise")]
|
||||
mod teams_ee;
|
||||
#[cfg(feature = "smtp")]
|
||||
mod smtp_server_ee;
|
||||
mod static_assets;
|
||||
@@ -435,6 +437,7 @@ pub async fn run_server(
|
||||
jobs::workspace_unauthed_service().layer(cors.clone()),
|
||||
)
|
||||
.route("/slack", post(slack_approvals::slack_app_callback_handler))
|
||||
.nest("/teams", teams_ee::teams_service())
|
||||
.route(
|
||||
"/w/:workspace_id/jobs/slack_approval/:job_id",
|
||||
get(slack_approvals::request_slack_approval),
|
||||
|
||||
@@ -31,7 +31,7 @@ use windmill_common::{
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
|
||||
use windmill_common::variables::{decrypt, encrypt};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
@@ -676,17 +676,3 @@ pub async fn get_value_internal<'c>(
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
|
||||
mc.encrypt_str_to_base64(value)
|
||||
}
|
||||
|
||||
pub fn decrypt(mc: &MagicCrypt256, value: String) -> Result<String> {
|
||||
mc.decrypt_base64_to_string(value).map_err(|e| match e {
|
||||
MagicCryptError::DecryptError(_) => Error::InternalErr(
|
||||
"Could not decrypt value. The value may have been encrypted with a different key."
|
||||
.to_string(),
|
||||
),
|
||||
_ => Error::InternalErr(e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ use windmill_git_sync::handle_deployment_metadata;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::utils::require_admin_or_devops;
|
||||
|
||||
use crate::variables::{decrypt, encrypt};
|
||||
use windmill_common::variables::{decrypt, encrypt};
|
||||
use hyper::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
|
||||
@@ -36,7 +36,7 @@ use windmill_common::{
|
||||
variables::ExportableListableVariable,
|
||||
};
|
||||
|
||||
use crate::variables::decrypt;
|
||||
use windmill_common::variables::decrypt;
|
||||
use hyper::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub const PIP_INDEX_URL_SETTING: &str = "pip_index_url";
|
||||
pub const SCIM_TOKEN_SETTING: &str = "scim_token";
|
||||
pub const SAML_METADATA_SETTING: &str = "saml_metadata";
|
||||
pub const SMTP_SETTING: &str = "smtp_settings";
|
||||
pub const TEAMS_SETTING: &str = "teams";
|
||||
pub const INDEXER_SETTING: &str = "indexer_settings";
|
||||
pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result";
|
||||
|
||||
@@ -94,3 +95,21 @@ pub const ENV_SETTINGS: [&str; 54] = [
|
||||
"OTEL_TRACING",
|
||||
"OTEL_LOGS",
|
||||
];
|
||||
|
||||
use crate::error;
|
||||
use sqlx::Pool;
|
||||
use sqlx::postgres::Postgres;
|
||||
|
||||
pub async fn load_value_from_global_settings(
|
||||
db: &Pool<Postgres>,
|
||||
setting_name: &str,
|
||||
) -> error::Result<Option<serde_json::Value>> {
|
||||
let r = sqlx::query!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
setting_name
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.map(|x| x.value);
|
||||
Ok(r)
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub mod job_s3_helpers_ee;
|
||||
pub mod jobs;
|
||||
pub mod more_serde;
|
||||
pub mod oauth2;
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub mod teams_ee;
|
||||
pub mod otel_ee;
|
||||
pub mod queue;
|
||||
pub mod s3_helpers;
|
||||
|
||||
@@ -18,6 +18,9 @@ pub const WORKSPACE_SLACK_BOT_TOKEN_PATH: &str = "f/slack_bot/bot_token";
|
||||
|
||||
pub const GLOBAL_SLACK_BOT_TOKEN_PATH: &str = "f/slack_bot/global_bot_token";
|
||||
|
||||
pub const GLOBAL_TEAMS_BOT_TOKEN_PATH: &str = "f/teams_bot/global_bot_token";
|
||||
|
||||
pub const GLOBAL_TEAMS_API_TOKEN_PATH: &str = "f/teams_bot/global_api_token";
|
||||
lazy_static::lazy_static! {
|
||||
|
||||
pub static ref REQUIRE_PREEXISTING_USER_FOR_OAUTH: AtomicBool = AtomicBool::new(std::env::var("REQUIRE_PREEXISTING_USER_FOR_OAUTH")
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use magic_crypt::{MagicCrypt256, MagicCryptError, MagicCryptTrait};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::error;
|
||||
|
||||
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
|
||||
|
||||
@@ -157,6 +158,20 @@ pub async fn decrypt_value_with_mc(
|
||||
})?)
|
||||
}
|
||||
|
||||
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
|
||||
mc.encrypt_str_to_base64(value)
|
||||
}
|
||||
|
||||
pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result<String> {
|
||||
mc.decrypt_base64_to_string(value).map_err(|e| match e {
|
||||
MagicCryptError::DecryptError(_) => error::Error::InternalErr(
|
||||
"Could not decrypt value. The value may have been encrypted with a different key."
|
||||
.to_string(),
|
||||
),
|
||||
_ => error::Error::InternalErr(e.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub const WM_SCHEDULED_FOR: &str = "WM_SCHEDULED_FOR";
|
||||
|
||||
pub async fn get_reserved_variables(
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
'visma',
|
||||
'spotify',
|
||||
'snowflake_oauth',
|
||||
'teams',
|
||||
'xero'
|
||||
]
|
||||
|
||||
@@ -170,10 +171,12 @@
|
||||
<div class="py-1" />
|
||||
<OAuthSetting login={false} name="slack" bind:value={oauths['slack']} />
|
||||
<div class="py-1" />
|
||||
<OAuthSetting login={false} name="teams" bind:value={oauths['teams']} />
|
||||
<div class="py-1" />
|
||||
|
||||
{#each Object.keys(oauths) as k}
|
||||
{#if oauths[k] && !('login_config' in oauths[k])}
|
||||
{#if !['slack'].includes(k) && oauths[k]}
|
||||
{#if !['slack', 'teams'].includes(k) && 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 -->
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
export let center = false
|
||||
export let isSelected = false
|
||||
export let formatExtension = undefined
|
||||
$: iconComponent = APP_TO_ICON_COMPONENT[name] || APP_TO_ICON_COMPONENT[name.split('_')[0]]
|
||||
|
||||
$: iconComponent = name === "teams"
|
||||
? APP_TO_ICON_COMPONENT.ms_teams_webhook
|
||||
: APP_TO_ICON_COMPONENT[name] || APP_TO_ICON_COMPONENT[name.split('_')[0]]
|
||||
</script>
|
||||
|
||||
<div class="truncate flex flex-row gap-2 {center ? 'justify-center items-center' : ''} -pl-2">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
BadgeX,
|
||||
Info,
|
||||
Plus,
|
||||
RefreshCcw,
|
||||
Slack,
|
||||
X
|
||||
} from 'lucide-svelte'
|
||||
@@ -16,7 +17,7 @@
|
||||
import ObjectStoreConfigSettings from './ObjectStoreConfigSettings.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import ConfirmButton from './ConfirmButton.svelte'
|
||||
import { IndexSearchService, SettingService } from '$lib/gen'
|
||||
import { IndexSearchService, SettingService, TeamsService } from '$lib/gen'
|
||||
import { Button, SecondsInput, Skeleton } from './common'
|
||||
import Password from './Password.svelte'
|
||||
import { classNames } from '$lib/utils'
|
||||
@@ -27,6 +28,8 @@
|
||||
import { fade } from 'svelte/transition'
|
||||
import { base } from '$lib/base'
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import type { TeamInfo, ChannelInfo } from '../gen/types.gen'
|
||||
|
||||
export let setting: Setting
|
||||
export let version: string
|
||||
@@ -40,6 +43,11 @@
|
||||
attempted_at: string
|
||||
} | null
|
||||
|
||||
let teams: TeamInfo[] = []
|
||||
let selectedTeam: TeamInfo | null = null
|
||||
let selectedChannel: ChannelInfo | null = null
|
||||
let isFetching = false
|
||||
|
||||
function showSetting(setting: string, values: Record<string, any>) {
|
||||
if (setting == 'dev_instance') {
|
||||
if (values['license_key'] == undefined) {
|
||||
@@ -111,6 +119,55 @@
|
||||
valid: false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTeams() {
|
||||
isFetching = true
|
||||
try {
|
||||
teams = await TeamsService.syncTeams()
|
||||
} catch (error) {
|
||||
console.error('Error fetching teams:', error)
|
||||
} finally {
|
||||
isFetching = false
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchTeams()
|
||||
const storedTeams = $values['critical_error_channels']?.find((el) =>
|
||||
el.hasOwnProperty('teams_channel')
|
||||
)?.teams_channel
|
||||
if (storedTeams) {
|
||||
selectedTeam = teams.find((team) => team.team_name === storedTeams.team_name) || null
|
||||
selectedChannel =
|
||||
selectedTeam?.channels.find((channel) => channel.channel_id === storedTeams.channel_id) ||
|
||||
null
|
||||
}
|
||||
})
|
||||
|
||||
function handleTeamChange(event: Event) {
|
||||
const teamId = (event.target as HTMLSelectElement).value
|
||||
selectedTeam = teams.find((team) => team.team_id === teamId) || null
|
||||
selectedChannel = null
|
||||
console.log(selectedTeam)
|
||||
}
|
||||
|
||||
function handleChannelChange(event: Event, setting: Setting, i: number) {
|
||||
const channelId = (event.target as HTMLSelectElement).value
|
||||
if (selectedTeam) {
|
||||
selectedChannel =
|
||||
selectedTeam.channels.find((channel) => channel.channel_id === channelId) || null
|
||||
}
|
||||
if (event.target?.['value']) {
|
||||
$values[setting.key][i] = {
|
||||
teams_channel: {
|
||||
team_id: selectedTeam?.team_id,
|
||||
team_name: selectedTeam?.team_name,
|
||||
channel_id: channelId,
|
||||
channel_name: selectedChannel?.channel_name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null)}
|
||||
@@ -339,7 +396,7 @@
|
||||
{#each $values[setting.key] ?? [] as v, i}
|
||||
<div class="flex w-full max-w-lg mt-1 gap-2 items-center">
|
||||
<select
|
||||
class="w-20"
|
||||
class="max-w-24"
|
||||
on:change={(e) => {
|
||||
if (e.target?.['value']) {
|
||||
$values[setting.key][i] = {
|
||||
@@ -347,10 +404,17 @@
|
||||
}
|
||||
}
|
||||
}}
|
||||
value={v && 'slack_channel' in v ? 'slack_channel' : 'email'}
|
||||
value={!v
|
||||
? 'email'
|
||||
: 'slack_channel' in v
|
||||
? 'slack_channel'
|
||||
: 'teams_channel' in v
|
||||
? 'teams_channel'
|
||||
: 'email'}
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="slack_channel">Slack</option>
|
||||
<option value="teams_channel">Teams</option>
|
||||
</select>
|
||||
{#if v && 'slack_channel' in v}
|
||||
<input
|
||||
@@ -365,6 +429,43 @@
|
||||
}}
|
||||
value={v?.slack_channel ?? ''}
|
||||
/>
|
||||
{:else if v && 'teams_channel' in v}
|
||||
<div class="flex flex-row gap-2 w-full">
|
||||
<select on:change={handleTeamChange}>
|
||||
<option value="" disabled selected={!selectedTeam}>Select team</option>
|
||||
{#each teams as team}
|
||||
<option
|
||||
value={team.team_id}
|
||||
selected={selectedTeam?.team_id === team.team_id}
|
||||
>
|
||||
{team.team_name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selectedTeam}
|
||||
<select
|
||||
id="channel-select"
|
||||
on:change={(e) => handleChannelChange(e, setting, i)}
|
||||
>
|
||||
<option value="" disabled selected={!selectedChannel}
|
||||
>Select channel</option
|
||||
>
|
||||
{#each selectedTeam.channels as channel}
|
||||
<option
|
||||
value={channel.channel_id}
|
||||
selected={selectedChannel?.channel_id === channel.channel_id}
|
||||
>
|
||||
{channel.channel_name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div>
|
||||
<button on:click={fetchTeams} class="flex items-center gap-1 mt-2">
|
||||
<RefreshCcw size={16} class={isFetching ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="email"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
$: enabled = value != undefined
|
||||
|
||||
let tenant: string = ''
|
||||
$: name == 'microsoft' && changeTenantId(tenant)
|
||||
$: (name == 'microsoft' || name == 'teams') && changeTenantId(tenant)
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
@@ -24,6 +24,9 @@
|
||||
) {
|
||||
tenant = value['login_config']['auth_url'].split('/')[3]
|
||||
}
|
||||
if (name === 'teams' && value?.tenant) {
|
||||
tenant = value.tenant
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Could not set tenantId', e)
|
||||
}
|
||||
@@ -32,13 +35,20 @@
|
||||
function changeTenantId(tenant: string) {
|
||||
if (value && tenant) {
|
||||
if (tenant != '') {
|
||||
value = {
|
||||
...value,
|
||||
login_config: {
|
||||
auth_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
|
||||
token_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
||||
userinfo_url: `https://graph.microsoft.com/oidc/userinfo`,
|
||||
scopes: ['openid', 'profile', 'email']
|
||||
if (name === "teams") {
|
||||
value = {
|
||||
...value,
|
||||
tenant
|
||||
}
|
||||
} else {
|
||||
value = {
|
||||
...value,
|
||||
login_config: {
|
||||
auth_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
|
||||
token_url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
||||
userinfo_url: `https://graph.microsoft.com/oidc/userinfo`,
|
||||
scopes: ['openid', 'profile', 'email']
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -67,7 +77,7 @@
|
||||
>
|
||||
{#if enabled}
|
||||
<div class="p-2 rounded border mb-4">
|
||||
{#if name != 'slack'}
|
||||
{#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']} />
|
||||
@@ -81,7 +91,7 @@
|
||||
<span class="text-primary font-semibold text-sm">Client Secret</span>
|
||||
<input type="text" placeholder="Client Secret" bind:value={value['secret']} />
|
||||
</label>
|
||||
{#if name == 'microsoft'}
|
||||
{#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} />
|
||||
@@ -166,6 +176,13 @@
|
||||
under "Delegated Permissions".
|
||||
</div>
|
||||
</CollapseLink>
|
||||
{:else if name == 'teams'}
|
||||
<CollapseLink text="Instructions">
|
||||
<div class="text-sm text-secondary border p-2">
|
||||
Follow this guide on <a href="https://www.windmill.dev/docs/misc/setup_oauth#teams" target="_blank"
|
||||
>Windmill Docs</a> to create a new Microsoft Teams App. Then paste Client ID, Tenant ID, and Client Secret here.
|
||||
</div>
|
||||
</CollapseLink>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -271,7 +271,7 @@ export const settings: Record<string, Setting[]> = {
|
||||
{
|
||||
label: 'Critical alert channels',
|
||||
description:
|
||||
'Channels to send critical alerts to. SMTP and Slack must be configured below. <a href="https://www.windmill.dev/docs/core_concepts/critical_alerts">Learn more</a>',
|
||||
'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>',
|
||||
key: 'critical_error_channels',
|
||||
fieldType: 'critical_error_channels',
|
||||
storage: 'setting',
|
||||
|
||||
Reference in New Issue
Block a user