Merge pull request #4756 from Infisical/feature/slack-secret-sync-error-notification

Feature: slack secret sync error notification
This commit is contained in:
Victor Hugo dos Santos
2025-11-07 13:57:31 -03:00
committed by GitHub
23 changed files with 700 additions and 289 deletions

View File

@@ -0,0 +1,31 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled"))) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.boolean("isSecretSyncErrorNotificationEnabled").notNullable().defaultTo(false);
});
}
if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels"))) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.text("secretSyncErrorChannels").notNullable().defaultTo("");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled")) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.dropColumn("isSecretSyncErrorNotificationEnabled");
});
}
if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels")) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.dropColumn("secretSyncErrorChannels");
});
}
}

View File

@@ -16,7 +16,9 @@ export const ProjectSlackConfigsSchema = z.object({
isSecretRequestNotificationEnabled: z.boolean().default(false),
secretRequestChannels: z.string().default(""),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
isSecretSyncErrorNotificationEnabled: z.boolean().default(false),
secretSyncErrorChannels: z.string().default("")
});
export type TProjectSlackConfigs = z.infer<typeof ProjectSlackConfigsSchema>;

View File

@@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({
);
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const approvalPath = `/projects/secret-management/${project.id}/approval`;
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
@@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({
type: TriggerFeature.ACCESS_REQUEST,
payload: {
projectName: project.name,
projectPath,
requesterFullName,
isTemporary,
requesterEmail: requestedByUser.email as string,
@@ -397,7 +399,8 @@ export const accessApprovalRequestServiceFactory = ({
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalPath = `/projects/secret-management/${project.id}/approval`;
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
@@ -415,7 +418,8 @@ export const accessApprovalRequestServiceFactory = ({
approvalUrl,
editNote,
editorEmail: editedByUser.email as string,
editorFullName
editorFullName,
projectPath
}
},
projectId: project.id

View File

@@ -37,7 +37,7 @@ export const sendApprovalEmailsFn = async ({
type: NotificationType.SECRET_CHANGE_REQUEST,
title: "Secret Change Request",
body: `You have a new secret change request pending your review for the project **${project.name}** in the organization **${project.organization.name}**.`,
link: `/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
link: `/projects/secret-management/${project.id}/approval`
}))
);
@@ -51,7 +51,7 @@ export const sendApprovalEmailsFn = async ({
firstName: reviewerUser.firstName,
projectName: project.name,
organizationName: project.organization.name,
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval}`
},
template: SmtpTemplates.SecretApprovalRequestNeedsReview
});

View File

@@ -1416,6 +1416,11 @@ export const secretApprovalRequestServiceFactory = ({
const env = await projectEnvDAL.findOne({ id: policy.envId });
const user = await userDAL.findById(actorId);
const projectPath = `/projects/secret-management/${projectId}`;
const approvalPath = `${projectPath}/approval`;
const cfg = getConfig();
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
input: {
projectId,
@@ -1427,7 +1432,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath,
projectId,
requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))]
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))],
approvalUrl
}
}
},
@@ -1786,6 +1792,11 @@ export const secretApprovalRequestServiceFactory = ({
const user = await userDAL.findById(actorId);
const env = await projectEnvDAL.findOne({ id: policy.envId });
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const cfg = getConfig();
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
input: {
projectId,
@@ -1797,7 +1808,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath,
projectId,
requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))]
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))],
approvalUrl
}
}
},

View File

