From a2b555dd810b5bec18c55a4cf831d6a8f73caab7 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Tue, 3 Sep 2024 22:08:31 +0800 Subject: [PATCH] feat: finished org-level integration management flow --- backend/src/@types/knex.d.ts | 8 + ...20240830142938_native-slack-integration.ts | 29 ++- backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../src/db/schemas/project-slack-configs.ts | 24 ++ backend/src/db/schemas/slack-integrations.ts | 8 +- .../ee/services/audit-log/audit-log-types.ts | 11 +- backend/src/server/routes/v1/index.ts | 8 +- backend/src/server/routes/v1/slack-router.ts | 225 ++++++++++-------- backend/src/services/slack/slack-service.ts | 188 ++++++++------- backend/src/services/slack/slack-types.ts | 28 ++- frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/slack/index.ts | 2 +- frontend/src/hooks/api/slack/queries.tsx | 10 - .../hooks/api/workflowIntegrations/index.ts | 6 + .../api/workflowIntegrations/mutation.tsx | 38 +++ .../api/workflowIntegrations/queries.tsx | 55 +++++ .../hooks/api/workflowIntegrations/types.ts | 22 ++ .../components/OrgTabGroup/OrgTabGroup.tsx | 5 + .../AddWorkflowIntegrationForm.tsx | 97 ++++++++ .../IntegrationFormDetails.tsx | 23 ++ .../OrgWorkflowIntegrationTab.tsx | 188 +++++++++++++++ .../SlackIntegrationForm.tsx | 121 ++++++++++ 23 files changed, 865 insertions(+), 234 deletions(-) create mode 100644 backend/src/db/schemas/project-slack-configs.ts create mode 100644 frontend/src/hooks/api/workflowIntegrations/index.ts create mode 100644 frontend/src/hooks/api/workflowIntegrations/mutation.tsx create mode 100644 frontend/src/hooks/api/workflowIntegrations/queries.tsx create mode 100644 frontend/src/hooks/api/workflowIntegrations/types.ts create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index dad478d0e..7c9d88a46 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -196,6 +196,9 @@ import { TProjectRolesUpdate, TProjects, TProjectsInsert, + TProjectSlackConfigs, + TProjectSlackConfigsInsert, + TProjectSlackConfigsUpdate, TProjectsUpdate, TProjectUserAdditionalPrivilege, TProjectUserAdditionalPrivilegeInsert, @@ -787,6 +790,11 @@ declare module "knex/types/tables" { TSlackIntegrationsInsert, TSlackIntegrationsUpdate >; + [TableName.ProjectSlackConfigs]: KnexOriginal.CompositeTableType< + TProjectSlackConfigs, + TProjectSlackConfigsInsert, + TProjectSlackConfigsUpdate + >; [TableName.AdminSlackConfig]: KnexOriginal.CompositeTableType< TAdminSlackConfigs, TAdminSlackConfigsInsert, diff --git a/backend/src/db/migrations/20240830142938_native-slack-integration.ts b/backend/src/db/migrations/20240830142938_native-slack-integration.ts index 5e80bdb77..15842af05 100644 --- a/backend/src/db/migrations/20240830142938_native-slack-integration.ts +++ b/backend/src/db/migrations/20240830142938_native-slack-integration.ts @@ -7,8 +7,10 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.SlackIntegrations))) { await knex.schema.createTable(TableName.SlackIntegrations, (tb) => { tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - tb.string("projectId").notNullable().unique(); - tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + tb.string("slug").notNullable(); + tb.uuid("orgId").notNullable(); + tb.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + tb.string("description"); tb.string("teamId").notNullable(); tb.string("teamName").notNullable(); tb.string("slackUserId").notNullable(); @@ -16,6 +18,20 @@ export async function up(knex: Knex): Promise { tb.binary("encryptedBotAccessToken").notNullable(); tb.string("slackBotId").notNullable(); tb.string("slackBotUserId").notNullable(); + tb.unique(["orgId", "slug"]); + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SlackIntegrations); + } + + if (!(await knex.schema.hasTable(TableName.ProjectSlackConfigs))) { + await knex.schema.createTable(TableName.ProjectSlackConfigs, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("projectId").notNullable().unique(); + tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + tb.uuid("slackIntegrationId").notNullable(); + tb.foreign("slackIntegrationId").references("id").inTable(TableName.SlackIntegrations).onDelete("CASCADE"); tb.boolean("isAccessRequestNotificationEnabled").notNullable().defaultTo(false); tb.string("accessRequestChannels").notNullable().defaultTo(""); tb.boolean("isSecretRequestNotificationEnabled").notNullable().defaultTo(false); @@ -23,7 +39,7 @@ export async function up(knex: Knex): Promise { tb.timestamps(true, true, true); }); - await createOnUpdateTrigger(knex, TableName.SlackIntegrations); + await createOnUpdateTrigger(knex, TableName.ProjectSlackConfigs); } if (!(await knex.schema.hasTable(TableName.AdminSlackConfig))) { @@ -39,9 +55,12 @@ export async function up(knex: Knex): Promise { } export async function down(knex: Knex): Promise { - await knex.schema.dropTableIfExists(TableName.SlackIntegrations); - await dropOnUpdateTrigger(knex, TableName.SlackIntegrations); + await knex.schema.dropTableIfExists(TableName.ProjectSlackConfigs); + await dropOnUpdateTrigger(knex, TableName.ProjectSlackConfigs); await knex.schema.dropTableIfExists(TableName.AdminSlackConfig); await dropOnUpdateTrigger(knex, TableName.AdminSlackConfig); + + await knex.schema.dropTableIfExists(TableName.SlackIntegrations); + await dropOnUpdateTrigger(knex, TableName.SlackIntegrations); } diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index aaa4e02b8..6fb79982a 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -63,6 +63,7 @@ export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; +export * from "./project-slack-configs"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index fa2c80f7c..3e4f259ff 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -116,6 +116,7 @@ export enum TableName { // @depreciated KmsKeyVersion = "kms_key_versions", SlackIntegrations = "slack_integrations", + ProjectSlackConfigs = "project_slack_configs", AdminSlackConfig = "admin_slack_configs" } diff --git a/backend/src/db/schemas/project-slack-configs.ts b/backend/src/db/schemas/project-slack-configs.ts new file mode 100644 index 000000000..0a46e5aae --- /dev/null +++ b/backend/src/db/schemas/project-slack-configs.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectSlackConfigsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + slackIntegrationId: z.string().uuid(), + isAccessRequestNotificationEnabled: z.boolean().default(false), + accessRequestChannels: z.string().default(""), + isSecretRequestNotificationEnabled: z.boolean().default(false), + secretRequestChannels: z.string().default(""), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectSlackConfigs = z.infer; +export type TProjectSlackConfigsInsert = Omit, TImmutableDBKeys>; +export type TProjectSlackConfigsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/slack-integrations.ts b/backend/src/db/schemas/slack-integrations.ts index 2f938d94b..3cd33bed2 100644 --- a/backend/src/db/schemas/slack-integrations.ts +++ b/backend/src/db/schemas/slack-integrations.ts @@ -11,7 +11,9 @@ import { TImmutableDBKeys } from "./models"; export const SlackIntegrationsSchema = z.object({ id: z.string().uuid(), - projectId: z.string(), + slug: z.string(), + orgId: z.string().uuid(), + description: z.string().nullable().optional(), teamId: z.string(), teamName: z.string(), slackUserId: z.string(), @@ -19,10 +21,6 @@ export const SlackIntegrationsSchema = z.object({ encryptedBotAccessToken: zodBuffer, slackBotId: z.string(), slackBotUserId: z.string(), - isAccessRequestNotificationEnabled: z.boolean().default(false), - accessRequestChannels: z.string().default(""), - isSecretRequestNotificationEnabled: z.boolean().default(false), - secretRequestChannels: z.string().default(""), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 3fa9260aa..29f5e99bb 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1453,18 +1453,17 @@ interface GetCertificateTemplateEstConfig { interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { - projectId?: string; - }; // no metadata + slug: string; + description?: string; + }; } interface UpdateSlackIntegration { type: EventType.UPDATE_SLACK_INTEGRATION; metadata: { id: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; + slug: string; + description?: string; }; } diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 05c6ae7e5..734352b58 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -61,7 +61,13 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); await server.register(registerSecretFolderRouter, { prefix: "/folders" }); - await server.register(registerSlackRouter, { prefix: "/slack" }); + + await server.register( + async (workflowIntegrationRouter) => { + await workflowIntegrationRouter.register(registerSlackRouter, { prefix: "/slack" }); + }, + { prefix: "/workflow-integrations" } + ); await server.register( async (projectRouter) => { diff --git a/backend/src/server/routes/v1/slack-router.ts b/backend/src/server/routes/v1/slack-router.ts index a4787987c..88ae200d9 100644 --- a/backend/src/server/routes/v1/slack-router.ts +++ b/backend/src/server/routes/v1/slack-router.ts @@ -6,7 +6,6 @@ import { getConfig } from "@app/lib/config/env"; 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(); @@ -24,7 +23,8 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } ], querystring: z.object({ - projectId: z.string() + slug: z.string(), + description: z.string().optional() }), response: { 200: z.string() @@ -37,15 +37,18 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.query.projectId + ...req.query }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: req.query.projectId, + orgId: req.permission.orgId, event: { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION, - metadata: {} + metadata: { + slug: req.query.slug, + description: req.query.description + } } }); @@ -65,104 +68,25 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { bearerAuth: [] } ], - querystring: z.object({ - projectId: z.string() - }), response: { 200: SlackIntegrationsSchema.pick({ id: true, - teamName: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true - }) + slug: true, + description: true, + teamName: true + }).array() } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const slackIntegration = await server.services.slack.getSlackIntegrationByProjectId({ + const slackIntegrations = await server.services.slack.getSlackIntegrationsByOrg({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - projectId: req.query.projectId + actorOrgId: req.permission.orgId }); - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.query.projectId, - event: { - type: EventType.GET_SLACK_INTEGRATION, - metadata: { - id: slackIntegration?.id - } - } - }); - - return slackIntegration; - } - }); - - 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) => { - const updatedSlackIntegration = await 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 - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: updatedSlackIntegration.projectId, - event: { - type: EventType.UPDATE_SLACK_INTEGRATION, - metadata: { - id: updatedSlackIntegration.id, - isAccessRequestNotificationEnabled: updatedSlackIntegration.isAccessRequestNotificationEnabled, - accessRequestChannels: updatedSlackIntegration.accessRequestChannels, - isSecretRequestNotificationEnabled: updatedSlackIntegration.isSecretRequestNotificationEnabled, - secretRequestChannels: updatedSlackIntegration.secretRequestChannels - } - } - }); - - return updatedSlackIntegration; + return slackIntegrations; } }); @@ -184,11 +108,9 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { response: { 200: SlackIntegrationsSchema.pick({ id: true, - teamName: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + slug: true, + description: true, + teamName: true }) } }, @@ -204,7 +126,7 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: deletedSlackIntegration.projectId, + orgId: deletedSlackIntegration.orgId, event: { type: EventType.DELETE_SLACK_INTEGRATION, metadata: { @@ -217,6 +139,111 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:slackIntegrationId", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + slug: true, + description: true, + teamName: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackIntegration = await server.services.slack.getSlackIntegrationById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.slackIntegrationId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: slackIntegration.orgId, + event: { + type: EventType.GET_SLACK_INTEGRATION, + metadata: { + id: slackIntegration.id + } + } + }); + + return slackIntegration; + } + }); + + server.route({ + method: "PATCH", + url: "/:slackIntegrationId", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + slackIntegrationId: z.string() + }), + body: z.object({ + slug: z.string().optional(), + description: z.string().optional() + }), + response: { + 200: SlackIntegrationsSchema.pick({ + id: true, + slug: true, + description: true, + teamName: true + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const slackIntegration = await 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 + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: slackIntegration.orgId, + event: { + type: EventType.UPDATE_SLACK_INTEGRATION, + metadata: { + id: slackIntegration.id, + slug: slackIntegration.slug, + description: slackIntegration.description as string + } + } + }); + + return slackIntegration; + } + }); + server.route({ method: "GET", url: "/oauth_redirect", @@ -232,10 +259,10 @@ export const registerSlackRouter = async (server: FastifyZodProvider) => { }, successAsync: async (installation) => { const metadata = JSON.parse(installation.metadata || "") as { - projectId: string; + orgId: string; }; - return res.redirect(`${appCfg.SITE_URL}/project/${metadata.projectId}/settings`); + return res.redirect(`${appCfg.SITE_URL}/org/${metadata.orgId}/settings`); } }); } diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index c32c1e466..b6a60492d 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -1,43 +1,40 @@ import { ForbiddenError } from "@casl/ability"; import { InstallProvider } from "@slack/oauth"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; 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, TDeleteSlackIntegrationDTO, TGetSlackInstallUrlDTO, - TGetSlackIntegrationByProjectIdDTO, + TGetSlackIntegrationByIdDTO, + TGetSlackIntegrationByOrgDTO, TUpdateSlackIntegrationDTO } from "./slack-types"; type TSlackServiceFactoryDep = { - slackIntegrationDAL: Pick< - TSlackIntegrationDALFactory, - "create" | "findOne" | "findById" | "updateById" | "deleteById" | "transaction" - >; - permissionService: Pick; - projectDAL: Pick; + slackIntegrationDAL: Pick; + permissionService: Pick; kmsService: Pick; }; export type TSlackServiceFactory = ReturnType; export const slackServiceFactory = ({ - projectDAL, permissionService, slackIntegrationDAL, kmsService }: TSlackServiceFactoryDep) => { const completeSlackIntegration = async ({ - projectId, + orgId, + slug, + description, teamId, teamName, slackUserId, @@ -46,59 +43,26 @@ export const slackServiceFactory = ({ slackBotId, slackBotUserId }: TCompleteSlackIntegrationDTO) => { - const project = await projectDAL.findById(projectId); - if (!project) { - throw new NotFoundError({ - message: "Project not found" - }); - } - const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: project.orgId + orgId, + type: KmsDataKey.Organization }); const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({ plainText: Buffer.from(botAccessToken, "utf8") }); - await slackIntegrationDAL.transaction(async (tx) => { - const slackIntegration = await slackIntegrationDAL.findOne( - { - projectId - }, - tx - ); - - if (slackIntegration) { - return slackIntegrationDAL.updateById( - slackIntegration.id, - { - teamId, - teamName, - slackUserId, - slackAppId, - slackBotId, - slackBotUserId, - encryptedBotAccessToken - }, - tx - ); - } - - return slackIntegrationDAL.create( - { - projectId, - teamId, - teamName, - slackUserId, - slackAppId, - slackBotId, - slackBotUserId, - encryptedBotAccessToken - }, - tx - ); + await slackIntegrationDAL.create({ + orgId, + slug, + description, + teamId, + teamName, + slackUserId, + slackAppId, + slackBotId, + slackBotUserId, + encryptedBotAccessToken }); }; @@ -125,11 +89,15 @@ export const slackServiceFactory = ({ } const metadata = JSON.parse(installation.metadata || "") as { - projectId: string; + orgId: string; + slug: string; + description?: string; }; return completeSlackIntegration({ - projectId: metadata.projectId, + orgId: metadata.orgId, + slug: metadata.slug, + description: metadata.description, teamId: installation.team?.id || "", teamName: installation.team?.name || "", slackUserId: installation.user.id, @@ -152,56 +120,87 @@ export const slackServiceFactory = ({ }); }; - const getInstallUrl = async ({ actorId, actor, actorOrgId, actorAuthMethod, projectId }: TGetSlackInstallUrlDTO) => { + const getInstallUrl = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + slug, + description + }: TGetSlackInstallUrlDTO) => { const appCfg = getConfig(); - const { permission } = await permissionService.getProjectPermission( + + const { permission } = await permissionService.getOrgPermission( actor, actorId, - projectId, + actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Settings); - const project = await projectDAL.findById(projectId); - if (!project) { - throw new NotFoundError({ - message: "Project not found" - }); - } + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); const installer = await getSlackInstaller(); const url = await installer.generateInstallUrl({ scopes: ["chat:write.public", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"], metadata: JSON.stringify({ - projectId: project.id + slug, + description, + orgId: actorOrgId }), - redirectUri: `${appCfg.SITE_URL}/api/v1/slack/oauth_redirect` + redirectUri: `${appCfg.SITE_URL}/api/v1/workflow-integrations/slack/oauth_redirect` }); return url; }; - const getSlackIntegrationByProjectId = async ({ + const getSlackIntegrationsByOrg = async ({ actorId, actor, actorOrgId, - actorAuthMethod, - projectId - }: TGetSlackIntegrationByProjectIdDTO) => { - const { permission } = await permissionService.getProjectPermission( + actorAuthMethod + }: TGetSlackIntegrationByOrgDTO) => { + const { permission } = await permissionService.getOrgPermission( actor, actorId, - projectId, + actorOrgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); - const slackIntegration = await slackIntegrationDAL.findOne({ - projectId + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const slackIntegrations = await slackIntegrationDAL.find({ + orgId: actorOrgId }); + return slackIntegrations; + }; + + const getSlackIntegrationById = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetSlackIntegrationByIdDTO) => { + 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); + return slackIntegration; }; @@ -211,10 +210,8 @@ export const slackServiceFactory = ({ actorOrgId, actorAuthMethod, id, - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels + slug, + description }: TUpdateSlackIntegrationDTO) => { const slackIntegration = await slackIntegrationDAL.findById(id); if (!slackIntegration) { @@ -222,21 +219,20 @@ export const slackServiceFactory = ({ message: "Slack integration not found" }); } - const { permission } = await permissionService.getProjectPermission( + + const { permission } = await permissionService.getOrgPermission( actor, actorId, - slackIntegration.projectId, + slackIntegration.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); return slackIntegrationDAL.updateById(slackIntegration.id, { - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels + slug, + description }); }; @@ -253,22 +249,24 @@ export const slackServiceFactory = ({ message: "Slack integration not found" }); } - const { permission } = await permissionService.getProjectPermission( + + const { permission } = await permissionService.getOrgPermission( actor, actorId, - slackIntegration.projectId, + slackIntegration.orgId, actorAuthMethod, actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); return slackIntegrationDAL.deleteById(id); }; return { getInstallUrl, - getSlackIntegrationByProjectId, + getSlackIntegrationsByOrg, + getSlackIntegrationById, completeSlackIntegration, getSlackInstaller, updateSlackIntegration, diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index 55d836662..ab9035b09 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -1,23 +1,27 @@ -import { TProjectPermission } from "@app/lib/types"; +import { TOrgPermission } from "@app/lib/types"; -export type TGetSlackInstallUrlDTO = TProjectPermission; +export type TGetSlackInstallUrlDTO = { + slug: string; + description?: string; +} & Omit; -export type TGetSlackIntegrationByProjectIdDTO = TProjectPermission; +export type TGetSlackIntegrationByOrgDTO = Omit; -export type TUpdateSlackIntegrationDTO = { - id: string; - isAccessRequestNotificationEnabled?: boolean; - accessRequestChannels?: string; - isSecretRequestNotificationEnabled?: boolean; - secretRequestChannels?: string; -} & Omit; +export type TGetSlackIntegrationByIdDTO = { id: string } & Omit; + +export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit< + TOrgPermission, + "orgId" +>; export type TDeleteSlackIntegrationDTO = { id: string; -} & Omit; +} & Omit; export type TCompleteSlackIntegrationDTO = { - projectId: string; + orgId: string; + slug: string; + description?: string; teamId: string; teamName: string; slackUserId: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 57644b616..9607829f5 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -45,4 +45,5 @@ export * from "./tags"; export * from "./trustedIps"; export * from "./users"; export * from "./webhooks"; +export * from "./workflowIntegrations"; export * from "./workspace"; diff --git a/frontend/src/hooks/api/slack/index.ts b/frontend/src/hooks/api/slack/index.ts index 73df6a19c..e98ddce71 100644 --- a/frontend/src/hooks/api/slack/index.ts +++ b/frontend/src/hooks/api/slack/index.ts @@ -1,2 +1,2 @@ export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation"; -export { fetchSlackInstallUrl, useGetSlackIntegrationByProject } from "./queries"; +export { useGetSlackIntegrationByProject } from "./queries"; diff --git a/frontend/src/hooks/api/slack/queries.tsx b/frontend/src/hooks/api/slack/queries.tsx index 3b82793f9..c59cf64f6 100644 --- a/frontend/src/hooks/api/slack/queries.tsx +++ b/frontend/src/hooks/api/slack/queries.tsx @@ -11,16 +11,6 @@ export const slackKeys = { ] }; -export const fetchSlackInstallUrl = async (workspaceId?: string) => { - const { data } = await apiRequest.get("/api/v1/slack/install", { - params: { - projectId: workspaceId - } - }); - - return data; -}; - export const fetchSlackIntegrationByProject = async (workspaceId?: string) => { const { data } = await apiRequest.get("/api/v1/slack", { params: { diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts new file mode 100644 index 000000000..8d0529b2e --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -0,0 +1,6 @@ +export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation"; +export { + fetchSlackInstallUrl, + useGetSlackIntegrationById, + useGetSlackIntegrations +} from "./queries"; diff --git a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx new file mode 100644 index 000000000..0979e423c --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workflowIntegrationKeys } from "./queries"; +import { TDeleteSlackIntegrationDTO, TUpdateSlackIntegrationDTO } from "./types"; + +export const useUpdateSlackIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSlackIntegrationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch(`/api/v1/workflow-integrations/slack/${dto.id}`, dto); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + } + }); +}; + +export const useDeleteSlackIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSlackIntegrationDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete(`/api/v1/workflow-integrations/slack/${dto.id}`); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegration(id)); + queryClient.invalidateQueries(workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/workflowIntegrations/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx new file mode 100644 index 000000000..30e028828 --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { SlackIntegration } from "./types"; + +export const workflowIntegrationKeys = { + getSlackWorkflowIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], + getSlackWorkflowIntegration: (id?: string) => [{ id }, "slack-workflow-integration"] +}; + +export const fetchSlackInstallUrl = async ({ + slug, + description +}: { + slug: string; + description?: string; +}) => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack/install", { + params: { + slug, + description + } + }); + + return data; +}; + +export const fetchSlackIntegrations = async () => { + const { data } = await apiRequest.get("/api/v1/workflow-integrations/slack"); + + return data; +}; + +export const fetchSlackIntegrationById = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/slack/${id}` + ); + + return data; +}; + +export const useGetSlackIntegrations = (orgId?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getSlackWorkflowIntegrations(orgId), + queryFn: () => fetchSlackIntegrations(), + enabled: Boolean(orgId) + }); + +export const useGetSlackIntegrationById = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getSlackWorkflowIntegration(id), + queryFn: () => fetchSlackIntegrationById(id), + enabled: Boolean(id) + }); diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts new file mode 100644 index 000000000..5fb1868e2 --- /dev/null +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -0,0 +1,22 @@ +export enum WorkflowIntegrationPlatform { + SLACK = "slack" +} + +export type SlackIntegration = { + id: string; + slug: string; + description: string; + teamName: string; +}; + +export type TUpdateSlackIntegrationDTO = { + id: string; + orgId: string; + slug?: string; + description?: string; +}; + +export type TDeleteSlackIntegrationDTO = { + id: string; + orgId: string; +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 3894bb28f..029018914 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -5,11 +5,13 @@ import { AuditLogStreamsTab } from "../AuditLogStreamTab"; import { OrgAuthTab } from "../OrgAuthTab"; import { OrgEncryptionTab } from "../OrgEncryptionTab"; import { OrgGeneralTab } from "../OrgGeneralTab"; +import { OrgWorkflowIntegrationTab } from "../OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab"; const tabs = [ { name: "General", key: "tab-org-general" }, { name: "Security", key: "tab-org-security" }, { name: "Encryption", key: "tab-org-encryption" }, + { name: "Workflow Integrations", key: "workflow-integrations" }, { name: "Audit Log Streams", key: "tag-audit-log-streams" } ]; export const OrgTabGroup = () => { @@ -41,6 +43,9 @@ export const OrgTabGroup = () => { + + + diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx new file mode 100644 index 000000000..63182dda1 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { faSlack } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; +}; + +enum WizardSteps { + SelectPlatform = "select-platform", + PlatformInputs = "platform-inputs" +} + +const PLATFORM_LIST = [ + { + icon: faSlack, + platform: WorkflowIntegrationPlatform.SLACK, + title: "Slack" + } +]; + +export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => { + const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform); + const [selectedPlatform, setSelectedPlatform] = useState(null); + + const handleFormReset = (state: boolean = false) => { + onToggle(state); + setWizardStep(WizardSteps.SelectPlatform); + setSelectedPlatform(null); + }; + + return ( + handleFormReset(state)}> + + + {wizardStep === WizardSteps.SelectPlatform && ( + +
Select a platform
+
+ {PLATFORM_LIST.map(({ icon, platform, title }) => ( +
{ + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + } + }} + > + +
{title}
+
+ ))} +
+
+ )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.SLACK && ( + + onToggle(false)} /> + + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx new file mode 100644 index 000000000..fce1edb92 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx @@ -0,0 +1,23 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen: boolean; + id: string; + workflowPlatform: WorkflowIntegrationPlatform; + onOpenChange: (state: boolean) => void; +}; + +export const IntegrationFormDetails = ({ isOpen, id, onOpenChange, workflowPlatform }: Props) => { + return ( + + + {workflowPlatform === WorkflowIntegrationPlatform.SLACK && ( + onOpenChange(false)} /> + )} + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx new file mode 100644 index 000000000..9c2018168 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx @@ -0,0 +1,188 @@ +import { faSlack } from "@fortawesome/free-brands-svg-icons"; +import { faEllipsis, faGear, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { useDeleteSlackIntegration, useGetSlackIntegrations } from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { AddWorkflowIntegrationForm } from "./AddWorkflowIntegrationForm"; +import { IntegrationFormDetails } from "./IntegrationFormDetails"; + +export const OrgWorkflowIntegrationTab = withPermission( + () => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "addWorkflowIntegration", + "integrationDetails", + "removeIntegration" + ] as const); + + const { currentOrg } = useOrganization(); + const { data: slackIntegrations, isLoading: isSlackIntegrationsLoading } = + useGetSlackIntegrations(currentOrg?.id); + const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration(); + + const handleRemoveIntegration = async () => { + if (!currentOrg) { + return; + } + + const { platform, id } = popUp.removeIntegration.data; + if (platform === WorkflowIntegrationPlatform.SLACK) { + await deleteSlackIntegration({ + id, + orgId: currentOrg?.id + }); + } + + handlePopUpClose("removeIntegration"); + createNotification({ + text: "Successfully deleted integration", + type: "success" + }); + }; + + const isIntegrationsLoading = isSlackIntegrationsLoading; + + return ( +
+
+

Workflow Integrations

+ + {(isAllowed) => ( + + )} + +
+

+ Connect Infisical to other platforms for notification and workflow integrations. +

+ + + + + + + + + + {isIntegrationsLoading && ( + + )} + {!isIntegrationsLoading && slackIntegrations && slackIntegrations.length === 0 && ( + + + + )} + {slackIntegrations?.map((slackIntegration) => ( + + + + + + ))} + +
ProviderAlias
+ +
+ +
SLACK
+
{slackIntegration.slug} + + +
+ +
+
+ + { + e.stopPropagation(); + + handlePopUpOpen("integrationDetails", { + id: slackIntegration.id, + platform: WorkflowIntegrationPlatform.SLACK + }); + }} + > + More details + + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("removeIntegration", { + id: slackIntegration.id, + slug: slackIntegration.slug, + platform: WorkflowIntegrationPlatform.SLACK + }); + }} + > + Delete + + )} + + +
+
+
+ handlePopUpToggle("addWorkflowIntegration", state)} + /> + handlePopUpToggle("integrationDetails", state)} + /> + handlePopUpToggle("removeIntegration", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleRemoveIntegration} + /> +
+ ); + }, + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Settings } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx new file mode 100644 index 000000000..c72d8aae3 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgWorkflowIntegrationTab/SlackIntegrationForm.tsx @@ -0,0 +1,121 @@ +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 { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { + fetchSlackInstallUrl, + useGetSlackIntegrationById, + useUpdateSlackIntegration +} from "@app/hooks/api"; + +type Props = { + id?: string; + onClose: () => void; +}; + +const slackFormSchema = z.object({ + slug: z.string(), + description: z.string().optional() +}); + +type TSlackFormData = z.infer; + +export const SlackIntegrationForm = ({ id, onClose }: Props) => { + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(slackFormSchema) + }); + + const router = useRouter(); + const [isConnectLoading, setIsConnectLoading] = useToggle(false); + const { currentOrg } = useOrganization(); + const { data: slackIntegration } = useGetSlackIntegrationById(id); + const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration(); + + useEffect(() => { + if (slackIntegration) { + setValue("slug", slackIntegration.slug); + setValue("description", slackIntegration.description ?? ""); + } + }, [slackIntegration]); + + const triggerSlackInstall = async (slug: string, description?: string) => { + setIsConnectLoading.on(); + const slackInstallUrl = await fetchSlackInstallUrl({ + slug, + description + }); + if (slackInstallUrl) { + router.push(slackInstallUrl); + } + }; + + const handleSlackFormSubmit = async ({ slug, description }: TSlackFormData) => { + if (id && slackIntegration) { + if (!currentOrg) { + return; + } + + await updateSlackIntegration({ + id, + orgId: currentOrg?.id, + slug, + description + }); + + onClose(); + createNotification({ + text: "Successfully updated Slack integration", + type: "success" + }); + } else { + await triggerSlackInstall(slug, description); + } + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + {slackIntegration && ( + + + + )} +
+ + +
+ + ); +};