feat(workflow-integration): enhance project workflow integration with secret sync error notifications and improve Slack notification formatting

This commit is contained in:
Victor Santos
2025-10-27 09:29:16 -03:00
parent ecca51b1d6
commit c1dc3c2b76
13 changed files with 260 additions and 152 deletions
@@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({
); );
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; 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}`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({ await triggerWorkflowIntegrationNotification({
@@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({
type: TriggerFeature.ACCESS_REQUEST, type: TriggerFeature.ACCESS_REQUEST,
payload: { payload: {
projectName: project.name, projectName: project.name,
projectPath,
requesterFullName, requesterFullName,
isTemporary, isTemporary,
requesterEmail: requestedByUser.email as string, requesterEmail: requestedByUser.email as string,
@@ -32,6 +32,7 @@ export type TNotification =
secretPath: string; secretPath: string;
environment: string; environment: string;
projectName: string; projectName: string;
projectPath: string;
permissions: string[]; permissions: string[];
approvalUrl: string; approvalUrl: string;
note?: string; note?: string;
@@ -61,6 +62,10 @@ export type TNotification =
syncDestination: string; syncDestination: string;
failureMessage: string; failureMessage: string;
syncUrl: string; syncUrl: string;
environment: string;
secretPath: string;
projectName: string;
projectPath: string;
}; };
}; };
+4 -1
View File
@@ -1244,7 +1244,10 @@ export const registerRoutes = async (
licenseService, licenseService,
gatewayService, gatewayService,
gatewayV2Service, gatewayV2Service,
notificationService notificationService,
projectSlackConfigDAL,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
}); });
const secretQueueService = secretQueueFactory({ const secretQueueService = secretQueueFactory({
@@ -769,7 +769,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
isAccessRequestNotificationEnabled: true, isAccessRequestNotificationEnabled: true,
accessRequestChannels: true, accessRequestChannels: true,
isSecretRequestNotificationEnabled: true, isSecretRequestNotificationEnabled: true,
secretRequestChannels: true secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge( }).merge(
z.object({ z.object({
integration: z.literal(WorkflowIntegration.SLACK), integration: z.literal(WorkflowIntegration.SLACK),
@@ -873,7 +875,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
accessRequestChannels: validateSlackChannelsField, accessRequestChannels: validateSlackChannelsField,
secretRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField,
isAccessRequestNotificationEnabled: z.boolean(), isAccessRequestNotificationEnabled: z.boolean(),
isSecretRequestNotificationEnabled: z.boolean() isSecretRequestNotificationEnabled: z.boolean(),
secretSyncErrorChannels: validateSlackChannelsField,
isSecretSyncErrorNotificationEnabled: z.boolean()
}), }),
z.object({ z.object({
integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS),
@@ -891,7 +895,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
isAccessRequestNotificationEnabled: true, isAccessRequestNotificationEnabled: true,
accessRequestChannels: true, accessRequestChannels: true,
isSecretRequestNotificationEnabled: true, isSecretRequestNotificationEnabled: true,
secretRequestChannels: true secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge( }).merge(
z.object({ z.object({
integration: z.literal(WorkflowIntegration.SLACK), integration: z.literal(WorkflowIntegration.SLACK),
@@ -1568,8 +1568,14 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled, isAccessRequestNotificationEnabled,
accessRequestChannels, accessRequestChannels,
isSecretRequestNotificationEnabled, isSecretRequestNotificationEnabled,
secretRequestChannels secretRequestChannels,
}: TUpdateProjectWorkflowIntegration) => { 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); const project = await projectDAL.findById(projectId);
if (!project) { if (!project) {
throw new NotFoundError({ throw new NotFoundError({
@@ -1591,6 +1597,7 @@ export const projectServiceFactory = ({
const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels); const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels);
const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels); const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels);
const sanitizedSecretSyncErrorChannels = validateSlackChannelsField.parse(secretSyncErrorChannels);
const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId);
@@ -1628,7 +1635,9 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled, isAccessRequestNotificationEnabled,
accessRequestChannels: sanitizedAccessRequestChannels, accessRequestChannels: sanitizedAccessRequestChannels,
isSecretRequestNotificationEnabled, isSecretRequestNotificationEnabled,
secretRequestChannels: sanitizedSecretRequestChannels secretRequestChannels: sanitizedSecretRequestChannels,
isSecretSyncErrorNotificationEnabled,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
}, },
tx tx
); );
@@ -1641,7 +1650,9 @@ export const projectServiceFactory = ({
isAccessRequestNotificationEnabled, isAccessRequestNotificationEnabled,
accessRequestChannels: sanitizedAccessRequestChannels, accessRequestChannels: sanitizedAccessRequestChannels,
isSecretRequestNotificationEnabled, isSecretRequestNotificationEnabled,
secretRequestChannels: sanitizedSecretRequestChannels secretRequestChannels: sanitizedSecretRequestChannels,
isSecretSyncErrorNotificationEnabled,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels
}, },
tx tx
); );
@@ -1651,6 +1662,7 @@ export const projectServiceFactory = ({
...updatedWorkflowIntegration, ...updatedWorkflowIntegration,
accessRequestChannels: sanitizedAccessRequestChannels, accessRequestChannels: sanitizedAccessRequestChannels,
secretRequestChannels: sanitizedSecretRequestChannels, secretRequestChannels: sanitizedSecretRequestChannels,
secretSyncErrorChannels: sanitizedSecretSyncErrorChannels,
integrationId: slackIntegration.id, integrationId: slackIntegration.id,
integration: WorkflowIntegration.SLACK integration: WorkflowIntegration.SLACK
} as const; } as const;
@@ -184,8 +184,10 @@ export type TUpdateProjectWorkflowIntegration = (
integration: WorkflowIntegration.SLACK; integration: WorkflowIntegration.SLACK;
isAccessRequestNotificationEnabled: boolean; isAccessRequestNotificationEnabled: boolean;
isSecretRequestNotificationEnabled: boolean; isSecretRequestNotificationEnabled: boolean;
isSecretSyncErrorNotificationEnabled: boolean;
accessRequestChannels?: string; accessRequestChannels?: string;
secretRequestChannels?: string; secretRequestChannels?: string;
secretSyncErrorChannels?: string;
} }
| { | {
integrationId: string; integrationId: string;
@@ -932,7 +932,9 @@ export const secretSyncQueueFactory = ({
break; 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}`;
const notifications = [ const notifications = [
triggerWorkflowIntegrationNotification({ triggerWorkflowIntegrationNotification({
@@ -944,10 +946,14 @@ export const secretSyncQueueFactory = ({
syncDestination, syncDestination,
failureMessage: failureMessage || "An unknown error occurred", failureMessage: failureMessage || "An unknown error occurred",
syncUrl: `${appCfg.SITE_URL}${syncPath}`, syncUrl: `${appCfg.SITE_URL}${syncPath}`,
syncActionLabel: actionLabel syncActionLabel: actionLabel,
environment: environment?.name || "-",
secretPath: folder?.path || "-",
projectName: project.name,
projectPath: overviewPath
} }
}, },
projectId: project.id projectId
}, },
dependencies: { dependencies: {
projectDAL, projectDAL,
+46 -22
View File
@@ -76,6 +76,7 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo
]; ];
return { return {
headerBlocks: [],
payloadMessage: messageBody, payloadMessage: messageBody,
payloadBlocks, payloadBlocks,
color: COMPANY_BRAND_COLOR color: COMPANY_BRAND_COLOR
@@ -83,20 +84,15 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo
} }
case TriggerFeature.ACCESS_REQUEST: { case TriggerFeature.ACCESS_REQUEST: {
const { payload } = notification; const { payload } = notification;
const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}`;
payload.isTemporary ? "temporary" : "permanent" const accessType = payload.isTemporary ? "temporary" : "permanent";
} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", ");
The following permissions are requested: ${payload.permissions.join(", ")} 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\n${payload.note}` : ""
View the request and approve or deny it <${payload.approvalUrl}|here>.${
payload.note
? `
User Note: ${payload.note}`
: ""
}`; }`;
const payloadBlocks = [ const headerBlocks = [
{ {
type: "header", type: "header",
text: { text: {
@@ -104,17 +100,38 @@ User Note: ${payload.note}`
text: "New access approval request pending for review", text: "New access approval request pending for review",
emoji: true emoji: true
} }
}, }
];
const payloadBlocks = [
{ {
type: "section", type: "section",
text: { text: {
type: "mrkdwn", 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*\n${payload.note}` : ""
}`
} }
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View request",
emoji: true
},
style: "primary",
url: payload.approvalUrl
}
]
} }
]; ];
return { return {
headerBlocks,
payloadMessage: messageBody, payloadMessage: messageBody,
payloadBlocks, payloadBlocks,
color: COMPANY_BRAND_COLOR color: COMPANY_BRAND_COLOR
@@ -154,6 +171,7 @@ Editor Note: ${payload.editNote}`
]; ];
return { return {
headerBlocks: [],
payloadMessage: messageBody, payloadMessage: messageBody,
payloadBlocks, payloadBlocks,
color: COMPANY_BRAND_COLOR color: COMPANY_BRAND_COLOR
@@ -161,24 +179,26 @@ Editor Note: ${payload.editNote}`
} }
case TriggerFeature.SECRET_SYNC_ERROR: { case TriggerFeature.SECRET_SYNC_ERROR: {
const { payload } = notification; const { payload } = notification;
const messageBody = `${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel} 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}`;
Sync Error: ${payload.failureMessage}`; const headerBlocks = [
const payloadBlocks = [
{ {
type: "header", type: "header",
text: { text: {
type: "plain_text", type: "plain_text",
text: `${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`, text: `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`,
emoji: true emoji: true
} }
}, }
];
const payloadBlocks = [
{ {
type: "section", type: "section",
text: { text: {
type: "mrkdwn", type: "mrkdwn",
text: `*Sync Error:* ${payload.failureMessage}` text: `*Environment*\n${payload.environment}\n\n\n*Secret Path*\n${payload.secretPath}\n\n\n*Project*\n<${projectUrl}|${payload.projectName}>\n\n\n*Reason*\n${payload.failureMessage}`
} }
}, },
{ {
@@ -188,9 +208,10 @@ Sync Error: ${payload.failureMessage}`;
type: "button", type: "button",
text: { text: {
type: "plain_text", type: "plain_text",
text: `Open ${payload.syncName}`, text: "Open secret sync",
emoji: true emoji: true
}, },
style: "primary",
url: payload.syncUrl url: payload.syncUrl
} }
] ]
@@ -199,6 +220,7 @@ Sync Error: ${payload.failureMessage}`;
return { return {
payloadMessage: messageBody, payloadMessage: messageBody,
headerBlocks,
payloadBlocks, payloadBlocks,
color: ERROR_COLOR color: ERROR_COLOR
}; };
@@ -227,14 +249,16 @@ export const sendSlackNotification = async ({
}).toString("utf8"); }).toString("utf8");
const slackWebClient = new WebClient(botKey); const slackWebClient = new WebClient(botKey);
const { payloadMessage, payloadBlocks, color } = buildSlackPayload(notification); const { payloadMessage, payloadBlocks, color, headerBlocks } = buildSlackPayload(notification);
for await (const conversationId of targetChannelIds) { for await (const conversationId of targetChannelIds) {
// we send both text and blocks for compatibility with barebone clients // we send both text and blocks for compatibility with barebone clients
await slackWebClient.chat await slackWebClient.chat
.postMessage({ .postMessage({
channel: conversationId, channel: conversationId,
text: payloadMessage, text: payloadMessage,
blocks: headerBlocks,
attachments: [ attachments: [
{ {
color, color,
@@ -86,6 +86,8 @@ export type ProjectWorkflowIntegrationConfig =
accessRequestChannels: string; accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean; isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string; secretRequestChannels: string;
isSecretSyncErrorNotificationEnabled: boolean;
secretSyncErrorChannels: string;
} }
| { | {
id: string; id: string;
@@ -112,6 +114,8 @@ export type TUpdateProjectWorkflowIntegrationConfigDTO =
accessRequestChannels: string; accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean; isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string; secretRequestChannels: string;
isSecretSyncErrorNotificationEnabled: boolean;
secretSyncErrorChannels: string;
} }
| { | {
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS; integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS;
@@ -111,7 +111,7 @@ export const WorkflowIntegrationTab = () => {
<Td>Provider</Td> <Td>Provider</Td>
<Td>Access Request Notifications Destination</Td> <Td>Access Request Notifications Destination</Td>
<Td>Secret Request Notifications Destination</Td> <Td>Secret Request Notifications Destination</Td>
<Td /> <Td>Secret Sync Error Notifications Destination</Td>
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
@@ -92,7 +92,9 @@ export const MicrosoftTeamsConfigRow = ({
<Badge variant="danger">Disabled</Badge> <Badge variant="danger">Disabled</Badge>
)} )}
</Td> </Td>
<Td>
<Badge variant="danger">Coming Soon</Badge>
</Td>
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
@@ -89,7 +89,22 @@ export const SlackConfigRow = ({ handlePopUpOpen, isSlackConfigLoading, slackCon
<Badge variant="danger">Disabled</Badge> <Badge variant="danger">Disabled</Badge>
)} )}
</Td> </Td>
<Td>
{slackConfig.isSecretSyncErrorNotificationEnabled &&
!isLoadingConfig &&
slackConfig.secretSyncErrorChannels.length > 0 ? (
<Badge>
{slackConfig.secretSyncErrorChannels
.split(", ")
.map((channel) => slackChannelIdToName[channel])
.join(", ")}
</Badge>
) : isLoadingConfig ? (
<Spinner size="xs" />
) : (
<Badge variant="danger">Disabled</Badge>
)}
</Td>
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
@@ -37,7 +37,9 @@ const formSchema = z.object({
isSecretRequestNotificationEnabled: z.boolean(), isSecretRequestNotificationEnabled: z.boolean(),
secretRequestChannels: z.string().array(), secretRequestChannels: z.string().array(),
isAccessRequestNotificationEnabled: z.boolean(), 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 TSlackConfigForm = z.infer<typeof formSchema>;
@@ -71,7 +73,9 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
isAccessRequestNotificationEnabled: false, isAccessRequestNotificationEnabled: false,
accessRequestChannels: [], accessRequestChannels: [],
isSecretRequestNotificationEnabled: false, isSecretRequestNotificationEnabled: false,
secretRequestChannels: [] secretRequestChannels: [],
isSecretSyncErrorNotificationEnabled: false,
secretSyncErrorChannels: []
} }
}); });
@@ -87,7 +91,8 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
integration: WorkflowIntegrationPlatform.SLACK, integration: WorkflowIntegrationPlatform.SLACK,
integrationId: data.slackIntegrationId, integrationId: data.slackIntegrationId,
accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), 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({ createNotification({
@@ -107,6 +112,7 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); const secretRequestNotifState = watch("isSecretRequestNotificationEnabled");
const selectedSlackIntegrationId = watch("slackIntegrationId"); const selectedSlackIntegrationId = watch("slackIntegrationId");
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
const secretSyncErrorNotifState = watch("isSecretSyncErrorNotificationEnabled");
const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId); const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId);
const slackChannelIdToName = Object.fromEntries( const slackChannelIdToName = Object.fromEntries(
@@ -117,7 +123,7 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
); );
useEffect(() => { useEffect(() => {
if (slackConfig) { if (slackConfig && slackConfig.integration === WorkflowIntegrationPlatform.SLACK) {
setValue("slackIntegrationId", slackConfig.integrationId); setValue("slackIntegrationId", slackConfig.integrationId);
setValue( setValue(
"isSecretRequestNotificationEnabled", "isSecretRequestNotificationEnabled",
@@ -127,22 +133,30 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
"isAccessRequestNotificationEnabled", "isAccessRequestNotificationEnabled",
slackConfig.isAccessRequestNotificationEnabled slackConfig.isAccessRequestNotificationEnabled
); );
setValue(
"isSecretSyncErrorNotificationEnabled",
slackConfig.isSecretSyncErrorNotificationEnabled
);
if (slackConfig.integration === WorkflowIntegrationPlatform.SLACK) { if (slackChannels) {
if (slackChannels) { setValue(
setValue( "secretRequestChannels",
"secretRequestChannels", slackConfig.secretRequestChannels
slackConfig.secretRequestChannels .split(", ")
.split(", ") .filter((channel) => channel in slackChannelIdToName)
.filter((channel) => channel in slackChannelIdToName) );
); setValue(
setValue( "accessRequestChannels",
"accessRequestChannels", slackConfig.accessRequestChannels
slackConfig.accessRequestChannels .split(", ")
.split(", ") .filter((channel) => channel in slackChannelIdToName)
.filter((channel) => channel in slackChannelIdToName) );
); setValue(
} "secretSyncErrorChannels",
slackConfig.secretSyncErrorChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
} }
} }
}, [slackConfig, slackChannels]); }, [slackConfig, slackChannels]);
@@ -215,54 +229,14 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
control={control} control={control}
name="secretRequestChannels" name="secretRequestChannels"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <ChannelSelector
label="Slack channels" value={value}
isError={Boolean(error)} onChange={onChange}
errorText={error?.message} error={error}
> slackChannelIdToName={slackChannelIdToName}
<DropdownMenu> sortedSlackChannels={sortedSlackChannels}
<DropdownMenuTrigger asChild> keyPrefix="secret-requests"
<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>
)} )}
/> />
)} )}
@@ -288,54 +262,47 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
control={control} control={control}
name="accessRequestChannels" name="accessRequestChannels"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <ChannelSelector
label="Slack channels" value={value}
isError={Boolean(error)} onChange={onChange}
errorText={error?.message} error={error}
> slackChannelIdToName={slackChannelIdToName}
<DropdownMenu> sortedSlackChannels={sortedSlackChannels}
<DropdownMenuTrigger asChild> keyPrefix="access-requests"
<Input />
isReadOnly )}
value={value />
?.filter(Boolean) )}
.map((entry) => slackChannelIdToName[entry]) <Controller
.join(", ")} control={control}
className="text-left" name="isSecretSyncErrorNotificationEnabled"
/> render={({ field, fieldState: { error } }) => {
</DropdownMenuTrigger> return (
<DropdownMenuContent <FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
style={{ <Switch
width: "var(--radix-dropdown-menu-trigger-width)", id="secret-sync-error-notification"
maxHeight: "350px", onCheckedChange={(value) => field.onChange(value)}
overflowY: "auto" isChecked={field.value}
}} >
side="bottom" <p className="w-full">Secret Sync Errors</p>
align="start" </Switch>
>
{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>
</FormControl> </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"
/>
)} )}
/> />
)} )}
@@ -353,3 +320,63 @@ export const SlackIntegrationForm = ({ onClose }: Props) => {
</form> </form>
); );
}; };
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>
);