feat: finished org-level integration management flow

This commit is contained in:
Sheen Capadngan
2024-09-03 22:08:31 +08:00
parent 9120367562
commit a2b555dd81
23 changed files with 865 additions and 234 deletions

View File

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

View File

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

View File

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

View File

@@ -116,6 +116,7 @@ export enum TableName {
// @depreciated
KmsKeyVersion = "kms_key_versions",
SlackIntegrations = "slack_integrations",
ProjectSlackConfigs = "project_slack_configs",
AdminSlackConfig = "admin_slack_configs"
}

View File

@@ -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<typeof ProjectSlackConfigsSchema>;
export type TProjectSlackConfigsInsert = Omit<z.input<typeof ProjectSlackConfigsSchema>, TImmutableDBKeys>;
export type TProjectSlackConfigsUpdate = Partial<Omit<z.input<typeof ProjectSlackConfigsSchema>, TImmutableDBKeys>>;

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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<TPermissionServiceFactory, "getProjectPermission">;
projectDAL: Pick<TProjectDALFactory, "findById">;
slackIntegrationDAL: Pick<TSlackIntegrationDALFactory, "find" | "findById" | "deleteById" | "updateById" | "create">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
export type TSlackServiceFactory = ReturnType<typeof slackServiceFactory>;
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,

View File