@@ -0,0 +1,92 @@
import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns";
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import {
TProjectMicrosoftTeamsConfigDALFactory,
TProjectMicrosoftTeamsConfigWithIntegrations
} from "@app/services/microsoft-teams/project-microsoft-teams-config-dal";
import { logger } from "../../logger";
import { TNotification, TriggerFeature } from "../types";
const handleMicrosoftTeamsNotification = async ({
microsoftTeamsConfig,
notification,
orgId,
microsoftTeamsService
}: {
microsoftTeamsConfig: TProjectMicrosoftTeamsConfigWithIntegrations;
notification: TNotification;
orgId: string;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
}): Promise<void> => {
let targetChannels: unknown;
let isEnabled = false;
switch (notification.type) {
case TriggerFeature.ACCESS_REQUEST:
case TriggerFeature.ACCESS_REQUEST_UPDATED:
targetChannels = microsoftTeamsConfig.accessRequestChannels;
isEnabled = microsoftTeamsConfig.isAccessRequestNotificationEnabled;
break;
case TriggerFeature.SECRET_APPROVAL:
targetChannels = microsoftTeamsConfig.secretRequestChannels;
isEnabled = microsoftTeamsConfig.isSecretRequestNotificationEnabled;
break;
default:
return;
}
if (isEnabled && targetChannels) {
const { success, data, error: validationError } = validateMicrosoftTeamsChannelsSchema.safeParse(targetChannels);
if (!success) {
logger.error(validationError, "Invalid Microsoft Teams channel configuration");
return;
}
if (data) {
await microsoftTeamsService
.sendNotification({
notification,
target: data,
tenantId: microsoftTeamsConfig.tenantId,
microsoftTeamsIntegrationId: microsoftTeamsConfig.id,
orgId
})
.catch((error) => {
logger.error(
error,
`Error sending Microsoft Teams notification. Notification type: ${notification.type}, Tenant ID: ${microsoftTeamsConfig.tenantId}, Project ID: ${microsoftTeamsConfig.projectId}`
);
});
}
}
};
export const triggerMicrosoftTeamsNotification = async ({
projectId,
notification,
orgId,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
}: {
projectId: string;
notification: TNotification;
orgId: string;
projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
}): Promise<void> => {
try {
const config = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId);
if (config) {
await handleMicrosoftTeamsNotification({
microsoftTeamsConfig: config,
notification,
orgId,
microsoftTeamsService
});
}
} catch (error) {
logger.error(error, `Error handling Microsoft Teams notification. Project ID: ${projectId}`);
}
};

View File

@@ -0,0 +1,80 @@
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import {
TProjectSlackConfigDALFactory,
TProjectSlackConfigWithIntegrations
} from "@app/services/slack/project-slack-config-dal";
import { sendSlackNotification } from "@app/services/slack/slack-fns";
import { logger } from "../../logger";
import { TNotification, TriggerFeature } from "../types";
const handleSlackNotification = async ({
slackConfig,
notification,
orgId,
kmsService
}: {
slackConfig: TProjectSlackConfigWithIntegrations;
notification: TNotification;
orgId: string;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}): Promise<void> => {
let targetChannelIds: string[] = [];
let isEnabled = false;
switch (notification.type) {
case TriggerFeature.ACCESS_REQUEST:
case TriggerFeature.ACCESS_REQUEST_UPDATED:
targetChannelIds = slackConfig.accessRequestChannels?.split(", ").filter(Boolean) || [];
isEnabled = slackConfig.isAccessRequestNotificationEnabled;
break;
case TriggerFeature.SECRET_APPROVAL:
targetChannelIds = slackConfig.secretRequestChannels?.split(", ").filter(Boolean) || [];
isEnabled = slackConfig.isSecretRequestNotificationEnabled;
break;
case TriggerFeature.SECRET_SYNC_ERROR:
targetChannelIds = slackConfig.secretSyncErrorChannels?.split(", ").filter(Boolean) || [];
isEnabled = slackConfig.isSecretSyncErrorNotificationEnabled;
break;
default:
return;
}
if (targetChannelIds.length && isEnabled) {
await sendSlackNotification({
orgId,
notification,
kmsService,
targetChannelIds,
slackIntegration: slackConfig
}).catch((error) => {
logger.error(
error,
`Error sending Slack notification. Notification type: ${notification.type}, Target channel IDs: ${targetChannelIds.join(", ")}, Project ID: ${slackConfig.projectId}`
);
});
}
};
export const triggerSlackNotification = async ({
projectId,
notification,
orgId,
projectSlackConfigDAL,
kmsService
}: {
projectId: string;
notification: TNotification;
orgId: string;
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}): Promise<void> => {
try {
const config = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
if (config) {
await handleSlackNotification({ slackConfig: config, notification, orgId, kmsService });
}
} catch (error) {
logger.error(error, `Error handling Slack notification. Project ID: ${projectId}`);
}
};

View File

