From f6d3831d6d7af6cef608b864392976bd04256a3e Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 2 Sep 2024 20:13:01 +0800 Subject: [PATCH] feat: finished slack integration update --- ...20240830142938_native-slack-integration.ts | 4 +- backend/src/server/routes/v1/slack-router.ts | 91 +++++++- .../services/slack/slack-auth-validators.ts | 13 ++ backend/src/services/slack/slack-fns.ts | 4 +- backend/src/services/slack/slack-service.ts | 73 +++++- backend/src/services/slack/slack-types.ts | 10 + frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/slack/index.ts | 2 + frontend/src/hooks/api/slack/mutation.tsx | 21 ++ frontend/src/hooks/api/slack/queries.tsx | 28 +++ frontend/src/hooks/api/slack/types.ts | 17 ++ .../NotificationSection/NotificationTab.tsx | 212 ++++++++++++++++-- 12 files changed, 448 insertions(+), 28 deletions(-) create mode 100644 backend/src/services/slack/slack-auth-validators.ts create mode 100644 frontend/src/hooks/api/slack/index.ts create mode 100644 frontend/src/hooks/api/slack/mutation.tsx create mode 100644 frontend/src/hooks/api/slack/types.ts diff --git a/backend/src/db/migrations/20240830142938_native-slack-integration.ts b/backend/src/db/migrations/20240830142938_native-slack-integration.ts index 01d88989b..d4fb22241 100644 --- a/backend/src/db/migrations/20240830142938_native-slack-integration.ts +++ b/backend/src/db/migrations/20240830142938_native-slack-integration.ts @@ -17,9 +17,9 @@ export async function up(knex: Knex): Promise { tb.string("slackBotId").notNullable(); tb.string("slackBotUserId").notNullable(); tb.boolean("isAccessRequestNotificationEnabled").defaultTo(false); - tb.string("accessRequestChannels"); + tb.string("accessRequestChannels").defaultTo(""); tb.boolean("isSecretRequestNotificationEnabled").defaultTo(false); - tb.string("secretRequestChannels"); + tb.string("secretRequestChannels").defaultTo(""); tb.timestamps(true, true, true); }); diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index e5fc3e782..57fcd5ec3 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -1,9 +1,11 @@ import { z } from "zod"; +import { SlackIntegrationsSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; export const registerSlackRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -27,8 +29,9 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { 200: z.string() } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + // TODO: add audit logs return server.services.slack.getInstallUrl({ actor: req.permission.type, actorId: req.permission.id, @@ -39,6 +42,90 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + teamName: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + return server.services.slack.getSlackIntegrationByProjectId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId + }); + } + }); + + server.route({ + method: "PATCH", + url: "/:slackIntegrationId", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + body: z.object({ + isAccessRequestNotificationEnabled: z.boolean().optional(), + accessRequestChannels: validateSlackChannelsField.optional(), + isSecretRequestNotificationEnabled: z.boolean().optional(), + secretRequestChannels: validateSlackChannelsField.optional() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + teamName: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + // TODO: add audit logs + return server.services.slack.updateSlackIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId, + ...req.body + }); + } + }); + server.route({ method: "GET", url: "/oauth_redirect", diff --git a/backend/src/services/slack/slack-auth-validators.ts b/backend/src/services/slack/slack-auth-validators.ts new file mode 100644 index 000000000..d81d12224 --- /dev/null +++ b/backend/src/services/slack/slack-auth-validators.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +export const validateSlackChannelsField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 3edb08f14..5c22186c6 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -34,12 +34,12 @@ export const triggerSlackNotification = async ({ let targetChannels: string[] = []; if (feature === SlackTriggerFeature.ACCESS_REQUEST) { - targetChannels = slackIntegration.accessRequestChannels?.split(",") || []; + targetChannels = slackIntegration.accessRequestChannels?.split(", ") || []; if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) { return; } } else if (feature === SlackTriggerFeature.SECRET_APPROVAL) { - targetChannels = slackIntegration.secretRequestChannels?.split(",") || []; + targetChannels = slackIntegration.secretRequestChannels?.split(", ") || []; if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) { return; } diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index b5627c3ba..347d2acd3 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -10,10 +10,15 @@ import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; -import { TCompleteSlackIntegrationDTO, TGetSlackInstallUrlDTO } from "./slack-types"; +import { + TCompleteSlackIntegrationDTO, + TGetSlackInstallUrlDTO, + TGetSlackIntegrationByProjectId, + TUpdateSlackIntegration +} from "./slack-types"; type TSlackServiceFactoryDep = { - slackIntegrationDAL: Pick; + slackIntegrationDAL: Pick; permissionService: Pick; projectDAL: Pick; kmsService: Pick; @@ -142,14 +147,72 @@ export const slackServiceFactory = ({ redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect` }); - // TODO: add audit log here - return url; }; + const getSlackIntegrationByProjectId = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId + }: TGetSlackIntegrationByProjectId) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); + const slackIntegration = await slackIntegrationDAL.findOne({ + projectId + }); + + return slackIntegration; + }; + + const updateSlackIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + isAccessRequestNotificationEnabled, + accessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels + }: TUpdateSlackIntegration) => { + const slackIntegration = await slackIntegrationDAL.findById(id); + if (!slackIntegration) { + throw new NotFoundError({ + message: "Slack integration not found" + }); + } + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + slackIntegration.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + return slackIntegrationDAL.updateById(slackIntegration.id, { + isAccessRequestNotificationEnabled, + accessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels + }); + }; + return { getInstallUrl, + getSlackIntegrationByProjectId, completeSlackIntegration, - getSlackInstaller + getSlackInstaller, + updateSlackIntegration }; }; diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index b76ce1e82..c9f0127c0 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -2,6 +2,16 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetSlackInstallUrlDTO = TProjectPermission; +export type TGetSlackIntegrationByProjectId = TProjectPermission; + +export type TUpdateSlackIntegration = { + id: string; + isAccessRequestNotificationEnabled?: boolean; + accessRequestChannels?: string; + isSecretRequestNotificationEnabled?: boolean; + secretRequestChannels?: string; +} & Omit; + export type TCompleteSlackIntegrationDTO = { projectId: string; teamId: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index a9d24a5c7..57644b616 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -38,6 +38,7 @@ export * from "./secretSharing"; export * from "./secretSnapshots"; export * from "./serverDetails"; export * from "./serviceTokens"; +export * from "./slack"; export * from "./ssoConfig"; export * from "./subscriptions"; export * from "./tags"; diff --git a/frontend/src/hooks/api/slack/index.ts b/frontend/src/hooks/api/slack/index.ts new file mode 100644 index 000000000..e015740d7 --- /dev/null +++ b/frontend/src/hooks/api/slack/index.ts @@ -0,0 +1,2 @@ +export { useUpdateSlackIntegration } from "./mutation"; +export { fetchSlackInstallUrl, useGetSlackIntegrationByProject } from "./queries"; diff --git a/frontend/src/hooks/api/slack/mutation.tsx b/frontend/src/hooks/api/slack/mutation.tsx new file mode 100644 index 000000000..338952aef --- /dev/null +++ b/frontend/src/hooks/api/slack/mutation.tsx @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { slackKeys } from "./queries"; +import { TUpdateSlackIntegrationDTO } from "./types"; + +export const useUpdateSlackIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSlackIntegrationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch(`/api/v1/slack/${dto.id}`, dto); + + return data; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(slackKeys.getSlackIntegrationByProject(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/slack/queries.tsx b/frontend/src/hooks/api/slack/queries.tsx index f2dbdc6be..3b82793f9 100644 --- a/frontend/src/hooks/api/slack/queries.tsx +++ b/frontend/src/hooks/api/slack/queries.tsx @@ -1,5 +1,16 @@ +import { useQuery } from "@tanstack/react-query"; + import { apiRequest } from "@app/config/request"; +import { ProjectSlackIntegration } from "./types"; + +export const slackKeys = { + getSlackIntegrationByProject: (workspaceId?: string) => [ + { workspaceId }, + "slack-integration-by-project" + ] +}; + export const fetchSlackInstallUrl = async (workspaceId?: string) => { const { data } = await apiRequest.get("/api/v1/slack/install", { params: { @@ -9,3 +20,20 @@ export const fetchSlackInstallUrl = async (workspaceId?: string) => { return data; }; + +export const fetchSlackIntegrationByProject = async (workspaceId?: string) => { + const { data } = await apiRequest.get("/api/v1/slack", { + params: { + projectId: workspaceId + } + }); + + return data; +}; + +export const useGetSlackIntegrationByProject = (workspaceId?: string) => + useQuery({ + queryKey: slackKeys.getSlackIntegrationByProject(workspaceId), + queryFn: () => fetchSlackIntegrationByProject(workspaceId), + enabled: Boolean(workspaceId) + }); diff --git a/frontend/src/hooks/api/slack/types.ts b/frontend/src/hooks/api/slack/types.ts new file mode 100644 index 000000000..9453c5d75 --- /dev/null +++ b/frontend/src/hooks/api/slack/types.ts @@ -0,0 +1,17 @@ +export type ProjectSlackIntegration = { + id: string; + teamName: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; +}; + +export type TUpdateSlackIntegrationDTO = { + id: string; + workspaceId: string; + isAccessRequestNotificationEnabled?: boolean; + accessRequestChannels?: string; + isSecretRequestNotificationEnabled?: boolean; + secretRequestChannels?: string; +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx index e18fd8281..da77b91b0 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/NotificationSection/NotificationTab.tsx @@ -1,37 +1,215 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { useRouter } from "next/router"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; -import { Button } from "@app/components/v2"; +import { createNotification } from "@app/components/notifications"; +import { Button, ContentLoader, FormControl, Input, Switch } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; -import { fetchSlackInstallUrl } from "@app/hooks/api/slack/queries"; +import { + fetchSlackInstallUrl, + useGetSlackIntegrationByProject, + useUpdateSlackIntegration +} from "@app/hooks/api"; + +const formSchema = z.object({ + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.string(), + isAccessRequestNotificationEnabled: z.boolean(), + accessRequestChannels: z.string() +}); + +type TSlackIntegrationForm = z.infer; export const NotificationTab = () => { const { currentWorkspace } = useWorkspace(); + const { data: slackIntegration, isLoading: isSlackIntegrationLoading } = + useGetSlackIntegrationByProject(currentWorkspace?.id); + const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration(); + const { + control, + watch, + handleSubmit, + setValue, + formState: { isDirty, isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + isSecretRequestNotificationEnabled: slackIntegration?.isSecretRequestNotificationEnabled, + secretRequestChannels: slackIntegration?.secretRequestChannels || "", + isAccessRequestNotificationEnabled: slackIntegration?.isAccessRequestNotificationEnabled, + accessRequestChannels: slackIntegration?.accessRequestChannels || "" + } + }); + const router = useRouter(); const [isConnectToSlackLoading, setIsConnectToSlackLoading] = useToggle(false); + const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); + const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); + + const handleIntegrationSave = async (data: TSlackIntegrationForm) => { + if (!currentWorkspace || !slackIntegration) { + return; + } + await updateSlackIntegration({ + workspaceId: currentWorkspace?.id, + id: slackIntegration?.id, + ...data + }); + + createNotification({ + type: "success", + text: "Successfully updated slack integration" + }); + }; + + useEffect(() => { + if (slackIntegration) { + setValue( + "isSecretRequestNotificationEnabled", + slackIntegration.isSecretRequestNotificationEnabled + ); + setValue("secretRequestChannels", slackIntegration.secretRequestChannels); + setValue( + "isAccessRequestNotificationEnabled", + slackIntegration.isAccessRequestNotificationEnabled + ); + setValue("accessRequestChannels", slackIntegration.accessRequestChannels); + } + }, [slackIntegration]); + + if (isSlackIntegrationLoading) { + return ; + } return (

Slack Integration

-

- This integration allows you send notifications to your Slack workspace in response to events - in your project. + This integration allows you to send notifications to your Slack workspace in response to + events in your project.

- + {!slackIntegration && ( + + )} + {slackIntegration && ( +
+
Connected Slack workspace: {slackIntegration.teamName}
+
+ + +
+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotifState && ( + ( + + + + )} + /> + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotifState && ( + ( + + + + )} + /> + )} + + + )}
); };