diff --git a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx index 761b86164f..edc7593708 100644 --- a/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx +++ b/js/console/src/components/settings/notifications/sinks/UpsertNotificationSinkModal.tsx @@ -1,6 +1,7 @@ import { ComponentProps, FormEvent, useCallback, useMemo } from 'react' import { Button, FormField, Input2, Modal } from '@pluralsh/design-system' +import { GqlError } from 'components/utils/Alert' import { ModalMountTransition } from 'components/utils/ModalMountTransition' import { Body2P } from 'components/utils/typography/Text' import { useTheme } from 'styled-components' @@ -17,10 +18,7 @@ import { appendConnection, updateCache } from 'utils/graphql' import { sinkTypeToIcon } from './NotificationSinksColumns' -const hookUrlMatch = [ - [SinkType.Slack, /^https:\/\/[^/]*?slack/], - [SinkType.Teams, /^https:\/\/[^/]*?office/], -] as const satisfies [SinkType, RegExp][] +const slackHookUrlRegex = /^https:\/\/[^/]*?slack/ type ModalBaseProps = { mode: 'edit' | 'create' @@ -39,6 +37,10 @@ function UpsertNotificationSinkModal({ ...props }: ModalProps) { const sink = mode === 'edit' ? props.sink : undefined + const sinkName = sink?.name + const sinkType = sink?.type + const slackUrl = sink?.configuration.slack?.url + const teamsUrl = sink?.configuration.teams?.url const theme = useTheme() const initialState = useMemo( () => ({ @@ -46,28 +48,25 @@ function UpsertNotificationSinkModal({ hookUrl: '', ...(mode === 'edit' ? { - name: sink?.name, - hookUrl: - sink?.configuration.slack?.url || sink?.configuration.teams?.url, + name: sinkName, + hookUrl: slackUrl || teamsUrl, } : {}), }), - [ - mode, - sink?.configuration.slack?.url, - sink?.configuration.teams?.url, - sink?.name, - ] + [mode, sinkName, slackUrl, teamsUrl] ) const { state, update, hasUpdates } = useUpdateState<{ name: string hookUrl: string }>(initialState) - const hookType = hookUrlMatch.find(([_, regex]) => - regex.test(state.hookUrl) - )?.[0] + const hookType = + mode === 'edit' && sinkType + ? sinkType + : slackHookUrlRegex.test(state.hookUrl) + ? SinkType.Slack + : SinkType.Teams - const [mutation, { loading }] = useUpsertNotificationSinkMutation({ + const [mutation, { loading, error }] = useUpsertNotificationSinkMutation({ onCompleted: () => onClose?.(), update: (cache, { data }) => updateCache(cache, { @@ -81,7 +80,7 @@ function UpsertNotificationSinkModal({ }), }) - const allowSubmit = hookType && state.name && state.hookUrl && hasUpdates + const allowSubmit = state.name && state.hookUrl && hasUpdates const onSubmit = useCallback( (e: FormEvent) => { @@ -166,6 +165,7 @@ function UpsertNotificationSinkModal({ {' '} webhook url to send this event alert to your team. + {error && }
{mode !== 'edit' && ( @@ -178,7 +178,7 @@ function UpsertNotificationSinkModal({ )} update({ hookUrl: e.target.value })} placeholder="https://hooks.provider.com/..." css={{ flex: '1 1 100%' }} diff --git a/lib/console/schema/notification_sink.ex b/lib/console/schema/notification_sink.ex index 5765c52333..768a513e27 100644 --- a/lib/console/schema/notification_sink.ex +++ b/lib/console/schema/notification_sink.ex @@ -44,6 +44,8 @@ defmodule Console.Schema.NotificationSink do end @valid ~w(type name)a + @slack_webhook_hosts ~w(slack.com slack-gov.com) + @teams_webhook_hosts ~w(office.com office365.com powerautomate.com powerplatform.com logic.azure.com) def changeset(model, attrs \\ %{}) do model @@ -57,8 +59,8 @@ defmodule Console.Schema.NotificationSink do defp config_changeset(model, attrs) do model |> cast(attrs, []) - |> cast_embed(:slack, with: &url_changeset/2) - |> cast_embed(:teams, with: &url_changeset/2) + |> cast_embed(:slack, with: &slack_changeset/2) + |> cast_embed(:teams, with: &teams_changeset/2) |> cast_embed(:plural, with: &plural_changeset/2) end @@ -68,6 +70,52 @@ defmodule Console.Schema.NotificationSink do |> validate_required([:url]) end + defp slack_changeset(model, attrs) do + model + |> url_changeset(attrs) + |> validate_change( + :url, + &validate_webhook_url(&1, &2, @slack_webhook_hosts, "must be a valid Slack webhook URL") + ) + end + + defp teams_changeset(model, attrs) do + model + |> url_changeset(attrs) + |> validate_change( + :url, + &validate_webhook_url( + &1, + &2, + @teams_webhook_hosts, + "must be a valid Microsoft Teams webhook URL" + ) + ) + end + + defp validate_webhook_url(:url, url, hosts, message) when is_binary(url) do + with {:ok, %URI{scheme: "https", host: host, userinfo: nil} = uri} when is_binary(host) <- + URI.new(url), + true <- valid_port?(uri.port), + true <- host_matches?(host, hosts) do + [] + else + _ -> [url: message] + end + end + + defp validate_webhook_url(:url, _, _, message), do: [url: message] + + defp valid_port?(port) when is_integer(port), do: port in 1..65_535 + defp valid_port?(nil), do: true + defp valid_port?(_), do: false + + defp host_matches?(host, hosts) do + host = String.downcase(host) + + Enum.any?(hosts, &(host == &1 || String.ends_with?(host, ".#{&1}"))) + end + defp plural_changeset(model, attrs) do model |> cast(attrs, ~w(priority urgent)a)