feat: finished slack integration update

This commit is contained in:
Sheen Capadngan
2024-09-02 20:13:01 +08:00
parent d604ef2480
commit f6d3831d6d
12 changed files with 448 additions and 28 deletions
@@ -17,9 +17,9 @@ export async function up(knex: Knex): Promise<void> {
tb.string("slackBotId").notNullable(); tb.string("slackBotId").notNullable();
tb.string("slackBotUserId").notNullable(); tb.string("slackBotUserId").notNullable();
tb.boolean("isAccessRequestNotificationEnabled").defaultTo(false); tb.boolean("isAccessRequestNotificationEnabled").defaultTo(false);
tb.string("accessRequestChannels"); tb.string("accessRequestChannels").defaultTo("");
tb.boolean("isSecretRequestNotificationEnabled").defaultTo(false); tb.boolean("isSecretRequestNotificationEnabled").defaultTo(false);
tb.string("secretRequestChannels"); tb.string("secretRequestChannels").defaultTo("");
tb.timestamps(true, true, true); tb.timestamps(true, true, true);
}); });
+89 -2
View File
@@ -1,9 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { SlackIntegrationsSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; 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 { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators";
export const registerSlackRouter = async (server: FastifyZodProvider) => { export const registerSlackRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -27,8 +29,9 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => {
200: z.string() 200: z.string()
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
// TODO: add audit logs
return server.services.slack.getInstallUrl({ return server.services.slack.getInstallUrl({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, 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({ server.route({
method: "GET", method: "GET",
url: "/oauth_redirect", url: "/oauth_redirect",
@@ -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(", ");
});
+2 -2
View File
@@ -34,12 +34,12 @@ export const triggerSlackNotification = async ({
let targetChannels: string[] = []; let targetChannels: string[] = [];
if (feature === SlackTriggerFeature.ACCESS_REQUEST) { if (feature === SlackTriggerFeature.ACCESS_REQUEST) {
targetChannels = slackIntegration.accessRequestChannels?.split(",") || []; targetChannels = slackIntegration.accessRequestChannels?.split(", ") || [];
if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) { if (!targetChannels.length || !slackIntegration.isAccessRequestNotificationEnabled) {
return; return;
} }
} else if (feature === SlackTriggerFeature.SECRET_APPROVAL) { } else if (feature === SlackTriggerFeature.SECRET_APPROVAL) {
targetChannels = slackIntegration.secretRequestChannels?.split(",") || []; targetChannels = slackIntegration.secretRequestChannels?.split(", ") || [];
if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) { if (!targetChannels.length || !slackIntegration.isSecretRequestNotificationEnabled) {
return; return;
} }
+68 -5
View File
@@ -10,10 +10,15 @@ import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types"; import { KmsDataKey } from "../kms/kms-types";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal";
import { TCompleteSlackIntegrationDTO, TGetSlackInstallUrlDTO } from "./slack-types"; import {
TCompleteSlackIntegrationDTO,
TGetSlackInstallUrlDTO,
TGetSlackIntegrationByProjectId,
TUpdateSlackIntegration
} from "./slack-types";
type TSlackServiceFactoryDep = { type TSlackServiceFactoryDep = {
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "create">; slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "create" | "findOne" | "findById" | "updateById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
@@ -142,14 +147,72 @@ export const slackServiceFactory = ({
redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect` redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect`
}); });
// TODO: add audit log here
return url; 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 { return {
getInstallUrl, getInstallUrl,
getSlackIntegrationByProjectId,
completeSlackIntegration, completeSlackIntegration,
getSlackInstaller getSlackInstaller,
updateSlackIntegration
}; };
}; };
+10
View File
@@ -2,6 +2,16 @@ import { TProjectPermission } from "@app/lib/types";
export type TGetSlackInstallUrlDTO = TProjectPermission; export type TGetSlackInstallUrlDTO = TProjectPermission;
export type TGetSlackIntegrationByProjectId = TProjectPermission;
export type TUpdateSlackIntegration = {
id: string;
isAccessRequestNotificationEnabled?: boolean;
accessRequestChannels?: string;
isSecretRequestNotificationEnabled?: boolean;
secretRequestChannels?: string;
} & Omit<TProjectPermission, "projectId">;
export type TCompleteSlackIntegrationDTO = { export type TCompleteSlackIntegrationDTO = {
projectId: string; projectId: string;
teamId: string; teamId: string;
+1
View File
@@ -38,6 +38,7 @@ export * from "./secretSharing";
export * from "./secretSnapshots"; export * from "./secretSnapshots";
export * from "./serverDetails"; export * from "./serverDetails";
export * from "./serviceTokens"; export * from "./serviceTokens";
export * from "./slack";
export * from "./ssoConfig"; export * from "./ssoConfig";
export * from "./subscriptions"; export * from "./subscriptions";
export * from "./tags"; export * from "./tags";
+2
View File
@@ -0,0 +1,2 @@
export { useUpdateSlackIntegration } from "./mutation";
export { fetchSlackInstallUrl, useGetSlackIntegrationByProject } from "./queries";
+21
View File
@@ -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));
}
});
};
+28
View File
@@ -1,5 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; 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) => { export const fetchSlackInstallUrl = async (workspaceId?: string) => {
const { data } = await apiRequest.get<string>("/api/v1/slack/install", { const { data } = await apiRequest.get<string>("/api/v1/slack/install", {
params: { params: {
@@ -9,3 +20,20 @@ export const fetchSlackInstallUrl = async (workspaceId?: string) => {
return data; return data;
}; };
export const fetchSlackIntegrationByProject = async (workspaceId?: string) => {
const { data } = await apiRequest.get<ProjectSlackIntegration>("/api/v1/slack", {
params: {
projectId: workspaceId
}
});
return data;
};
export const useGetSlackIntegrationByProject = (workspaceId?: string) =>
useQuery({
queryKey: slackKeys.getSlackIntegrationByProject(workspaceId),
queryFn: () => fetchSlackIntegrationByProject(workspaceId),
enabled: Boolean(workspaceId)
});
+17
View File
@@ -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;
};
@@ -1,37 +1,215 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router"; 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 { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks"; 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<typeof formSchema>;
export const NotificationTab = () => { export const NotificationTab = () => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { data: slackIntegration, isLoading: isSlackIntegrationLoading } =
useGetSlackIntegrationByProject(currentWorkspace?.id);
const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration();
const {
control,
watch,
handleSubmit,
setValue,
formState: { isDirty, isSubmitting }
} = useForm<TSlackIntegrationForm>({
resolver: zodResolver(formSchema),
defaultValues: {
isSecretRequestNotificationEnabled: slackIntegration?.isSecretRequestNotificationEnabled,
secretRequestChannels: slackIntegration?.secretRequestChannels || "",
isAccessRequestNotificationEnabled: slackIntegration?.isAccessRequestNotificationEnabled,
accessRequestChannels: slackIntegration?.accessRequestChannels || ""
}
});
const router = useRouter(); const router = useRouter();
const [isConnectToSlackLoading, setIsConnectToSlackLoading] = useToggle(false); 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 <ContentLoader />;
}
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between"> <div className="flex justify-between">
<h2 className="mb-2 flex-1 text-xl font-semibold text-mineshaft-100">Slack Integration</h2> <h2 className="mb-2 flex-1 text-xl font-semibold text-mineshaft-100">Slack Integration</h2>
</div> </div>
<p className="mb-4 text-gray-400"> <p className="mb-4 text-gray-400">
This integration allows you send notifications to your Slack workspace in response to events This integration allows you to send notifications to your Slack workspace in response to
in your project. events in your project.
</p> </p>
<Button {!slackIntegration && (
isLoading={isConnectToSlackLoading} <Button
onClick={async () => { isLoading={isConnectToSlackLoading}
setIsConnectToSlackLoading.on(); onClick={async () => {
const slackInstallUrl = await fetchSlackInstallUrl(currentWorkspace?.id); setIsConnectToSlackLoading.on();
if (slackInstallUrl) { const slackInstallUrl = await fetchSlackInstallUrl(currentWorkspace?.id);
router.push(slackInstallUrl); if (slackInstallUrl) {
} router.push(slackInstallUrl);
}} }
> }}
Connect to Slack >
</Button> Connect to Slack
</Button>
)}
{slackIntegration && (
<form onSubmit={handleSubmit(handleIntegrationSave)}>
<div>Connected Slack workspace: {slackIntegration.teamName}</div>
<div className="mt-2 mb-6">
<Button colorSchema="secondary" size="xs">
Reinstall integration
</Button>
<Button colorSchema="secondary" size="xs" className="ml-2">
Delete
</Button>
</div>
<Controller
control={control}
name="isSecretRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
className="mt-3 mb-2"
>
<Switch
id="secret-approval-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Secret Approval Requests</p>
</Switch>
</FormControl>
);
}}
/>
{secretRequestNotifState && (
<Controller
control={control}
name="secretRequestChannels"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Slack channels"
errorText={error?.message}
isError={Boolean(error)}
isRequired={false}
>
<Input
autoCorrect="off"
spellCheck={false}
placeholder="general, bot"
{...field}
/>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="isAccessRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
<Switch
id="access-request-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Access Requests</p>
</Switch>
</FormControl>
);
}}
/>
{accessRequestNotifState && (
<Controller
control={control}
name="accessRequestChannels"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Slack channels"
className="mt-0"
errorText={error?.message}
isError={Boolean(error)}
isRequired={false}
>
<Input
autoCorrect="off"
spellCheck={false}
placeholder="general, bot"
{...field}
/>
</FormControl>
)}
/>
)}
<Button
colorSchema="secondary"
className="mt-4"
type="submit"
isDisabled={!isDirty}
isLoading={isSubmitting}
>
Save
</Button>
</form>
)}
</div> </div>
); );
}; };