@@ -1,8 +1,7 @@
import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns";
import { sendSlackNotification } from "@app/services/slack/slack-fns";
import { logger } from "../logger";
import { TriggerFeature, TTriggerWorkflowNotificationDTO } from "./types";
import { triggerMicrosoftTeamsNotification } from "./notification-handlers/microsoft-teams";
import { triggerSlackNotification } from "./notification-handlers/slack";
import { TTriggerWorkflowNotificationDTO } from "./types";
export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => {
try {
@@ -16,88 +15,25 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
return;
}
const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId);
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
const handlerPromises = [
triggerSlackNotification({
projectId,
notification,
orgId: project.orgId,
projectSlackConfigDAL,
kmsService
}),
if (slackConfig) {
if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
await sendSlackNotification({
orgId: project.orgId,
notification,
kmsService,
targetChannelIds,
slackIntegration: slackConfig
}).catch((error) => {
logger.error(error, "Error sending Slack notification");
});
}
} else if (notification.type === TriggerFeature.SECRET_APPROVAL) {
const targetChannelIds = slackConfig.secretRequestChannels?.split(", ") || [];
if (targetChannelIds.length && slackConfig.isSecretRequestNotificationEnabled) {
await sendSlackNotification({
orgId: project.orgId,
notification,
kmsService,
targetChannelIds,
slackIntegration: slackConfig
}).catch((error) => {
logger.error(error, "Error sending Slack notification");
});
}
}
}
triggerMicrosoftTeamsNotification({
projectId,
notification,
orgId: project.orgId,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
})
];
if (microsoftTeamsConfig) {
if (
notification.type === TriggerFeature.ACCESS_REQUEST ||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
) {
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
microsoftTeamsConfig.accessRequestChannels
);
if (success && data) {
await microsoftTeamsService
.sendNotification({
notification,
target: data,
tenantId: microsoftTeamsConfig.tenantId,
microsoftTeamsIntegrationId: microsoftTeamsConfig.id,
orgId: project.orgId
})
.catch((error) => {
logger.error(error, "Error sending Microsoft Teams notification");
});
}
}
} else if (notification.type === TriggerFeature.SECRET_APPROVAL) {
if (microsoftTeamsConfig.isSecretRequestNotificationEnabled && microsoftTeamsConfig.secretRequestChannels) {
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
microsoftTeamsConfig.secretRequestChannels
);
if (success && data) {
await microsoftTeamsService
.sendNotification({
notification,
target: data,
tenantId: microsoftTeamsConfig.tenantId,
microsoftTeamsIntegrationId: microsoftTeamsConfig.id,
orgId: project.orgId
})
.catch((error) => {
logger.error(error, "Error sending Microsoft Teams notification");
});
}
}
}
}
await Promise.allSettled(handlerPromises);
} catch (error) {
logger.error(error, "Error triggering workflow integration notification");
}

View File

@@ -7,7 +7,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
export enum TriggerFeature {
SECRET_APPROVAL = "secret-approval",
ACCESS_REQUEST = "access-request",
ACCESS_REQUEST_UPDATED = "access-request-updated"
ACCESS_REQUEST_UPDATED = "access-request-updated",
SECRET_SYNC_ERROR = "secret-sync-error"
}
export type TNotification =
@@ -20,6 +21,7 @@ export type TNotification =
requestId: string;
projectId: string;
secretKeys: string[];
approvalUrl: string;
};
}
| {
@@ -31,6 +33,7 @@ export type TNotification =
secretPath: string;
environment: string;
projectName: string;
projectPath: string;
permissions: string[];
approvalUrl: string;
note?: string;
@@ -50,6 +53,21 @@ export type TNotification =
editNote?: string;
editorFullName?: string;
editorEmail?: string;
projectPath: string;
};
}
| {
type: TriggerFeature.SECRET_SYNC_ERROR;
payload: {
syncName: string;
syncActionLabel: string;
syncDestination: string;
failureMessage: string;
syncUrl: string;
environment: string;
secretPath: string;
projectName: string;
projectPath: string;
};
};

View File