@@ -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<TOrgPermission, "orgId">;
export type TGetSlackIntegrationByProjectIdDTO = TProjectPermission;
export type TGetSlackIntegrationByOrgDTO = Omit<TOrgPermission, "orgId">;
export type TUpdateSlackIntegrationDTO = {
id: string;
isAccessRequestNotificationEnabled?: boolean;
accessRequestChannels?: string;
isSecretRequestNotificationEnabled?: boolean;
secretRequestChannels?: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetSlackIntegrationByIdDTO = { id: string } & Omit<TOrgPermission, "orgId">;
export type TUpdateSlackIntegrationDTO = { id: string; slug?: string; description?: string } & Omit<
TOrgPermission,
"orgId"
>;
export type TDeleteSlackIntegrationDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
} & Omit<TOrgPermission, "orgId">;
export type TCompleteSlackIntegrationDTO = {
projectId: string;
orgId: string;
slug: string;
description?: string;
teamId: string;
teamName: string;
slackUserId: string;

View File

@@ -45,4 +45,5 @@ export * from "./tags";
export * from "./trustedIps";
export * from "./users";
export * from "./webhooks";
export * from "./workflowIntegrations";
export * from "./workspace";

View File

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

View File

@@ -11,16 +11,6 @@ export const slackKeys = {
]
};
export const fetchSlackInstallUrl = async (workspaceId?: string) => {
const { data } = await apiRequest.get<string>("/api/v1/slack/install", {
params: {
projectId: workspaceId
}
});
return data;
};
export const fetchSlackIntegrationByProject = async (workspaceId?: string) => {
const { data } = await apiRequest.get<ProjectSlackIntegration>("/api/v1/slack", {
params: {

View File

@@ -0,0 +1,6 @@
export { useDeleteSlackIntegration, useUpdateSlackIntegration } from "./mutation";
export {
fetchSlackInstallUrl,
useGetSlackIntegrationById,
useGetSlackIntegrations
} from "./queries";

View File

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

View File

@@ -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<string>("/api/v1/workflow-integrations/slack/install", {
params: {
slug,
description
}
});
return data;
};
export const fetchSlackIntegrations = async () => {
const { data } = await apiRequest.get<SlackIntegration[]>("/api/v1/workflow-integrations/slack");
return data;
};
export const fetchSlackIntegrationById = async (id?: string) => {
const { data } = await apiRequest.get<SlackIntegration>(
`/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)
});

View File

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

View File

@@ -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 = () => {
<Tab.Panel>
<OrgEncryptionTab />
</Tab.Panel>
<Tab.Panel>
<OrgWorkflowIntegrationTab />
</Tab.Panel>
<Tab.Panel>
<AuditLogStreamsTab />
</Tab.Panel>

View File

@@ -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<string | null>(null);
const handleFormReset = (state: boolean = false) => {
onToggle(state);
setWizardStep(WizardSteps.SelectPlatform);
setSelectedPlatform(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
<ModalContent
title="Add a workflow integration"
subTitle="Configure a workflow integration"
className="my-4"
>
<AnimatePresence exitBeforeEnter>
{wizardStep === WizardSteps.SelectPlatform && (
<motion.div
key="select-type-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<div className="mb-4 text-mineshaft-300">Select a platform</div>
<div className="flex items-center space-x-4">
{PLATFORM_LIST.map(({ icon, platform, title }) => (
<div
key={platform}
className="flex h-28 w-32 cursor-pointer flex-col items-center space-y-4 rounded border border-mineshaft-500 bg-bunker-600 p-6 transition-all hover:border-primary/70 hover:bg-primary/10 hover:text-white"
role="button"
tabIndex={0}
onClick={() => {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}
}}
>
<FontAwesomeIcon icon={icon} size="lg" />
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
))}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.PlatformInputs &&
selectedPlatform === WorkflowIntegrationPlatform.SLACK && (
<motion.div
key="slack-platform"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<SlackIntegrationForm onClose={() => onToggle(false)} />
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};

View File

@@ -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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Workflow integration details">
{workflowPlatform === WorkflowIntegrationPlatform.SLACK && (
<SlackIntegrationForm id={id} onClose={() => onOpenChange(false)} />
)}
</ModalContent>
</Modal>
);
};

View File

@@ -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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Workflow Integrations</p>
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<Button
onClick={() => {
handlePopUpOpen("addWorkflowIntegration");
}}
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add
</Button>
)}
</OrgPermissionCan>
</div>
<p className="mb-4 text-gray-400">
Connect Infisical to other platforms for notification and workflow integrations.
</p>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>Provider</Td>
<Td>Alias</Td>
</Tr>
</THead>
<TBody>
{isIntegrationsLoading && (
<TableSkeleton columns={2} innerKey="integrations-loading" />
)}
{!isIntegrationsLoading && slackIntegrations && slackIntegrations.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No workflow integrations found" icon={faGear} />
</Td>
</Tr>
)}
{slackIntegrations?.map((slackIntegration) => (
<Tr key={slackIntegration.id}>
<Td className="flex max-w-xs items-center overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
<FontAwesomeIcon icon={faSlack} />
<div className="ml-2">SLACK</div>
</Td>
<Td>{slackIntegration.slug}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="flex justify-end hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("integrationDetails", {
id: slackIntegration.id,
platform: WorkflowIntegrationPlatform.SLACK
});
}}
>
More details
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
an={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeIntegration", {
id: slackIntegration.id,
slug: slackIntegration.slug,
platform: WorkflowIntegrationPlatform.SLACK
});
}}
>
Delete
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
<AddWorkflowIntegrationForm
isOpen={popUp.addWorkflowIntegration.isOpen}
onToggle={(state) => handlePopUpToggle("addWorkflowIntegration", state)}
/>
<IntegrationFormDetails
isOpen={popUp.integrationDetails?.isOpen}
workflowPlatform={popUp.integrationDetails?.data?.platform}
id={popUp.integrationDetails?.data?.id}
onOpenChange={(state) => handlePopUpToggle("integrationDetails", state)}
/>
<DeleteActionModal
isOpen={popUp.removeIntegration.isOpen}
title={`Are you sure want to remove ${popUp?.removeIntegration?.data?.slug}?`}
onChange={(isOpen) => handlePopUpToggle("removeIntegration", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleRemoveIntegration}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Settings }
);

View File

@@ -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<typeof slackFormSchema>;
export const SlackIntegrationForm = ({ id, onClose }: Props) => {
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TSlackFormData>({
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 (
<form onSubmit={handleSubmit(handleSlackFormSubmit)} autoComplete="off">
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl label="Description" errorText={error?.message} isError={Boolean(error)}>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
{slackIntegration && (
<FormControl label="Slack workspace">
<Input value={slackIntegration?.teamName} isReadOnly className="bg-white/[0.07]" />
</FormControl>
)}
<div className="mt-6 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting || isConnectLoading} isDisabled={!isDirty}>
{slackIntegration ? "Save" : "Connect"}
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};