diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index d468da0bc..c72a6f167 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -576,16 +576,18 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { projectId: req.params.workspaceId }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.GET_PROJECT_SLACK_CONFIG, - metadata: { - id: slackConfig.id + if (slackConfig) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.GET_PROJECT_SLACK_CONFIG, + metadata: { + id: slackConfig.id + } } - } - }); + }); + } return slackConfig; } diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index 5a9bc67e5..f5b6c8f06 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -232,6 +232,44 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:slackIntegrationId/channels", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + response: { + 200: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackChannels = await server.services.slack.getSlackIntegrationChannels({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId + }); + + return slackChannels; + } + }); + server.route({ method: "PATCH", url: "/:slackIntegrationId", diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index cf28a550f..f12e72851 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -1,11 +1,44 @@ import { Block, WebClient } from "@slack/web-api"; +import { logger } from "@app/lib/logger"; + import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; import { SlackTriggerFeature } from "./slack-types"; +export const fetchSlackChannels = async (botKey: string) => { + const slackChannels: { + name: string; + id: string; + }[] = []; + + const slackWebClient = new WebClient(botKey); + let cursor; + + do { + // eslint-disable-next-line no-await-in-loop + const response = await slackWebClient.conversations.list({ + cursor, + limit: 1000, + types: "public_channel,private_channel" + }); + + response.channels?.forEach((channel) => + slackChannels.push({ + name: channel.name_normalized as string, + id: channel.id as string + }) + ); + + // Set the cursor for the next page + cursor = response.response_metadata?.next_cursor; + } while (cursor); // Continue while there is a cursor + + return slackChannels; +}; + export const triggerSlackNotification = async ({ projectId, payloadBlocks, @@ -30,15 +63,15 @@ export const triggerSlackNotification = async ({ return; } - let targetChannels: string[] = []; + let targetChannelIds: string[] = []; if (feature === SlackTriggerFeature.ACCESS_REQUEST) { - targetChannels = slackIntegration.accessRequestChannels?.split(", ") || []; - if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) { + targetChannelIds = slackIntegration.accessRequestChannels?.split(", ") || []; + if (!targetChannelIds.length || !slackIntegration.isAccessRequestNotificationEnabled) { return; } } else if (feature === SlackTriggerFeature.SECRET_APPROVAL) { - targetChannels = slackIntegration.secretRequestChannels?.split(", ") || []; - if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) { + targetChannelIds = slackIntegration.secretRequestChannels?.split(", ") || []; + if (!targetChannelIds.length || !slackIntegration.isSecretRequestNotificationEnabled) { return; } } @@ -52,35 +85,16 @@ export const triggerSlackNotification = async ({ cipherTextBlob: slackIntegration.encryptedBotAccessToken }).toString("utf8"); - const targetChannelSet = new Set(targetChannels); const slackWebClient = new WebClient(botKey); - const channelIdsToSendNotif: string[] = []; - let cursor; - do { - // eslint-disable-next-line no-await-in-loop - const response = await slackWebClient.conversations.list({ - cursor, - limit: 1000, - types: "public_channel,private_channel" - }); - - response.channels?.forEach((channel) => { - if (channel.name_normalized && targetChannelSet.has(channel.name_normalized)) { - channelIdsToSendNotif.push(channel.id as string); - } - }); - - // Set the cursor for the next page - cursor = response.response_metadata?.next_cursor; - } while (cursor); // Continue while there is a cursor - - for await (const conversationId of channelIdsToSendNotif) { + for await (const conversationId of targetChannelIds) { // we send both text and blocks for compatibility with barebone clients - await slackWebClient.chat.postMessage({ - channel: conversationId, - text: payloadMessage, - blocks: payloadBlocks - }); + await slackWebClient.chat + .postMessage({ + channel: conversationId, + text: payloadMessage, + blocks: payloadBlocks + }) + .catch((err) => void logger.error(err)); } }; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index f3da106c9..f6ccf3c84 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; +import { fetchSlackChannels } from "./slack-fns"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TCompleteSlackIntegrationDTO, @@ -16,6 +17,7 @@ import { TGetSlackInstallUrlDTO, TGetSlackIntegrationByIdDTO, TGetSlackIntegrationByOrgDTO, + TGetSlackIntegrationChannelsDTO, TReinstallSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./slack-types"; @@ -285,6 +287,42 @@ export const slackServiceFactory = ({ return slackIntegration; }; + const getSlackIntegrationChannels = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetSlackIntegrationChannelsDTO) => { + const slackIntegration = await slackIntegrationDAL.findById(id); + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found." + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + slackIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + orgId: slackIntegration.orgId, + type: KmsDataKey.Organization + }); + + const botKey = orgDataKeyDecryptor({ + cipherTextBlob: slackIntegration.encryptedBotAccessToken + }).toString("utf8"); + + return fetchSlackChannels(botKey); + }; + const updateSlackIntegration = async ({ actorId, actor, @@ -352,6 +390,7 @@ export const slackServiceFactory = ({ completeSlackIntegration, getSlackInstaller, updateSlackIntegration, - deleteSlackIntegration + deleteSlackIntegration, + getSlackIntegrationChannels }; }; diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index b1b64ecba..01090c065 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -13,6 +13,8 @@ export type TGetSlackIntegrationByOrgDTO = Omit; export type TGetSlackIntegrationByIdDTO = { id: string } & Omit; +export type TGetSlackIntegrationChannelsDTO = { id: string } & Omit; + export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit< TOrgPermission, "orgId" diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts index 5ad8f9105..b120f4fa4 100644 --- a/frontend/src/hooks/api/workflowIntegrations/index.ts +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -7,5 +7,6 @@ export { fetchSlackInstallUrl, fetchSlackReinstallUrl, useGetSlackIntegrationById, + useGetSlackIntegrationChannels, useGetSlackIntegrations } from "./queries"; diff --git a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx index 6d779154f..aa060ba17 100644 --- a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -20,8 +20,8 @@ export const useUpdateSlackIntegration = () => { return data; }, onSuccess: (_, { orgId, id }) => { - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegrations(orgId)); } }); }; @@ -36,8 +36,8 @@ export const useDeleteSlackIntegration = () => { return data; }, onSuccess: (_, { orgId, id }) => { - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); - queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackIntegrations(orgId)); } }); }; diff --git a/frontend/src/hooks/api/workflowIntegrations/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx index 7f4c96e62..15b987281 100644 --- a/frontend/src/hooks/api/workflowIntegrations/queries.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -2,11 +2,12 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { SlackIntegration } from "./types"; +import { SlackIntegration, SlackIntegrationChannel } from "./types"; export const workflowIntegrationKeys = { - getSlackWorkflowIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], - getSlackWorkflowIntegration: (id?: string) => [{ id }, "slack-workflow-integration"] + getSlackIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], + getSlackIntegration: (id?: string) => [{ id }, "slack-workflow-integration"], + getSlackIntegrationChannels: (id?: string) => [{ id }, "slack-workflow-integration-channels"] }; export const fetchSlackInstallUrl = async ({ @@ -54,16 +55,31 @@ export const fetchSlackIntegrationById = async (id?: string) => { return data; }; +export const fetchSlackIntegrationChannels = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/slack/${id}/channels` + ); + + return data; +}; + export const useGetSlackIntegrations = (orgId?: string) => useQuery({ - queryKey: workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId), + queryKey: workflowIntegrationKeys.getSlackIntegrations(orgId), queryFn: () => fetchSlackIntegrations(), enabled: Boolean(orgId) }); export const useGetSlackIntegrationById = (id?: string) => useQuery({ - queryKey: workflowIntegrationKeys.getSlackWorkflowIntegration(id), + queryKey: workflowIntegrationKeys.getSlackIntegration(id), queryFn: () => fetchSlackIntegrationById(id), enabled: Boolean(id) }); + +export const useGetSlackIntegrationChannels = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getSlackIntegrationChannels(id), + queryFn: () => fetchSlackIntegrationChannels(id), + enabled: Boolean(id) + }); diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index b56fdb23d..9342af9f7 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -9,6 +9,11 @@ export type SlackIntegration = { teamName: string; }; +export type SlackIntegrationChannel = { + id: string; + name: string; +}; + export type TUpdateSlackIntegrationDTO = { id: string; orgId: string; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx index 5cde9a561..78111af24 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WorkflowIntegrationSection/WorkflowIntegrationTab.tsx @@ -1,6 +1,8 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import Link from "next/link"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -9,6 +11,10 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, ContentLoader, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, FormControl, Input, @@ -18,6 +24,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { + useGetSlackIntegrationChannels, useGetSlackIntegrations, useGetWorkspaceSlackConfig, useUpdateProjectSlackConfig @@ -26,9 +33,9 @@ import { const formSchema = z.object({ slackIntegrationId: z.string(), isSecretRequestNotificationEnabled: z.boolean(), - secretRequestChannels: z.string().default(""), + secretRequestChannels: z.string().array(), isAccessRequestNotificationEnabled: z.boolean(), - accessRequestChannels: z.string().default("") + accessRequestChannels: z.string().array() }); type TSlackConfigForm = z.infer; @@ -51,9 +58,9 @@ export const WorkflowIntegrationTab = () => { resolver: zodResolver(formSchema), defaultValues: { isAccessRequestNotificationEnabled: false, - accessRequestChannels: "", + accessRequestChannels: [], isSecretRequestNotificationEnabled: false, - secretRequestChannels: "" + secretRequestChannels: [] } }); @@ -61,6 +68,14 @@ export const WorkflowIntegrationTab = () => { const selectedSlackIntegrationId = watch("slackIntegrationId"); const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); + const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId); + const slackChannelIdToName = Object.fromEntries( + (slackChannels || []).map((channel) => [channel.id, channel.name]) + ); + const sortedSlackChannels = slackChannels?.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ); + const handleIntegrationSave = async (data: TSlackConfigForm) => { if (!currentWorkspace) { return; @@ -68,7 +83,9 @@ export const WorkflowIntegrationTab = () => { await updateProjectSlackConfig({ workspaceId: currentWorkspace.id, - ...data + ...data, + accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), + secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ") }); createNotification({ @@ -84,14 +101,27 @@ export const WorkflowIntegrationTab = () => { "isSecretRequestNotificationEnabled", slackConfig.isSecretRequestNotificationEnabled ); - setValue("secretRequestChannels", slackConfig.secretRequestChannels); setValue( "isAccessRequestNotificationEnabled", slackConfig.isAccessRequestNotificationEnabled ); - setValue("accessRequestChannels", slackConfig.accessRequestChannels); + + if (slackChannels) { + setValue( + "secretRequestChannels", + slackConfig.secretRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + setValue( + "accessRequestChannels", + slackConfig.accessRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + } } - }, [slackConfig]); + }, [slackConfig, slackChannels]); if (isSlackConfigLoading) { return ; @@ -173,19 +203,49 @@ export const WorkflowIntegrationTab = () => { ( + render={({ field: { value, onChange }, fieldState: { error } }) => ( - + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`secret-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + )} /> @@ -211,20 +271,49 @@ export const WorkflowIntegrationTab = () => { ( + render={({ field: { value, onChange }, fieldState: { error } }) => ( - + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`access-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + )} />