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

View File

@@ -17,9 +17,9 @@ export async function up(knex: Knex): Promise<void> {
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);
});

View File

@@ -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",

View File

@@ -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(", ");
});

View File

@@ -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;
}

View File

@@ -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<TSlackIntegrationDALFactory, "create">;
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "create" | "findOne" | "findById" | "updateById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectDAL: Pick<TProjectDALFactory, "findById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
@@ -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
};
};

View File

@@ -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<TProjectPermission, "projectId">;
export type TCompleteSlackIntegrationDTO = {
projectId: string;
teamId: string;

View File

@@ -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";

View File

@@ -0,0 +1,2 @@
export { useUpdateSlackIntegration } from "./mutation";
export { fetchSlackInstallUrl, useGetSlackIntegrationByProject } from "./queries";

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));
}
});
};

View File

@@ -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<string>("/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<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)
});

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;
};

View File

@@ -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<typeof formSchema>;
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<TSlackIntegrationForm>({
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 <ContentLoader />;
}
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<h2 className="mb-2 flex-1 text-xl font-semibold text-mineshaft-100">Slack Integration</h2>
</div>
<p className="mb-4 text-gray-400">
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.
</p>
<Button
isLoading={isConnectToSlackLoading}
onClick={async () => {
setIsConnectToSlackLoading.on();
const slackInstallUrl = await fetchSlackInstallUrl(currentWorkspace?.id);
if (slackInstallUrl) {
router.push(slackInstallUrl);
}
}}
>
Connect to Slack
</Button>
{!slackIntegration && (
<Button
isLoading={isConnectToSlackLoading}
onClick={async () => {
setIsConnectToSlackLoading.on();
const slackInstallUrl = await fetchSlackInstallUrl(currentWorkspace?.id);
if (slackInstallUrl) {
router.push(slackInstallUrl);
}
}}
>
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>
);
};