@@ -1251,7 +1251,10 @@ export const registerRoutes = async (
licenseService,
gatewayService,
gatewayV2Service,
notificationService
notificationService,
projectSlackConfigDAL,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
});
const secretQueueService = secretQueueFactory({

View File

@@ -614,8 +614,10 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
integrationId: z.string(),
accessRequestChannels: validateSlackChannelsField,
secretRequestChannels: validateSlackChannelsField,
secretSyncErrorChannels: validateSlackChannelsField,
isAccessRequestNotificationEnabled: z.boolean(),
isSecretRequestNotificationEnabled: z.boolean()
isSecretRequestNotificationEnabled: z.boolean(),
isSecretSyncErrorNotificationEnabled: z.boolean()
}),
z.object({
integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS),
@@ -633,7 +635,9 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
isAccessRequestNotificationEnabled: true,
accessRequestChannels: true,
isSecretRequestNotificationEnabled: true,
secretRequestChannels: true
secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge(
z.object({
integration: z.literal(WorkflowIntegration.SLACK),

View File

@@ -769,7 +769,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
isAccessRequestNotificationEnabled: true,
accessRequestChannels: true,
isSecretRequestNotificationEnabled: true,
secretRequestChannels: true
secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge(
z.object({
integration: z.literal(WorkflowIntegration.SLACK),
@@ -873,7 +875,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
accessRequestChannels: validateSlackChannelsField,
secretRequestChannels: validateSlackChannelsField,
isAccessRequestNotificationEnabled: z.boolean(),
isSecretRequestNotificationEnabled: z.boolean()
isSecretRequestNotificationEnabled: z.boolean(),
secretSyncErrorChannels: validateSlackChannelsField,
isSecretSyncErrorNotificationEnabled: z.boolean()
}),
z.object({
integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS),
@@ -891,7 +895,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
isAccessRequestNotificationEnabled: true,
accessRequestChannels: true,
isSecretRequestNotificationEnabled: true,
secretRequestChannels: true
secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge(
z.object({
integration: z.literal(WorkflowIntegration.SLACK),

View File

@@ -1,16 +1,20 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TableName, TMicrosoftTeamsIntegrations } from "@app/db/schemas";
import { TProjectMicrosoftTeamsConfigs } from "@app/db/schemas/project-microsoft-teams-configs";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TProjectMicrosoftTeamsConfigDALFactory = ReturnType<typeof projectMicrosoftTeamsConfigDALFactory>;
export type TProjectMicrosoftTeamsConfigWithIntegrations = TProjectMicrosoftTeamsConfigs & TMicrosoftTeamsIntegrations;
export const projectMicrosoftTeamsConfigDALFactory = (db: TDbClient) => {
const projectMicrosoftTeamsConfigOrm = ormify(db, TableName.ProjectMicrosoftTeamsConfigs);
const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => {
return (tx || db.replicaNode())(TableName.ProjectMicrosoftTeamsConfigs)
return (tx || db.replicaNode())<TProjectMicrosoftTeamsConfigWithIntegrations>(
TableName.ProjectMicrosoftTeamsConfigs
)
.join(
TableName.MicrosoftTeamsIntegrations,
`${TableName.ProjectMicrosoftTeamsConfigs}.microsoftTeamsIntegrationId`,

View File

@@ -1605,8 +1605,14 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled,
accessRequestChannels,
isSecretRequestNotificationEnabled,
secretRequestChannels
}: TUpdateProjectWorkflowIntegration) => {
secretRequestChannels,
secretSyncErrorChannels,
isSecretSyncErrorNotificationEnabled
}: TUpdateProjectWorkflowIntegration & {
// workaround intersection type while we don't have the microsoft teams integration for failed secret syncs
isSecretSyncErrorNotificationEnabled?: boolean;
secretSyncErrorChannels?: string;
}) => {
const project = await projectDAL.findById(projectId);
if (!project) {
throw new NotFoundError({
@@ -1628,6 +1634,7 @@ export const projectServiceFactory = ({
const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels);
const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels);
const sanitizedSecretSyncErrorChannels = validateSlackChannelsField.parse(secretSyncErrorChannels);
const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId);
@@ -1665,7 +1672,9 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled,
accessRequestChannels: sanitizedAccessRequestChannels,
isSecretRequestNotificationEnabled,
secretRequestChannels: sanitizedSecretRequestChannels
secretRequestChannels: sanitizedSecretRequestChannels,
isSecretSyncErrorNotificationEnabled,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
},
tx
);
@@ -1678,7 +1687,9 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled,
accessRequestChannels: sanitizedAccessRequestChannels,
isSecretRequestNotificationEnabled,
secretRequestChannels: sanitizedSecretRequestChannels
secretRequestChannels: sanitizedSecretRequestChannels,
isSecretSyncErrorNotificationEnabled,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
},
tx
);
@@ -1688,6 +1699,7 @@ export const projectServiceFactory = ({
...updatedWorkflowIntegration,
accessRequestChannels: sanitizedAccessRequestChannels,
secretRequestChannels: sanitizedSecretRequestChannels,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels,
integrationId: slackIntegration.id,
integration: WorkflowIntegration.SLACK
} as const;

View File

@@ -185,8 +185,10 @@ export type TUpdateProjectWorkflowIntegration = (
integration: WorkflowIntegration.SLACK;
isAccessRequestNotificationEnabled: boolean;
isSecretRequestNotificationEnabled: boolean;
isSecretSyncErrorNotificationEnabled: boolean;
accessRequestChannels?: string;
secretRequestChannels?: string;
secretSyncErrorChannels?: string;
}
| {
integrationId: string;

View File

@@ -10,6 +10,8 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification";
import { TriggerFeature } from "@app/lib/workflow-integrations/types";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { SecretNameSchema } from "@app/server/lib/schemas";
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
@@ -62,8 +64,11 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service";
import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal";
import { TNotificationServiceFactory } from "../notification/notification-service";
import { NotificationType } from "../notification/notification-types";
import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal";
export type TSecretSyncQueueFactory = ReturnType<typeof secretSyncQueueFactory>;
@@ -104,6 +109,9 @@ type TSecretSyncQueueFactoryDep = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
};
type SecretSyncActionJob = Job<
@@ -147,7 +155,10 @@ export const secretSyncQueueFactory = ({
licenseService,
gatewayService,
gatewayV2Service,
notificationService
notificationService,
projectSlackConfigDAL,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
}: TSecretSyncQueueFactoryDep) => {
const appCfg = getConfig();
@@ -921,34 +932,65 @@ export const secretSyncQueueFactory = ({
break;
}
const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`;
const baseProjectPath = `/projects/secret-management/${projectId}`;
const overviewPath = `${baseProjectPath}/overview`;
const syncPath = `${baseProjectPath}/integrations/secret-syncs/${destination}/${secretSync.id}`;
await notificationService.createUserNotifications(
projectAdmins.map((admin) => ({
userId: admin.userId,
orgId: project.orgId,
type: NotificationType.SECRET_SYNC_FAILED,
title: `Secret Sync Failed to ${actionLabel} Secrets`,
body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`,
link: syncPath
}))
);
const notifications = [
triggerWorkflowIntegrationNotification({
input: {
notification: {
type: TriggerFeature.SECRET_SYNC_ERROR,
payload: {
syncName: name,
syncDestination,
failureMessage: failureMessage || "An unknown error occurred",
syncUrl: `${appCfg.SITE_URL}${syncPath}`,
syncActionLabel: actionLabel,
environment: environment?.name || "-",
secretPath: folder?.path || "-",
projectName: project.name,
projectPath: overviewPath
}
},
projectId
},
dependencies: {
projectDAL,
projectSlackConfigDAL,
kmsService,
microsoftTeamsService,
projectMicrosoftTeamsConfigDAL
}
}),
notificationService.createUserNotifications(
projectAdmins.map((admin) => ({
userId: admin.userId,
orgId: project.orgId,
type: NotificationType.SECRET_SYNC_FAILED,
title: `Secret Sync Failed to ${actionLabel} Secrets`,
body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`,
link: syncPath
}))
),
smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretSyncFailed,
subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`,
substitutions: {
syncName: name,
syncDestination,
content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`,
failureMessage,
secretPath: folder?.path,
environment: environment?.name,
projectName: project.name,
syncUrl: `${appCfg.SITE_URL}${syncPath}`
}
})
];
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretSyncFailed,
subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`,
substitutions: {
syncName: name,
syncDestination,
content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`,
failureMessage,
secretPath: folder?.path,
environment: environment?.name,
projectName: project.name,
syncUrl: `${appCfg.SITE_URL}${syncPath}`
}
});
await Promise.allSettled(notifications);
};
const queueSecretSyncsSyncSecretsByPath = async ({

View File

@@ -1,16 +1,17 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TableName, TProjectSlackConfigs, TSlackIntegrations } from "@app/db/schemas";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TProjectSlackConfigDALFactory = ReturnType<typeof projectSlackConfigDALFactory>;
export type TProjectSlackConfigWithIntegrations = TProjectSlackConfigs & TSlackIntegrations;
export const projectSlackConfigDALFactory = (db: TDbClient) => {
const projectSlackConfigOrm = ormify(db, TableName.ProjectSlackConfigs);
const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => {
return (tx || db.replicaNode())(TableName.ProjectSlackConfigs)
return (tx || db.replicaNode())<TProjectSlackConfigWithIntegrations>(TableName.ProjectSlackConfigs)
.join(
TableName.SlackIntegrations,
`${TableName.ProjectSlackConfigs}.slackIntegrationId`,

View File

@@ -8,6 +8,9 @@ import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/ty
import { KmsDataKey } from "../kms/kms-types";
import { TSendSlackNotificationDTO } from "./slack-types";
const COMPANY_BRAND_COLOR = "#e0ed34";
const ERROR_COLOR = "#e74c3c";
export const fetchSlackChannels = async (botKey: string) => {
const slackChannels: {
name: string;
@@ -48,13 +51,9 @@ const buildSlackPayload = (notification: TNotification) => {
const messageBody = `A secret approval request has been opened by ${payload.userEmail}.
*Environment*: ${payload.environment}
*Secret path*: ${payload.secretPath || "/"}
*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}
*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}`;
View the complete details <${appCfg.SITE_URL}/projects/secret-management/${payload.projectId}/approval?requestId=${
payload.requestId
}|here>.`;
const payloadBlocks = [
const headerBlocks = [
{
type: "header",
text: {
@@ -62,37 +61,52 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo
text: "Secret approval request",
emoji: true
}
},
}
];
const payloadBlocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: messageBody
}
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View request",
emoji: true
},
style: "primary",
url: payload.approvalUrl
}
]
}
];
return {
headerBlocks,
payloadMessage: messageBody,
payloadBlocks
payloadBlocks,
color: COMPANY_BRAND_COLOR
};
}
case TriggerFeature.ACCESS_REQUEST: {
const { payload } = notification;
const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${
payload.isTemporary ? "temporary" : "permanent"
} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
The following permissions are requested: ${payload.permissions.join(", ")}
const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`;
const accessType = payload.isTemporary ? "temporary" : "permanent";
const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", ");
View the request and approve or deny it <${payload.approvalUrl}|here>.${
payload.note
? `
User Note: ${payload.note}`
: ""
const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${accessType} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${
payload.note ? `\n\nUser note: ${payload.note}` : ""
}`;
const payloadBlocks = [
const headerBlocks = [
{
type: "header",
text: {
@@ -100,37 +114,54 @@ User Note: ${payload.note}`
text: "New access approval request pending for review",
emoji: true
}
},
}
];
const payloadBlocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: messageBody
text: `*${payload.requesterFullName}* (${payload.requesterEmail}) has requested *${accessType}* access to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${
payload.note ? `\n\n*User note:* ${payload.note}` : ""
}`
}
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View request",
emoji: true
},
style: "primary",
url: payload.approvalUrl
}
]
}
];
return {
headerBlocks,
payloadMessage: messageBody,
payloadBlocks
payloadBlocks,
color: COMPANY_BRAND_COLOR
};
}
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
const { payload } = notification;
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
payload.isTemporary ? "temporary" : "permanent"
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
The following permissions are requested: ${payload.permissions.join(", ")}
const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`;
const accessType = payload.isTemporary ? "temporary" : "permanent";
const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", ");
View the request and approve or deny it <${payload.approvalUrl}|here>.${
payload.editNote
? `
Editor Note: ${payload.editNote}`
: ""
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${accessType} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${
payload.editNote ? `\n\nEditor Note: ${payload.editNote}` : ""
}`;
const payloadBlocks = [
const headerBlocks = [
{
type: "header",
text: {
@@ -138,19 +169,89 @@ Editor Note: ${payload.editNote}`
text: "Updated access approval request pending for review",
emoji: true
}
},
}
];
const payloadBlocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: messageBody
text: `*${payload.editorFullName}* (${payload.editorEmail}) has updated the *${accessType}* access request from *${payload.requesterFullName}* (${payload.requesterEmail}) to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${
payload.editNote ? `\n\n*Editor Note:* ${payload.editNote}` : ""
}`
}
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View request",
emoji: true
},
style: "primary",
url: payload.approvalUrl
}
]
}
];
return {
headerBlocks,
payloadMessage: messageBody,
payloadBlocks,
color: COMPANY_BRAND_COLOR
};
}
case TriggerFeature.SECRET_SYNC_ERROR: {
const { payload } = notification;
const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}`;
const messageBody = `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}\n\n\nEnvironment: ${payload.environment}\n\n\nSecret Path: ${payload.secretPath}\n\n\nProject: ${payload.projectName} (${projectUrl})\n\n\nReason:\n${payload.failureMessage}`;
const headerBlocks = [
{
type: "header",
text: {
type: "plain_text",
text: `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`,
emoji: true
}
}
];
const payloadBlocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: `*Environment:* ${payload.environment}\n\n*Secret Path:* ${payload.secretPath}\n\n*Project:* <${projectUrl}|${payload.projectName}>\n\n*Reason:* ${payload.failureMessage}`
}
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "Open secret sync",
emoji: true
},
style: "primary",
url: payload.syncUrl
}
]
}
];
return {
payloadMessage: messageBody,
payloadBlocks
headerBlocks,
payloadBlocks,
color: ERROR_COLOR
};
}
default: {
@@ -177,15 +278,22 @@ export const sendSlackNotification = async ({
}).toString("utf8");
const slackWebClient = new WebClient(botKey);
const { payloadMessage, payloadBlocks } = buildSlackPayload(notification);
const { payloadMessage, payloadBlocks, color, headerBlocks } = buildSlackPayload(notification);
for await (const conversationId of targetChannelIds) {
// we send both text and blocks for compatibility with barebone clients
await slackWebClient.chat
.postMessage({
channel: conversationId,
text: payloadMessage,
blocks: payloadBlocks
blocks: headerBlocks,
attachments: [
{
color,
blocks: payloadBlocks
}
]
})
.catch((err) => logger.error(err));
}

View File

@@ -86,6 +86,8 @@ export type ProjectWorkflowIntegrationConfig =
accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string;
isSecretSyncErrorNotificationEnabled: boolean;
secretSyncErrorChannels: string;
}
| {
id: string;
@@ -112,6 +114,8 @@ export type TUpdateProjectWorkflowIntegrationConfigDTO =
accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string;
isSecretSyncErrorNotificationEnabled: boolean;
secretSyncErrorChannels: string;
}
| {
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS;

View File

@@ -111,7 +111,7 @@ export const WorkflowIntegrationTab = () => {
<Td>Provider</Td>
<Td>Access Request Notifications Destination</Td>
<Td>Secret Request Notifications Destination</Td>
<Td />
<Td>Secret Sync Error Notifications Destination</Td>
</Tr>
</THead>
<TBody>

View File

@@ -99,7 +99,9 @@ export const MicrosoftTeamsConfigRow = ({
</Badge>
)}
</Td>
<Td>
<Badge variant="danger">Disabled</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">

View File

@@ -96,7 +96,25 @@ export const SlackConfigRow = ({ handlePopUpOpen, isSlackConfigLoading, slackCon
</Badge>
)}
</Td>
<Td>
{slackConfig.isSecretSyncErrorNotificationEnabled &&
!isLoadingConfig &&
slackConfig.secretSyncErrorChannels.length > 0 ? (
<p>
{slackConfig.secretSyncErrorChannels
.split(", ")
.map((channel) => slackChannelIdToName[channel])
.join(", ")}
</p>
) : isLoadingConfig ? (
<Spinner size="xs" />
) : (
<Badge variant="neutral">
<BanIcon />
Disabled
</Badge>
)}
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">

View File

@@ -37,11 +37,76 @@ const formSchema = z.object({
isSecretRequestNotificationEnabled: z.boolean(),
secretRequestChannels: z.string().array(),
isAccessRequestNotificationEnabled: z.boolean(),
accessRequestChannels: z.string().array()
accessRequestChannels: z.string().array(),
isSecretSyncErrorNotificationEnabled: z.boolean(),
secretSyncErrorChannels: z.string().array()
});
type TSlackConfigForm = z.infer<typeof formSchema>;
type TChannelSelectorProps = {
value: string[];
onChange: (value: string[]) => void;
error?: { message?: string };
slackChannelIdToName: Record<string, string>;
sortedSlackChannels?: { id: string; name: string }[];
keyPrefix: string;
};
const ChannelSelector = ({
value,
onChange,
error,
slackChannelIdToName,
sortedSlackChannels,
keyPrefix
}: TChannelSelectorProps) => (
<FormControl label="Slack channels" isError={Boolean(error)} errorText={error?.message}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value
?.filter(Boolean)
.map((entry) => slackChannelIdToName[entry])
.join(", ")}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
maxHeight: "350px",
overflowY: "auto"
}}
side="bottom"
align="start"
>
{sortedSlackChannels?.map((slackChannel) => {
const isChecked = value?.includes(slackChannel.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== slackChannel.id)
: [...(value || []), slackChannel.id]
);
}}
key={`${keyPrefix}-slack-channel-${slackChannel.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{slackChannel.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
);
type Props = {
onClose: () => void;
};
@@ -71,7 +136,9 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
isAccessRequestNotificationEnabled: false,
accessRequestChannels: [],
isSecretRequestNotificationEnabled: false,
secretRequestChannels: []
secretRequestChannels: [],
isSecretSyncErrorNotificationEnabled: false,
secretSyncErrorChannels: []
}
});
@@ -86,7 +153,8 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
integration: WorkflowIntegrationPlatform.SLACK,
integrationId: data.slackIntegrationId,
accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "),
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ")
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", "),
secretSyncErrorChannels: data.secretSyncErrorChannels.filter(Boolean).join(", ")
});
createNotification({
@@ -100,6 +168,7 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
const secretRequestNotifState = watch("isSecretRequestNotificationEnabled");
const selectedSlackIntegrationId = watch("slackIntegrationId");
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
const secretSyncErrorNotifState = watch("isSecretSyncErrorNotificationEnabled");
const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId);
const slackChannelIdToName = Object.fromEntries(
@@ -110,7 +179,7 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
);
useEffect(() => {
if (slackConfig) {
if (slackConfig && slackConfig.integration === WorkflowIntegrationPlatform.SLACK) {
setValue("slackIntegrationId", slackConfig.integrationId);
setValue(
"isSecretRequestNotificationEnabled",
@@ -120,22 +189,30 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
"isAccessRequestNotificationEnabled",
slackConfig.isAccessRequestNotificationEnabled
);
setValue(
"isSecretSyncErrorNotificationEnabled",
slackConfig.isSecretSyncErrorNotificationEnabled
);
if (slackConfig.integration === WorkflowIntegrationPlatform.SLACK) {
if (slackChannels) {
setValue(
"secretRequestChannels",
slackConfig.secretRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
setValue(
"accessRequestChannels",
slackConfig.accessRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
}
if (slackChannels) {
setValue(
"secretRequestChannels",
(slackConfig.secretRequestChannels || "")
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
setValue(
"accessRequestChannels",
(slackConfig.accessRequestChannels || "")
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
setValue(
"secretSyncErrorChannels",
(slackConfig.secretSyncErrorChannels || "")
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
}
}
}, [slackConfig, slackChannels]);
@@ -208,54 +285,14 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
control={control}
name="secretRequestChannels"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Slack channels"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value
?.filter(Boolean)
.map((entry) => slackChannelIdToName[entry])
.join(", ")}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
maxHeight: "350px",
overflowY: "auto"
}}
side="bottom"
align="start"
>
{sortedSlackChannels?.map((slackChannel) => {
const isChecked = value?.includes(slackChannel.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== slackChannel.id)
: [...(value || []), slackChannel.id]
);
}}
key={`secret-requests-slack-channel-${slackChannel.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{slackChannel.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<ChannelSelector
value={value}
onChange={onChange}
error={error}
slackChannelIdToName={slackChannelIdToName}
sortedSlackChannels={sortedSlackChannels}
keyPrefix="secret-requests"
/>
)}
/>
)}
@@ -281,54 +318,47 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
control={control}
name="accessRequestChannels"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Slack channels"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value
?.filter(Boolean)
.map((entry) => slackChannelIdToName[entry])
.join(", ")}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
maxHeight: "350px",
overflowY: "auto"
}}
side="bottom"
align="start"
>
{sortedSlackChannels?.map((slackChannel) => {
const isChecked = value?.includes(slackChannel.id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== slackChannel.id)
: [...(value || []), slackChannel.id]
);
}}
key={`access-requests-slack-channel-${slackChannel.id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{slackChannel.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<ChannelSelector
value={value}
onChange={onChange}
error={error}
slackChannelIdToName={slackChannelIdToName}
sortedSlackChannels={sortedSlackChannels}
keyPrefix="access-requests"
/>
)}
/>
)}
<Controller
control={control}
name="isSecretSyncErrorNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
<Switch
id="secret-sync-error-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Secret Sync Errors</p>
</Switch>
</FormControl>
);
}}
/>
{secretSyncErrorNotifState && (
<Controller
control={control}
name="secretSyncErrorChannels"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<ChannelSelector
value={value}
onChange={onChange}
error={error}
slackChannelIdToName={slackChannelIdToName}
sortedSlackChannels={sortedSlackChannels}
keyPrefix="secret-sync-errors"
/>
)}
/>
)}