feat(native-integrations): microsoft teams

This commit is contained in:
Daniel Hougaard
2025-04-25 04:35:40 +04:00
parent d7af9e84be
commit b5d229a7c5
71 changed files with 5648 additions and 999 deletions
@@ -47,8 +47,10 @@ AccordionTrigger.displayName = "AccordionTrigger";
export const AccordionContent = forwardRef<
HTMLDivElement,
AccordionPrimitive.AccordionContentProps
>(({ children, className, ...props }, forwardedRef) => (
AccordionPrimitive.AccordionContentProps & {
childrenClassName?: string;
}
>(({ children, className, childrenClassName, ...props }, forwardedRef) => (
<AccordionPrimitive.Content
className={twMerge(
"overflow-hidden data-[state=closed]:animate-slideUp data-[state=open]:animate-slideDown",
@@ -57,7 +59,7 @@ export const AccordionContent = forwardRef<
{...props}
ref={forwardedRef}
>
<div className="px-4 py-2 text-sm">{children}</div>
<div className={twMerge("px-4 py-2 text-sm", childrenClassName)}>{children}</div>
</AccordionPrimitive.Content>
));
+1 -2
View File
@@ -4,13 +4,12 @@ export {
useAdminRemoveIdentitySuperAdminAccess,
useCreateAdminUser,
useRemoveUserServerAdminAccess,
useUpdateAdminSlackConfig,
useUpdateServerConfig,
useUpdateServerEncryptionStrategy
} from "./mutation";
export {
useAdminGetUsers,
useGetAdminSlackConfig,
useGetAdminIntegrationsConfig,
useGetServerConfig,
useGetServerRootKmsEncryptionDetails
} from "./queries";
+3 -24
View File
@@ -6,11 +6,10 @@ import { organizationKeys } from "../organization/queries";
import { User } from "../users/types";
import { adminQueryKeys, adminStandaloneKeys } from "./queries";
import {
AdminSlackConfig,
RootKeyEncryptionStrategy,
TCreateAdminUserDTO,
TServerConfig,
TUpdateAdminSlackConfigDTO
TUpdateServerConfigDTO
} from "./types";
export const useCreateAdminUser = () => {
@@ -34,11 +33,7 @@ export const useCreateAdminUser = () => {
export const useUpdateServerConfig = () => {
const queryClient = useQueryClient();
return useMutation<
TServerConfig,
object,
Partial<TServerConfig & { slackClientId: string; slackClientSecret: string }>
>({
return useMutation<TServerConfig, object, TUpdateServerConfigDTO>({
mutationFn: async (opt) => {
const { data } = await apiRequest.patch<{ config: TServerConfig }>(
"/api/v1/admin/config",
@@ -48,6 +43,7 @@ export const useUpdateServerConfig = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(adminQueryKeys.serverConfig(), data);
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getAdminIntegrationsConfig() });
queryClient.invalidateQueries({ queryKey: adminQueryKeys.serverConfig() });
queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations });
}
@@ -119,23 +115,6 @@ export const useAdminGrantServerAdminAccess = () => {
});
};
export const useUpdateAdminSlackConfig = () => {
const queryClient = useQueryClient();
return useMutation<AdminSlackConfig, object, TUpdateAdminSlackConfigDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.put<AdminSlackConfig>(
"/api/v1/admin/integrations/slack/config",
dto
);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: adminQueryKeys.getAdminSlackConfig() });
}
});
};
export const useUpdateServerEncryptionStrategy = () => {
const queryClient = useQueryClient();
return useMutation({
+5 -7
View File
@@ -7,7 +7,7 @@ import { User } from "../types";
import {
AdminGetIdentitiesFilters,
AdminGetUsersFilters,
AdminSlackConfig,
AdminIntegrationsConfig,
TGetServerRootKmsEncryptionDetails,
TServerConfig
} from "./types";
@@ -22,7 +22,7 @@ export const adminQueryKeys = {
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
getIdentities: (filters: AdminGetIdentitiesFilters) =>
[adminStandaloneKeys.getIdentities, { filters }] as const,
getAdminSlackConfig: () => ["admin-slack-config"] as const,
getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const,
getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const
};
@@ -95,13 +95,11 @@ export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => {
});
};
export const useGetAdminSlackConfig = () => {
export const useGetAdminIntegrationsConfig = () => {
return useQuery({
queryKey: adminQueryKeys.getAdminSlackConfig(),
queryKey: adminQueryKeys.getAdminIntegrationsConfig(),
queryFn: async () => {
const { data } = await apiRequest.get<AdminSlackConfig>(
"/api/v1/admin/integrations/slack/config"
);
const { data } = await apiRequest.get<AdminIntegrationsConfig>("/api/v1/admin/integrations");
return data;
}
+18 -8
View File
@@ -26,6 +26,14 @@ export type TServerConfig = {
pageFrameContent?: string;
};
export type TUpdateServerConfigDTO = {
slackClientId?: string;
slackClientSecret?: string;
microsoftTeamsAppId?: string;
microsoftTeamsClientSecret?: string;
microsoftTeamsBotId?: string;
} & Partial<TServerConfig>;
export type TCreateAdminUserDTO = {
email: string;
password: string;
@@ -42,11 +50,6 @@ export type TCreateAdminUserDTO = {
salt: string;
};
export type TUpdateAdminSlackConfigDTO = {
clientId: string;
clientSecret: string;
};
export type AdminGetUsersFilters = {
limit: number;
searchTerm: string;
@@ -58,9 +61,16 @@ export type AdminGetIdentitiesFilters = {
searchTerm: string;
};
export type AdminSlackConfig = {
clientId: string;
clientSecret: string;
export type AdminIntegrationsConfig = {
slack: {
clientId: string;
clientSecret: string;
};
microsoftTeams: {
appId: string;
clientSecret: string;
botId: string;
};
};
export type TGetServerRootKmsEncryptionDetails = {
@@ -93,8 +93,10 @@ export const eventToNameMap: { [K in EventType]: string } = {
"Create certificate template EST configuration",
[EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]:
"Update certificate template EST configuration",
[EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update project slack configuration",
[EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration",
[EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG]:
"Update project workflow integration configuration",
[EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG]:
"Get project workflow integration configuration",
[EventType.INTEGRATION_SYNCED]: "Integration sync",
[EventType.CREATE_SHARED_SECRET]: "Create shared secret",
[EventType.DELETE_SHARED_SECRET]: "Delete shared secret",
+2 -2
View File
@@ -97,8 +97,8 @@ export enum EventType {
CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config",
UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config",
GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config",
UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "update-project-workflow-integration-config",
GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "get-project-workflow-integration-config",
INTEGRATION_SYNCED = "integration-synced",
CREATE_SHARED_SECRET = "create-shared-secret",
DELETE_SHARED_SECRET = "delete-shared-secret",
+10 -7
View File
@@ -1,6 +1,7 @@
import { CaStatus } from "../ca";
import { IdentityTrustedIp } from "../identities/types";
import { PkiItemType } from "../pkiCollections/constants";
import { WorkflowIntegration } from "../workflowIntegrations/types";
import { ActorType, EventType, UserAgentType } from "./enums";
export type TGetAuditLogsFilter = {
@@ -781,11 +782,12 @@ interface GetCertificateTemplateEstConfig {
};
}
interface UpdateProjectSlackConfig {
type: EventType.UPDATE_PROJECT_SLACK_CONFIG;
interface UpdateProjectWorkflowIntegrationConfig {
type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG;
metadata: {
id: string;
slackIntegrationId: string;
integrationId: string;
integration: WorkflowIntegration;
isAccessRequestNotificationEnabled: boolean;
accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean;
@@ -793,10 +795,11 @@ interface UpdateProjectSlackConfig {
};
}
interface GetProjectSlackConfig {
type: EventType.GET_PROJECT_SLACK_CONFIG;
interface GetProjectWorkflowIntegrationConfig {
type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG;
metadata: {
id: string;
integration: WorkflowIntegration;
};
}
@@ -892,8 +895,8 @@ export type Event =
| UpdateCertificateTemplateEstConfig
| CreateCertificateTemplateEstConfig
| GetCertificateTemplateEstConfig
| UpdateProjectSlackConfig
| GetProjectSlackConfig
| UpdateProjectWorkflowIntegrationConfig
| GetProjectWorkflowIntegrationConfig
| IntegrationSyncedEvent;
export type AuditLog = {
@@ -1,13 +1,2 @@
export {
useDeleteSlackIntegration,
useUpdateProjectSlackConfig,
useUpdateSlackIntegration
} from "./mutation";
export {
fetchSlackInstallUrl,
fetchSlackReinstallUrl,
useGetSlackIntegrationById,
useGetSlackIntegrationChannels,
useGetSlackIntegrations,
useGetWorkflowIntegrations
} from "./queries";
export * from "./mutation";
export * from "./queries";
@@ -5,8 +5,13 @@ import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/query-keys";
import { workflowIntegrationKeys } from "./queries";
import {
TCheckMicrosoftTeamsIntegrationInstallationStatusDTO,
TCreateMicrosoftTeamsIntegrationDTO,
TDeleteMicrosoftTeamsIntegrationIntegrationDTO,
TDeleteProjectWorkflowIntegrationDTO,
TDeleteSlackIntegrationDTO,
TUpdateProjectSlackConfigDTO,
TUpdateMicrosoftTeamsIntegrationDTO,
TUpdateProjectWorkflowIntegrationConfigDTO,
TUpdateSlackIntegrationDTO
} from "./types";
@@ -28,6 +33,47 @@ export const useUpdateSlackIntegration = () => {
});
};
export const useUpdateMicrosoftTeamsIntegration = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TUpdateMicrosoftTeamsIntegrationDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.patch(
`/api/v1/workflow-integrations/microsoft-teams/${dto.id}`,
dto
);
return data;
},
onSuccess: (_, { orgId, id }) => {
queryClient.invalidateQueries({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id)
});
queryClient.invalidateQueries({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId)
});
queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) });
}
});
};
export const useCreateMicrosoftTeamsIntegration = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TCreateMicrosoftTeamsIntegrationDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post("/api/v1/workflow-integrations/microsoft-teams", dto);
return data;
},
onSuccess: (_, { orgId }) => {
queryClient.invalidateQueries({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId)
});
queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) });
}
});
};
export const useDeleteSlackIntegration = () => {
const queryClient = useQueryClient();
@@ -44,21 +90,86 @@ export const useDeleteSlackIntegration = () => {
});
};
export const useUpdateProjectSlackConfig = () => {
export const useDeleteMicrosoftTeamsIntegration = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TDeleteMicrosoftTeamsIntegrationIntegrationDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.delete(
`/api/v1/workflow-integrations/microsoft-teams/${dto.id}`
);
return data;
},
onSuccess: (_, { orgId, id }) => {
queryClient.invalidateQueries({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id)
});
queryClient.invalidateQueries({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId)
});
queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) });
}
});
};
export const useUpdateProjectWorkflowIntegrationConfig = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: TUpdateProjectSlackConfigDTO) => {
mutationFn: async (dto: TUpdateProjectWorkflowIntegrationConfigDTO) => {
const { data } = await apiRequest.put(
`/api/v1/workspace/${dto.workspaceId}/slack-config`,
`/api/v1/workspace/${dto.workspaceId}/workflow-integration/${dto.integration}`,
dto
);
return data;
},
onSuccess: (_, { workspaceId }) => {
onSuccess: (_, { workspaceId, integration }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId)
queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(workspaceId, integration)
});
}
});
};
export const useDeleteProjectWorkflowIntegration = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: TDeleteProjectWorkflowIntegrationDTO) => {
const { data } = await apiRequest.delete(
`/api/v1/workspace/${dto.projectId}/workflow-integrations/${dto.integration}/${dto.integrationId}`
);
return data;
},
onSuccess: (_, { projectId, integration }) => {
queryClient.invalidateQueries({
queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(projectId, integration)
});
}
});
};
export const useCheckMicrosoftTeamsIntegrationInstallationStatus = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (dto: TCheckMicrosoftTeamsIntegrationInstallationStatusDTO) => {
const { data } = await apiRequest.post(
`/api/v1/workflow-integrations/microsoft-teams/${dto.workflowIntegrationId}/installation-status`
);
return data;
},
onSuccess: (_, { workflowIntegrationId, orgId }) => {
queryClient.invalidateQueries({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(workflowIntegrationId)
});
queryClient.invalidateQueries({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId)
});
queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) });
}
});
};
@@ -2,12 +2,27 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { SlackIntegration, SlackIntegrationChannel, WorkflowIntegration } from "./types";
import {
MicrosoftTeamsIntegration,
MicrosoftTeamsIntegrationTeam,
SlackIntegration,
SlackIntegrationChannel,
WorkflowIntegration
} from "./types";
export const workflowIntegrationKeys = {
getIntegrations: (orgId?: string) => [{ orgId }, "workflow-integrations"],
getSlackIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"],
getSlackIntegration: (id?: string) => [{ id }, "slack-workflow-integration"],
getMicrosoftTeamsIntegrations: (orgId?: string) => [
{ orgId },
"microsoft-teams-workflow-integrations"
],
getMicrosoftTeamsIntegration: (id?: string) => [{ id }, "microsoft-teams-workflow-integration"],
getMicrosoftTeamsIntegrationTeams: (id?: string) => [
{ id },
"microsoft-teams-workflow-integration-teams"
],
getSlackIntegrationChannels: (id?: string) => [{ id }, "slack-workflow-integration-channels"]
};
@@ -66,13 +81,50 @@ export const fetchWorkflowIntegrations = async () => {
return data;
};
export const useGetSlackIntegrations = (orgId?: string) =>
export const fetchMicrosoftTeamsIntegrations = async () => {
const { data } = await apiRequest.get<MicrosoftTeamsIntegration[]>(
"/api/v1/workflow-integrations/microsoft-teams"
);
return data;
};
export const fetchMicrosoftTeamsIntegrationById = async (id?: string) => {
const { data } = await apiRequest.get<MicrosoftTeamsIntegration>(
`/api/v1/workflow-integrations/microsoft-teams/${id}`
);
return data;
};
export const fetchMicrosoftTeamsIntegrationTeams = async (id?: string) => {
const { data } = await apiRequest.get<MicrosoftTeamsIntegrationTeam[]>(
`/api/v1/workflow-integrations/microsoft-teams/${id}/teams`
);
return data;
};
export const useGetMicrosoftTeamsIntegrations = (orgId?: string) =>
useQuery({
queryKey: workflowIntegrationKeys.getSlackIntegrations(orgId),
queryFn: () => fetchSlackIntegrations(),
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId),
queryFn: () => fetchMicrosoftTeamsIntegrations(),
enabled: Boolean(orgId)
});
export const useGetMicrosoftTeamsIntegrationById = (id?: string) =>
useQuery({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id),
queryFn: () => fetchMicrosoftTeamsIntegrationById(id),
enabled: Boolean(id)
});
export const useGetMicrosoftTeamsIntegrationTeams = (id?: string) =>
useQuery({
queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrationTeams(id),
queryFn: () => fetchMicrosoftTeamsIntegrationTeams(id),
enabled: Boolean(id)
});
export const useGetSlackIntegrationById = (id?: string) =>
useQuery({
queryKey: workflowIntegrationKeys.getSlackIntegration(id),
@@ -1,14 +1,25 @@
export enum WorkflowIntegrationPlatform {
SLACK = "slack"
SLACK = "slack",
MICROSOFT_TEAMS = "microsoft-teams"
}
export type WorkflowIntegration = {
id: string;
slug: string;
description: string;
status: WorkflowIntegrationStatus;
integration: WorkflowIntegrationPlatform;
};
export type MicrosoftTeamsIntegrationTeam = {
teamId: string;
teamName: string;
channels: {
channelId: string;
channelName: string;
}[];
};
export type SlackIntegration = {
id: string;
slug: string;
@@ -16,6 +27,18 @@ export type SlackIntegration = {
teamName: string;
};
export enum WorkflowIntegrationStatus {
Pending = "pending",
Installed = "installed"
}
export type MicrosoftTeamsIntegration = {
id: string;
slug: string;
description: string;
tenantId: string;
};
export type SlackIntegrationChannel = {
id: string;
name: string;
@@ -28,25 +51,88 @@ export type TUpdateSlackIntegrationDTO = {
description?: string;
};
export type TUpdateMicrosoftTeamsIntegrationDTO = {
id: string;
orgId: string;
slug?: string;
description?: string;
};
export type TCreateMicrosoftTeamsIntegrationDTO = {
orgId: string;
tenantId: string;
slug: string;
description?: string;
};
export type TDeleteSlackIntegrationDTO = {
id: string;
orgId: string;
};
export type ProjectSlackConfig = {
export type TDeleteMicrosoftTeamsIntegrationIntegrationDTO = {
id: string;
slackIntegrationId: string;
isAccessRequestNotificationEnabled: boolean;
accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string;
orgId: string;
};
export type TUpdateProjectSlackConfigDTO = {
workspaceId: string;
slackIntegrationId: string;
isAccessRequestNotificationEnabled: boolean;
accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string;
export type ProjectWorkflowIntegrationConfig =
| {
id: string;
integration: WorkflowIntegrationPlatform.SLACK;
integrationId: string;
isAccessRequestNotificationEnabled: boolean;
accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string;
}
| {
id: string;
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS;
integrationId: string;
isAccessRequestNotificationEnabled: boolean;
isSecretRequestNotificationEnabled: boolean;
accessRequestChannels: {
teamId: string;
channelIds: string[];
};
secretRequestChannels: {
teamId: string;
channelIds: string[];
};
};
export type TUpdateProjectWorkflowIntegrationConfigDTO =
| {
integration: WorkflowIntegrationPlatform.SLACK;
workspaceId: string;
integrationId: string;
isAccessRequestNotificationEnabled: boolean;
accessRequestChannels: string;
isSecretRequestNotificationEnabled: boolean;
secretRequestChannels: string;
}
| {
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS;
workspaceId: string;
integrationId: string;
isAccessRequestNotificationEnabled: boolean;
isSecretRequestNotificationEnabled: boolean;
accessRequestChannels?: {
teamId: string;
channelIds: string[];
};
secretRequestChannels?: {
teamId: string;
channelIds: string[];
};
};
export type TDeleteProjectWorkflowIntegrationDTO = {
projectId: string;
integration: WorkflowIntegrationPlatform;
integrationId: string;
};
export type TCheckMicrosoftTeamsIntegrationInstallationStatusDTO = {
workflowIntegrationId: string;
orgId: string;
};
+1 -1
View File
@@ -24,9 +24,9 @@ export {
useGetWorkspaceIndexStatus,
useGetWorkspaceIntegrations,
useGetWorkspaceSecrets,
useGetWorkspaceSlackConfig,
useGetWorkspaceUserDetails,
useGetWorkspaceUsers,
useGetWorkspaceWorkflowIntegrationConfig,
useListWorkspaceCas,
useListWorkspaceCertificates,
useListWorkspaceCertificateTemplates,
+14 -5
View File
@@ -20,7 +20,10 @@ import { TSshCertificateTemplate } from "../sshCertificateTemplates/types";
import { TSshHost } from "../sshHost/types";
import { userKeys } from "../users/query-keys";
import { TWorkspaceUser } from "../users/types";
import { ProjectSlackConfig } from "../workflowIntegrations/types";
import {
ProjectWorkflowIntegrationConfig,
WorkflowIntegrationPlatform
} from "../workflowIntegrations/types";
import { workspaceKeys } from "./query-keys";
import {
CreateEnvironmentDTO,
@@ -874,12 +877,18 @@ export const useListWorkspaceSshCertificateTemplates = (projectId: string) => {
});
};
export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => {
export const useGetWorkspaceWorkflowIntegrationConfig = ({
workspaceId,
integration
}: {
workspaceId: string;
integration: WorkflowIntegrationPlatform;
}) => {
return useQuery({
queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId),
queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(workspaceId, integration),
queryFn: async () => {
const { data } = await apiRequest.get<ProjectSlackConfig>(
`/api/v1/workspace/${workspaceId}/slack-config`
const { data } = await apiRequest.get<ProjectWorkflowIntegrationConfig>(
`/api/v1/workspace/${workspaceId}/workflow-integration-config/${integration}`
);
return data;
@@ -1,6 +1,7 @@
import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "@app/hooks/api/workspace/types";
import type { CaStatus } from "../ca";
import { WorkflowIntegrationPlatform } from "../workflowIntegrations/types";
export const workspaceKeys = {
getWorkspaceById: (workspaceId: string) => ["workspaces", { workspaceId }] as const,
@@ -53,8 +54,10 @@ export const workspaceKeys = {
[{ workspaceId }, "workspace-pki-collections"] as const,
getWorkspaceCertificateTemplates: (workspaceId: string) =>
[{ workspaceId }, "workspace-certificate-templates"] as const,
getWorkspaceSlackConfig: (workspaceId: string) =>
[{ workspaceId }, "workspace-slack-config"] as const,
getWorkspaceWorkflowIntegrationConfig: (
workspaceId: string,
integration: WorkflowIntegrationPlatform
) => [{ workspaceId, integration }, "workspace-workflow-integration-config"] as const,
getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const,
allWorkspaceSshCertificates: (projectId: string) =>
[{ projectId }, "workspace-ssh-certificates"] as const,
+16
View File
@@ -198,3 +198,19 @@ html {
top: 0.5rem;
@apply text-sm text-gray-500 opacity-50;
}
.accordion {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 200ms ease !important;
animation: unset;
}
.accordion[data-state="open"] {
grid-template-rows: 1fr;
}
.accordion > div {
min-height: 0px;
}
@@ -1,166 +1,24 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useGetAdminIntegrationsConfig } from "@app/hooks/api";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useGetAdminSlackConfig, useUpdateServerConfig } from "@app/hooks/api";
const slackFormSchema = z.object({
clientId: z.string(),
clientSecret: z.string()
});
type TSlackForm = z.infer<typeof slackFormSchema>;
const getCustomSlackAppCreationUrl = () =>
`https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(
JSON.stringify({
display_information: {
name: "Infisical",
description: "Get real-time Infisical updates in Slack",
background_color: "#c2d62b",
long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack.
- Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice.
- Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent.
- Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects.
By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.`
},
features: {
app_home: {
home_tab_enabled: false,
messages_tab_enabled: false,
messages_tab_read_only_enabled: true
},
bot_user: {
display_name: "Infisical",
always_online: true
}
},
oauth_config: {
redirect_urls: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`],
scopes: {
bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"]
}
},
settings: {
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false
}
})
)}`;
import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
export const IntegrationPanel = () => {
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TSlackForm>({
resolver: zodResolver(slackFormSchema)
});
const { data: adminSlackConfig } = useGetAdminSlackConfig();
const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig();
const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle();
const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle();
useEffect(() => {
if (adminSlackConfig) {
setValue("clientId", adminSlackConfig.clientId);
setValue("clientSecret", adminSlackConfig.clientSecret);
}
}, [adminSlackConfig]);
const onSlackFormSubmit = async (data: TSlackForm) => {
await updateAdminServerConfig({
slackClientId: data.clientId,
slackClientSecret: data.clientSecret
});
createNotification({
text: "Updated admin slack configuration",
type: "success"
});
};
const { data: adminIntegrationsConfig } = useGetAdminIntegrationsConfig();
return (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onSlackFormSubmit)}
>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Slack Integration</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 1: Create your Infisical Slack App
<div className="mb-6 min-h-64 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<div className="text-xl font-semibold text-mineshaft-100">Integrations</div>
<div className="text-sm text-mineshaft-300">
Configure your instance-wide settings to enable integration with Slack and Microsoft
Teams.
</div>
<div className="mb-6">
<Button
colorSchema="secondary"
onClick={() => window.open(getCustomSlackAppCreationUrl())}
>
Create Slack App
</Button>
</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 2: Configure your instance-wide settings to enable integration with Slack. Copy the
values from the App Credentials page of your custom Slack App.
</div>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client ID"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isSlackClientIdFocused ? "text" : "password"}
onFocus={() => setIsSlackClientIdFocused.on()}
onBlur={() => setIsSlackClientIdFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isSlackClientSecretFocused ? "text" : "password"}
onFocus={() => setIsSlackClientSecretFocused.on()}
onBlur={() => setIsSlackClientSecretFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
</div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
<div className="flex flex-col gap-2">
<SlackIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
<MicrosoftTeamsIntegrationForm adminIntegrationsConfig={adminIntegrationsConfig} />
</div>
</div>
);
};
@@ -0,0 +1,176 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { BsMicrosoftTeams } from "react-icons/bs";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useUpdateServerConfig } from "@app/hooks/api";
import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types";
const microsoftTeamsFormSchema = z.object({
appId: z.string(),
clientSecret: z.string(),
botId: z.string()
});
type TMicrosoftTeamsForm = z.infer<typeof microsoftTeamsFormSchema>;
type Props = {
adminIntegrationsConfig?: AdminIntegrationsConfig;
};
export const MicrosoftTeamsIntegrationForm = ({ adminIntegrationsConfig }: Props) => {
const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig();
const [isMicrosoftTeamsAppIdFocused, setIsMicrosoftTeamsAppIdFocused] = useToggle();
const [isMicrosoftTeamsClientSecretFocused, setIsMicrosoftTeamsClientSecretFocused] = useToggle();
const [isMicrosoftTeamsBotIdFocused, setIsMicrosoftTeamsBotIdFocused] = useToggle();
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TMicrosoftTeamsForm>({
resolver: zodResolver(microsoftTeamsFormSchema)
});
const onSubmit = async (data: TMicrosoftTeamsForm) => {
await updateAdminServerConfig({
microsoftTeamsAppId: data.appId,
microsoftTeamsClientSecret: data.clientSecret,
microsoftTeamsBotId: data.botId
});
createNotification({
text: "Updated admin Microsoft Teams configuration",
type: "success"
});
};
useEffect(() => {
if (adminIntegrationsConfig) {
setValue("appId", adminIntegrationsConfig.microsoftTeams.appId);
setValue("clientSecret", adminIntegrationsConfig.microsoftTeams.clientSecret);
setValue("botId", adminIntegrationsConfig.microsoftTeams.botId);
}
}, [adminIntegrationsConfig]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Accordion type="single" collapsible className="w-full">
<AccordionItem
value="microsoft-teams-integration"
className="data-[state=open]:border-none"
>
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<BsMicrosoftTeams className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">Microsoft Teams Integration</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
<div className="flex w-full flex-col justify-start rounded-md rounded-t-none border border-t-0 border-mineshaft-500 bg-mineshaft-700 px-4 py-4">
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 1: Create Microsoft Teams bot
</div>
<div className="mb-6">
<Button colorSchema="secondary">Documentation</Button>
</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 2: Configure your instance-wide settings to enable integration with Microsoft
Teams. Copy the App ID and Client Secret from your Microsoft Teams bot&apos;s App
Registration page. The Client Secret is the password for the bot.
</div>
<Controller
control={control}
name="appId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Application (Client) ID"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isMicrosoftTeamsAppIdFocused ? "text" : "password"}
onFocus={() => setIsMicrosoftTeamsAppIdFocused.on()}
onBlur={() => setIsMicrosoftTeamsAppIdFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
tooltipText="You can find your Client Secret in the Certificates & Secrets section of your Microsoft Teams bot's App Registration."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isMicrosoftTeamsClientSecretFocused ? "text" : "password"}
onFocus={() => setIsMicrosoftTeamsClientSecretFocused.on()}
onBlur={() => setIsMicrosoftTeamsClientSecretFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="botId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Bot ID"
tooltipClassName="You can find the bot ID in your bot's manifest.json file as the `id` field."
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isMicrosoftTeamsBotIdFocused ? "text" : "password"}
onFocus={() => setIsMicrosoftTeamsBotIdFocused.on()}
onBlur={() => setIsMicrosoftTeamsBotIdFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</form>
);
};
@@ -0,0 +1,189 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { BsSlack } from "react-icons/bs";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
FormControl,
Input
} from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { useUpdateServerConfig } from "@app/hooks/api";
import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types";
const getCustomSlackAppCreationUrl = () =>
`https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(
JSON.stringify({
display_information: {
name: "Infisical",
description: "Get real-time Infisical updates in Slack",
background_color: "#c2d62b",
long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack.
- Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice.
- Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent.
- Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects.
By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.`
},
features: {
app_home: {
home_tab_enabled: false,
messages_tab_enabled: false,
messages_tab_read_only_enabled: true
},
bot_user: {
display_name: "Infisical",
always_online: true
}
},
oauth_config: {
redirect_urls: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`],
scopes: {
bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"]
}
},
settings: {
org_deploy_enabled: false,
socket_mode_enabled: false,
token_rotation_enabled: false
}
})
)}`;
const slackFormSchema = z.object({
clientId: z.string(),
clientSecret: z.string()
});
type TSlackForm = z.infer<typeof slackFormSchema>;
type Props = {
adminIntegrationsConfig?: AdminIntegrationsConfig;
};
export const SlackIntegrationForm = ({ adminIntegrationsConfig }: Props) => {
const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig();
const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle();
const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle();
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TSlackForm>({
resolver: zodResolver(slackFormSchema)
});
const onSubmit = async (data: TSlackForm) => {
await updateAdminServerConfig({
slackClientId: data.clientId,
slackClientSecret: data.clientSecret
});
createNotification({
text: "Updated admin slack configuration",
type: "success"
});
};
useEffect(() => {
if (adminIntegrationsConfig) {
setValue("clientId", adminIntegrationsConfig.slack.clientId);
setValue("clientSecret", adminIntegrationsConfig.slack.clientSecret);
}
}, [adminIntegrationsConfig]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="slack-integration" className="data-[state=open]:border-none">
<AccordionTrigger className="flex h-fit w-full justify-start rounded-md border border-mineshaft-500 bg-mineshaft-700 px-4 py-6 text-sm transition-colors data-[state=open]:rounded-b-none">
<div className="text-md group order-1 ml-3 flex items-center gap-2">
<BsSlack className="text-lg group-hover:text-primary-400" />
<div className="text-[15px] font-semibold">Slack Integration</div>
</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
<div className="flex w-full flex-col justify-start rounded-md rounded-t-none border border-t-0 border-mineshaft-500 bg-mineshaft-700 px-4 py-4">
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 1: Create your Infisical Slack App
</div>
<div className="mb-6">
<Button
colorSchema="secondary"
onClick={() => window.open(getCustomSlackAppCreationUrl())}
>
Create Slack App
</Button>
</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-300">
Step 2: Configure your instance-wide settings to enable integration with Slack. Copy
the values from the App Credentials page of your custom Slack App.
</div>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client ID"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isSlackClientIdFocused ? "text" : "password"}
onFocus={() => setIsSlackClientIdFocused.on()}
onBlur={() => setIsSlackClientIdFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
className="w-96"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || ""}
type={isSlackClientSecretFocused ? "text" : "password"}
onFocus={() => setIsSlackClientSecretFocused.on()}
onBlur={() => setIsSlackClientSecretFocused.off()}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</form>
);
};
@@ -1,4 +1,5 @@
import { useState } from "react";
import { BsMicrosoftTeams } from "react-icons/bs";
import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
@@ -6,6 +7,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
type Props = {
@@ -20,9 +22,14 @@ enum WizardSteps {
const PLATFORM_LIST = [
{
icon: faSlack,
icon: <FontAwesomeIcon icon={faSlack} size="lg" />,
platform: WorkflowIntegrationPlatform.SLACK,
title: "Slack"
},
{
icon: <BsMicrosoftTeams className="text-lg" />,
platform: WorkflowIntegrationPlatform.MICROSOFT_TEAMS,
title: "Microsoft Teams"
}
];
@@ -38,7 +45,10 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
<ModalContent title={`Add a ${selectedPlatform ?? "workflow"} integration`} className="my-4">
<ModalContent
title={`Add a ${selectedPlatform?.replace("-", " ").replace(/\b\w/g, (char) => char.toUpperCase()) ?? "workflow"} integration`}
className="my-4"
>
<AnimatePresence mode="wait">
{wizardStep === WizardSteps.SelectPlatform && (
<motion.div
@@ -67,7 +77,7 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => {
}
}}
>
<FontAwesomeIcon icon={icon} size="lg" />
<div>{icon}</div>
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
))}
@@ -86,6 +96,18 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => {
<SlackIntegrationForm onClose={() => onToggle(false)} />
</motion.div>
)}
{wizardStep === WizardSteps.PlatformInputs &&
selectedPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && (
<motion.div
key="microsoft-teams-platform"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<MicrosoftTeamsIntegrationForm onClose={() => onToggle(false)} />
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -1,6 +1,7 @@
import { Modal, ModalContent } from "@app/components/v2";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
type Props = {
@@ -20,6 +21,9 @@ export const IntegrationFormDetails = ({ isOpen, id, onOpenChange, workflowPlatf
{workflowPlatform === WorkflowIntegrationPlatform.SLACK && (
<SlackIntegrationForm id={id} onClose={() => onOpenChange(false)} />
)}
{workflowPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && (
<MicrosoftTeamsIntegrationForm id={id} onClose={() => onOpenChange(false)} />
)}
</ModalContent>
</Modal>
);
@@ -0,0 +1,154 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
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 {
useCreateMicrosoftTeamsIntegration,
useGetMicrosoftTeamsIntegrationById,
useUpdateMicrosoftTeamsIntegration
} from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
type Props = {
id?: string;
onClose: () => void;
};
const microsoftTeamsFormSchema = z.object({
slug: slugSchema({ min: 1, field: "Alias" }),
tenantId: z.string().min(1, { message: "Tenant ID is required" }).trim(),
description: z.string().optional()
});
type TMicrosoftTeamsFormData = z.infer<typeof microsoftTeamsFormSchema>;
export const MicrosoftTeamsIntegrationForm = ({ id, onClose }: Props) => {
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TMicrosoftTeamsFormData>({
resolver: zodResolver(microsoftTeamsFormSchema)
});
const { currentOrg } = useOrganization();
const { data: microsoftTeamsIntegration } = useGetMicrosoftTeamsIntegrationById(id);
const { mutateAsync: createMicrosoftTeamsIntegration } = useCreateMicrosoftTeamsIntegration();
const { mutateAsync: updateMicrosoftTeamsIntegration } = useUpdateMicrosoftTeamsIntegration();
useEffect(() => {
if (microsoftTeamsIntegration) {
setValue("slug", microsoftTeamsIntegration.slug);
setValue("description", microsoftTeamsIntegration.description ?? "");
setValue("tenantId", microsoftTeamsIntegration.tenantId);
}
}, [microsoftTeamsIntegration]);
const handleSlackFormSubmit = async ({
slug,
description,
tenantId
}: TMicrosoftTeamsFormData) => {
if (id && microsoftTeamsIntegration) {
if (!currentOrg) {
return;
}
if (tenantId !== microsoftTeamsIntegration.tenantId) {
createNotification({
text: "Tenant ID cannot be changed",
type: "error"
});
return;
}
await updateMicrosoftTeamsIntegration({
id,
orgId: currentOrg.id,
slug,
description
});
createNotification({
text: "Successfully updated Microsoft Teams integration",
type: "success"
});
onClose();
} else {
await createMicrosoftTeamsIntegration({
orgId: currentOrg.id,
tenantId,
slug,
description
});
onClose();
createNotification({
text: "Successfully created Microsoft Teams integration",
type: "success"
});
}
};
return (
<form onSubmit={handleSubmit(handleSlackFormSubmit)} autoComplete="off">
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl label="Alias" isRequired errorText={error?.message} isError={Boolean(error)}>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
{!microsoftTeamsIntegration && (
<Controller
control={control}
name="tenantId"
render={({ field, fieldState: { error } }) => (
<FormControl
isRequired
label="Tenant ID"
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>
)}
/>
{microsoftTeamsIntegration && (
<FormControl label="Connected Microsoft Teams Tenant">
<Input
value={microsoftTeamsIntegration.tenantId}
isReadOnly
className="bg-white/[0.07]"
/>
</FormControl>
)}
<div className="mt-6 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={!isDirty || isSubmitting}>
{id && microsoftTeamsIntegration ? "Save" : "Create Microsoft Teams Integration"}
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};
@@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Badge,
Button,
DeleteActionModal,
DropdownMenu,
@@ -27,14 +28,31 @@ import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import {
fetchSlackReinstallUrl,
useCheckMicrosoftTeamsIntegrationInstallationStatus,
useDeleteMicrosoftTeamsIntegration,
useDeleteSlackIntegration,
useGetWorkflowIntegrations
} from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import {
WorkflowIntegrationPlatform,
WorkflowIntegrationStatus
} from "@app/hooks/api/workflowIntegrations/types";
import { AddWorkflowIntegrationForm } from "./AddWorkflowIntegrationForm";
import { IntegrationFormDetails } from "./IntegrationFormDetails";
const renderStatus = (status: WorkflowIntegrationStatus) => {
if (status === WorkflowIntegrationStatus.Installed) {
return <Badge variant="success">Installed</Badge>;
}
if (status === WorkflowIntegrationStatus.Pending) {
return <Badge variant="primary">Pending</Badge>;
}
return <Badge variant="danger">Failed</Badge>;
};
export const OrgWorkflowIntegrationTab = withPermission(
() => {
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
@@ -48,6 +66,9 @@ export const OrgWorkflowIntegrationTab = withPermission(
useGetWorkflowIntegrations(currentOrg?.id);
const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration();
const { mutateAsync: deleteMicrosoftTeamsIntegration } = useDeleteMicrosoftTeamsIntegration();
const { mutateAsync: checkMicrosoftTeamsInstallationStatus } =
useCheckMicrosoftTeamsIntegrationInstallationStatus();
const handleRemoveIntegration = async () => {
if (!currentOrg) {
@@ -62,6 +83,13 @@ export const OrgWorkflowIntegrationTab = withPermission(
});
}
if (platform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) {
await deleteMicrosoftTeamsIntegration({
id,
orgId: currentOrg?.id
});
}
handlePopUpClose("removeIntegration");
createNotification({
text: "Successfully deleted integration",
@@ -69,27 +97,37 @@ export const OrgWorkflowIntegrationTab = withPermission(
});
};
const triggerReinstall = async (platform: WorkflowIntegrationPlatform, id: string) => {
if (platform === WorkflowIntegrationPlatform.SLACK) {
try {
const slackReinstallUrl = await fetchSlackReinstallUrl({
id
});
const triggerSlackReinstall = async (id: string) => {
try {
const slackReinstallUrl = await fetchSlackReinstallUrl({
id
});
if (slackReinstallUrl) {
window.location.href = slackReinstallUrl;
}
} catch (err) {
if (axios.isAxiosError(err)) {
createNotification({
text: (err.response?.data as { message: string })?.message,
type: "error"
});
}
if (slackReinstallUrl) {
window.location.href = slackReinstallUrl;
}
} catch (err) {
if (axios.isAxiosError(err)) {
createNotification({
text: (err.response?.data as { message: string })?.message,
type: "error"
});
}
}
};
const triggerMicrosoftTeamsInstallationStatusCheck = async (id: string) => {
await checkMicrosoftTeamsInstallationStatus({
workflowIntegrationId: id,
orgId: currentOrg?.id
});
createNotification({
text: "The Microsoft Teams bot is successfully installed in your Microsoft Teams tenant",
type: "success"
});
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
@@ -117,6 +155,7 @@ export const OrgWorkflowIntegrationTab = withPermission(
<Tr>
<Td>Provider</Td>
<Td>Alias</Td>
<Td>Status</Td>
</Tr>
</THead>
<TBody>
@@ -139,6 +178,7 @@ export const OrgWorkflowIntegrationTab = withPermission(
<div className="ml-2">{workflowIntegration.integration.toUpperCase()}</div>
</Td>
<Td>{workflowIntegration.slug}</Td>
<Td>{renderStatus(workflowIntegration.status)}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
@@ -159,29 +199,56 @@ export const OrgWorkflowIntegrationTab = withPermission(
>
More details
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Create}
an={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
triggerReinstall(
workflowIntegration.integration,
workflowIntegration.id
);
}}
>
Reinstall
</DropdownMenuItem>
)}
</OrgPermissionCan>
{workflowIntegration.integration === WorkflowIntegrationPlatform.SLACK && (
<OrgPermissionCan
I={OrgPermissionActions.Create}
an={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async (e) => {
e.stopPropagation();
await triggerSlackReinstall(workflowIntegration.integration);
}}
>
Reinstall
</DropdownMenuItem>
)}
</OrgPermissionCan>
)}
{workflowIntegration.integration ===
WorkflowIntegrationPlatform.MICROSOFT_TEAMS && (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
an={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async (e) => {
e.stopPropagation();
await triggerMicrosoftTeamsInstallationStatusCheck(
workflowIntegration.id
);
}}
>
Check Installation Status
</DropdownMenuItem>
)}
</OrgPermissionCan>
)}
<OrgPermissionCan
I={OrgPermissionActions.Delete}
an={OrgPermissionSubjects.Settings}
@@ -1,351 +1,133 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { BsMicrosoftTeams, BsSlack } from "react-icons/bs";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
ContentLoader,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
FormControl,
Input,
Select,
SelectItem,
Switch
DeleteActionModal,
Table,
TableContainer,
TBody,
Td,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useGetSlackIntegrationChannels,
useGetSlackIntegrations,
useGetWorkspaceSlackConfig,
useUpdateProjectSlackConfig
} from "@app/hooks/api";
import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteProjectWorkflowIntegration } from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
const formSchema = z.object({
slackIntegrationId: z.string(),
isSecretRequestNotificationEnabled: z.boolean(),
secretRequestChannels: z.string().array(),
isAccessRequestNotificationEnabled: z.boolean(),
accessRequestChannels: z.string().array()
});
import { AddWorkflowIntegrationModal } from "./components/AddWorkflowIntegrationModal";
import { EditWorkflowIntegrationModal } from "./components/EditWorkflowIntegrationModal";
import { MicrosoftTeamsConfigRow } from "./components/MicrosoftTeamsConfigRow";
import { SlackConfigRow } from "./components/SlackConfigRow";
type TSlackConfigForm = z.infer<typeof formSchema>;
export const renderProvider = (integration: WorkflowIntegrationPlatform) => {
if (integration === WorkflowIntegrationPlatform.SLACK) {
return <BsSlack />;
}
if (integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) {
return <BsMicrosoftTeams />;
}
return null;
};
export const WorkflowIntegrationTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"addWorkflowIntegration",
"removeIntegration",
"editIntegration"
] as const);
const { currentWorkspace } = useWorkspace();
const { data: slackConfig, isPending: isSlackConfigLoading } = useGetWorkspaceSlackConfig({
workspaceId: currentWorkspace?.id ?? ""
});
const { data: slackIntegrations } = useGetSlackIntegrations(currentWorkspace?.orgId);
const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectSlackConfig();
const {
control,
watch,
handleSubmit,
setValue,
formState: { isDirty, isSubmitting }
} = useForm<TSlackConfigForm>({
resolver: zodResolver(formSchema),
defaultValues: {
isAccessRequestNotificationEnabled: false,
accessRequestChannels: [],
isSecretRequestNotificationEnabled: false,
secretRequestChannels: []
}
});
const { mutateAsync: deleteIntegration } = useDeleteProjectWorkflowIntegration();
const secretRequestNotifState = watch("isSecretRequestNotificationEnabled");
const selectedSlackIntegrationId = watch("slackIntegrationId");
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId);
const slackChannelIdToName = Object.fromEntries(
(slackChannels || []).map((channel) => [channel.id, channel.name])
);
const sortedSlackChannels = slackChannels?.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
const handleIntegrationSave = async (data: TSlackConfigForm) => {
if (!currentWorkspace) {
const handleRemoveIntegration = async (
integrationType: WorkflowIntegrationPlatform,
integrationId: string
) => {
if (!currentWorkspace.id) {
return;
}
await updateProjectSlackConfig({
workspaceId: currentWorkspace.id,
...data,
accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "),
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ")
await deleteIntegration({
projectId: currentWorkspace?.id ?? "",
integration: integrationType,
integrationId
});
createNotification({
type: "success",
text: "Successfully updated slack integration"
text: `Successfully removed ${integrationType.replace("-", " ").replace(/\b\w/g, (char) => char.toUpperCase())} integration`
});
};
useEffect(() => {
if (slackConfig) {
setValue("slackIntegrationId", slackConfig.slackIntegrationId);
setValue(
"isSecretRequestNotificationEnabled",
slackConfig.isSecretRequestNotificationEnabled
);
setValue(
"isAccessRequestNotificationEnabled",
slackConfig.isAccessRequestNotificationEnabled
);
if (slackChannels) {
setValue(
"secretRequestChannels",
slackConfig.secretRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
setValue(
"accessRequestChannels",
slackConfig.accessRequestChannels
.split(", ")
.filter((channel) => channel in slackChannelIdToName)
);
}
}
}, [slackConfig, slackChannels]);
if (isSlackConfigLoading) {
return <ContentLoader />;
}
return !slackIntegrations?.length ? (
<EmptyState title="You do not have any integrations configured.">
<Link
to="/organization/settings"
search={{
selectedTab: "workflow-integrations"
}}
>
<div className="mt-2 underline decoration-primary-800 underline-offset-4 duration-200 hover:cursor-pointer hover:text-mineshaft-100 hover:decoration-primary-600">
Create one now
</div>
</Link>
</EmptyState>
) : (
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<h2 className="mb-2 flex-1 text-xl font-semibold text-mineshaft-100">Slack Integration</h2>
<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">
This integration allows you to send notifications to your Slack workspace in response to
events in your project.
Connect Infisical to other platforms for notification and workflow integrations.
</p>
<form onSubmit={handleSubmit(handleIntegrationSave)}>
<div className="max-w-md">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<Controller
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl errorText={error?.message} isError={Boolean(error)}>
<Select
{...field}
isDisabled={!isAllowed}
placeholder="None"
onValueChange={onChange}
defaultValue={slackConfig?.slackIntegrationId}
className="w-3/4 bg-mineshaft-600"
>
{slackIntegrations?.map((slackIntegration) => (
<SelectItem
value={slackIntegration.id}
key={`slack-integration-${slackIntegration.id}`}
>
{slackIntegration.slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
control={control}
name="slackIntegrationId"
/>
)}
</ProjectPermissionCan>
</div>
{selectedSlackIntegrationId && (
<>
<h2 className="mb-2 flex-1 text-lg font-semibold text-mineshaft-100">Events</h2>
<Controller
control={control}
name="isSecretRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 mt-3"
>
<Switch
id="secret-approval-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Secret Approval Requests</p>
</Switch>
</FormControl>
);
}}
/>
{secretRequestNotifState && (
<Controller
control={control}
name="secretRequestChannels"
render={({ field: { 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>
)}
/>
)}
<Controller
control={control}
name="isAccessRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
<Switch
id="access-request-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Access Requests</p>
</Switch>
</FormControl>
);
}}
/>
{accessRequestNotifState && (
<Controller
control={control}
name="accessRequestChannels"
render={({ field: { 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>
</FormControl>
)}
/>
)}
<Button
colorSchema="secondary"
className="mt-4"
type="submit"
isDisabled={!isDirty}
isLoading={isSubmitting}
>
Save
</Button>
</>
)}
</form>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>Provider</Td>
<Td>Access Request Notifications</Td>
<Td>Secret Request Notifications</Td>
<Td>Access Request Notifications Destination</Td>
<Td>Secret Request Notifications Destination</Td>
<Td>Actions</Td>
</Tr>
</THead>
<TBody>
<SlackConfigRow handlePopUpOpen={handlePopUpOpen} />
<MicrosoftTeamsConfigRow handlePopUpOpen={handlePopUpOpen} />
</TBody>
</Table>
</TableContainer>
<AddWorkflowIntegrationModal
isOpen={popUp.addWorkflowIntegration.isOpen}
onToggle={(state) => handlePopUpToggle("addWorkflowIntegration", state)}
/>
<DeleteActionModal
isOpen={popUp.removeIntegration.isOpen}
title="Are you sure want to remove this integration?"
onChange={(isOpen) => handlePopUpToggle("removeIntegration", isOpen)}
deleteKey="confirm"
onDeleteApproved={async () => {
await handleRemoveIntegration(
popUp.removeIntegration.data?.integration,
popUp.removeIntegration.data?.integrationId
);
handlePopUpClose("removeIntegration");
}}
/>
<EditWorkflowIntegrationModal
isOpen={popUp.editIntegration.isOpen}
onClose={() => handlePopUpClose("editIntegration")}
integration={popUp.editIntegration.data?.integration}
/>
</div>
);
};
@@ -0,0 +1,154 @@
import { useState } from "react";
import { BsMicrosoftTeams } from "react-icons/bs";
import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { Modal, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetWorkspaceWorkflowIntegrationConfig } from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
};
enum WizardSteps {
SelectPlatform = "select-platform",
PlatformInputs = "platform-inputs"
}
const PLATFORM_LIST = [
{
icon: <FontAwesomeIcon icon={faSlack} size="lg" />,
platform: WorkflowIntegrationPlatform.SLACK,
title: "Slack"
},
{
icon: <BsMicrosoftTeams className="text-lg" />,
platform: WorkflowIntegrationPlatform.MICROSOFT_TEAMS,
title: "Microsoft Teams"
}
];
export const AddWorkflowIntegrationModal = ({ isOpen, onToggle }: Props) => {
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const { currentWorkspace } = useWorkspace();
const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS
});
const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.SLACK
});
const microsoftTeamsConfigured = !!microsoftTeamsConfig;
const slackConfigured = !!slackConfig;
const handleFormReset = (state: boolean = false) => {
onToggle(state);
setWizardStep(WizardSteps.SelectPlatform);
setSelectedPlatform(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
<ModalContent
title={`Add a ${selectedPlatform?.replace("-", " ").replace(/\b\w/g, (char) => char.toUpperCase()) ?? "workflow"} integration`}
className="my-4"
>
<AnimatePresence mode="wait">
{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 workflow integration</div>
<div className="flex items-center space-x-4">
{PLATFORM_LIST.map(({ icon, platform, title }) => {
const isConfigured =
(platform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS &&
microsoftTeamsConfigured) ||
(platform === WorkflowIntegrationPlatform.SLACK && slackConfigured);
return (
<div className="relative">
<div
key={platform}
className={twMerge(
"flex h-32 w-36 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",
isConfigured && "border-primary/50 opacity-60"
)}
role="button"
tabIndex={0}
onClick={() => {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}
}}
>
<div>{icon}</div>
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
{isConfigured && (
<div className="absolute bottom-0 left-0 right-0 z-30 h-full">
<div className="relative h-full">
<div className="absolute bottom-0 right-0 w-full flex-row items-center overflow-hidden whitespace-nowrap rounded-b-md bg-primary px-2 py-0.5 text-center text-xs text-black transition-all duration-300 group-hover:w-0 group-hover:p-0">
Already Configured
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.PlatformInputs &&
selectedPlatform === WorkflowIntegrationPlatform.SLACK && (
<motion.div
key="platform-inputs-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<SlackIntegrationForm onClose={() => handleFormReset(false)} />
</motion.div>
)}
{wizardStep === WizardSteps.PlatformInputs &&
selectedPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && (
<motion.div
key="platform-inputs-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<MicrosoftTeamsIntegrationForm onClose={() => handleFormReset(false)} />
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,29 @@
import { Modal, ModalContent } from "@app/components/v2";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
type Props = {
isOpen?: boolean;
onClose: () => void;
integration: WorkflowIntegrationPlatform;
};
export const EditWorkflowIntegrationModal = ({ isOpen, onClose, integration }: Props) => {
const handleFormReset = () => {
onClose();
};
return (
<Modal isOpen={isOpen} onOpenChange={handleFormReset}>
<ModalContent
title={`Edit ${integration?.replace("-", " ").replace(/\b\w/g, (char) => char.toUpperCase()) ?? "workflow"} integration`}
>
{integration === WorkflowIntegrationPlatform.SLACK && (
<SlackIntegrationForm onClose={handleFormReset} />
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,153 @@
import { BsMicrosoftTeams } from "react-icons/bs";
import { faCheck, faEllipsis, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Spinner,
Td,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context";
import {
useGetMicrosoftTeamsIntegrationTeams,
useGetWorkspaceWorkflowIntegrationConfig
} from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>,
data?: {
integrationId?: string;
integration: WorkflowIntegrationPlatform;
}
) => void;
};
export const MicrosoftTeamsConfigRow = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS
});
const { data: microsoftTeamsChannels, isPending: isMicrosoftTeamsChannelsLoading } =
useGetMicrosoftTeamsIntegrationTeams(microsoftTeamsConfig?.integrationId);
const microsoftTeamsChannelIdToName = Object.fromEntries(
microsoftTeamsChannels?.flatMap((team) =>
team.channels.map((channel) => [channel.channelId, channel.channelName])
) ?? []
);
if (microsoftTeamsConfig?.integration !== WorkflowIntegrationPlatform.MICROSOFT_TEAMS) {
return null;
}
console.log("after");
return (
<Tr>
<Td className="flex max-w-xs items-center overflow-hidden text-ellipsis">
<div className="flex items-center gap-2">
<BsMicrosoftTeams />
Microsoft Teams
</div>
</Td>
<Td>
{microsoftTeamsConfig.isAccessRequestNotificationEnabled ? (
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
) : (
<FontAwesomeIcon icon={faXmark} className="text-red-500" />
)}
</Td>
<Td>
{microsoftTeamsConfig.isSecretRequestNotificationEnabled ? (
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
) : (
<FontAwesomeIcon icon={faXmark} className="text-red-500" />
)}
</Td>
<Td>
{isMicrosoftTeamsChannelsLoading ? (
<Spinner size="xs" />
) : (
<Badge>
{microsoftTeamsConfig.accessRequestChannels?.channelIds
?.map((channel) => microsoftTeamsChannelIdToName[channel])
.join(", ")}
</Badge>
)}
</Td>
<Td>
{isMicrosoftTeamsChannelsLoading ? (
<Spinner size="xs" />
) : (
<Badge>
{microsoftTeamsConfig.secretRequestChannels?.channelIds
?.map((channel) => microsoftTeamsChannelIdToName[channel])
.join(", ")}
</Badge>
)}
</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">
<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", {
integrationId: microsoftTeamsConfig.integrationId,
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS
});
}}
>
Delete
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan I={OrgPermissionActions.Edit} an={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("editIntegration", {
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS
});
}}
>
Edit
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};
@@ -0,0 +1,540 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FormControl,
Input,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext";
import {
useGetMicrosoftTeamsIntegrations,
useGetMicrosoftTeamsIntegrationTeams,
useGetWorkspaceWorkflowIntegrationConfig,
useUpdateProjectWorkflowIntegrationConfig
} from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
const formSchema = z
.object({
microsoftTeamsIntegrationId: z.string(),
isSecretRequestNotificationEnabled: z.boolean(),
isAccessRequestNotificationEnabled: z.boolean(),
secretRequestChannels: z.object({
teamId: z.string(),
channelIds: z.string().array()
}),
accessRequestChannels: z.object({
teamId: z.string(),
channelIds: z.string().array()
})
})
.superRefine((data, ctx) => {
if (data.isSecretRequestNotificationEnabled) {
if (!data.secretRequestChannels.teamId) {
ctx.addIssue({
path: ["secretRequestChannels", "teamId"],
code: z.ZodIssueCode.custom,
message: "Team is required"
});
}
if (!data.secretRequestChannels.channelIds.length) {
ctx.addIssue({
path: ["secretRequestChannels", "channelIds"],
code: z.ZodIssueCode.custom,
message: "At least one channel is required"
});
}
}
if (data.isAccessRequestNotificationEnabled) {
if (!data.accessRequestChannels.teamId) {
ctx.addIssue({
path: ["accessRequestChannels", "teamId"],
code: z.ZodIssueCode.custom,
message: "Team is required"
});
}
if (!data.accessRequestChannels.channelIds.length) {
ctx.addIssue({
path: ["accessRequestChannels", "channelIds"],
code: z.ZodIssueCode.custom,
message: "At least one channel is required"
});
}
}
});
type TSlackConfigForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
};
export const MicrosoftTeamsIntegrationForm = ({ onClose }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS
});
const { data: microsoftTeamsIntegrations } = useGetMicrosoftTeamsIntegrations(
currentWorkspace?.orgId
);
const { mutateAsync: updateProjectMicrosoftTeamsConfig } =
useUpdateProjectWorkflowIntegrationConfig();
const {
control,
watch,
handleSubmit,
setValue,
formState: { isDirty, isSubmitting }
} = useForm<TSlackConfigForm>({
resolver: zodResolver(formSchema),
defaultValues: {
isAccessRequestNotificationEnabled: false,
isSecretRequestNotificationEnabled: false,
accessRequestChannels: {
teamId: "",
channelIds: []
},
secretRequestChannels: {
teamId: "",
channelIds: []
}
}
});
const handleIntegrationSave = async (data: TSlackConfigForm) => {
try {
if (!currentWorkspace) {
return;
}
await updateProjectMicrosoftTeamsConfig({
workspaceId: currentWorkspace.id,
isAccessRequestNotificationEnabled: data.isAccessRequestNotificationEnabled,
isSecretRequestNotificationEnabled: data.isSecretRequestNotificationEnabled,
...(data.isAccessRequestNotificationEnabled && {
accessRequestChannels: data.accessRequestChannels
}),
...(data.isSecretRequestNotificationEnabled && {
secretRequestChannels: data.secretRequestChannels
}),
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS,
integrationId: data.microsoftTeamsIntegrationId
});
createNotification({
type: "success",
text: "Successfully created microsoft teams integration"
});
onClose();
} catch {
createNotification({
type: "error",
text: "Failed to create microsoft teams integration"
});
}
};
const selectedAccessRequestTeamId = watch("accessRequestChannels.teamId");
const selectedSecretRequestTeamId = watch("secretRequestChannels.teamId");
console.log("selectedAccessRequestTeamId", selectedAccessRequestTeamId);
console.log("selectedSecretRequestTeamId", selectedSecretRequestTeamId);
const selectedMicrosoftTeamsIntegrationId = watch("microsoftTeamsIntegrationId");
const accessRequestNotificationsEnabled = watch("isAccessRequestNotificationEnabled");
const secretRequestNotificationsEnabled = watch("isSecretRequestNotificationEnabled");
const {
data: microsoftTeamsIntegrationTeams,
isPending: isLoadingMicrosoftTeamsIntegrationTeams
} = useGetMicrosoftTeamsIntegrationTeams(selectedMicrosoftTeamsIntegrationId);
const sortedMicrosoftTeamsIntegrationTeams = microsoftTeamsIntegrationTeams?.sort((a, b) =>
a.teamName.toLowerCase().localeCompare(b.teamName.toLowerCase())
);
const selectableAccessRequestChannelIds = sortedMicrosoftTeamsIntegrationTeams
?.filter((team) => team.teamId === selectedAccessRequestTeamId)
.map((team) => team.channels)
.flat();
const selectableSecretRequestChannelIds = sortedMicrosoftTeamsIntegrationTeams
?.filter((team) => team.teamId === selectedSecretRequestTeamId)
.map((team) => team.channels)
.flat();
console.log("selectableAccessRequestChannelIds", selectableAccessRequestChannelIds);
console.log("selectableSecretRequestChannelIds", selectableSecretRequestChannelIds);
const channelIdToName = [
...(selectableAccessRequestChannelIds || []),
...(selectableSecretRequestChannelIds || [])
].reduce(
(acc, channel) => {
acc[channel.channelId] = channel.channelName;
return acc;
},
{} as Record<string, string>
);
useEffect(() => {
if (microsoftTeamsConfig) {
setValue("microsoftTeamsIntegrationId", microsoftTeamsConfig.integrationId);
setValue(
"isSecretRequestNotificationEnabled",
microsoftTeamsConfig.isSecretRequestNotificationEnabled
);
setValue(
"isAccessRequestNotificationEnabled",
microsoftTeamsConfig.isAccessRequestNotificationEnabled
);
if (microsoftTeamsConfig.integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) {
if (microsoftTeamsConfig.secretRequestChannels) {
setValue("secretRequestChannels", microsoftTeamsConfig.secretRequestChannels);
setValue("accessRequestChannels", microsoftTeamsConfig.accessRequestChannels);
}
}
}
}, [microsoftTeamsConfig]);
return (
<form onSubmit={handleSubmit(handleIntegrationSave)}>
<div className="flex w-full flex-col justify-start">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<Controller
control={control}
name="microsoftTeamsIntegrationId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Microsoft Teams Integration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
{...field}
isDisabled={!isAllowed}
placeholder="None"
onValueChange={onChange}
className="w-full"
defaultValue={microsoftTeamsConfig?.integrationId}
>
{microsoftTeamsIntegrations?.map((microsoftTeamsIntegration) => (
<SelectItem
value={microsoftTeamsIntegration.id}
key={`microsoft-teams-integration-${microsoftTeamsIntegration.id}`}
>
{microsoftTeamsIntegration.slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
</ProjectPermissionCan>
</div>
{selectedMicrosoftTeamsIntegrationId && (
<>
<h2 className="mb-2 flex-1 text-sm text-mineshaft-400">Configure Events</h2>
<Controller
control={control}
name="isSecretRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 mt-3"
>
<Switch
id="secret-approval-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Secret Approval Requests</p>
</Switch>
</FormControl>
);
}}
/>
{secretRequestNotificationsEnabled && (
<>
<Controller
control={control}
name="secretRequestChannels.teamId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Secret Approval Requests Notifications Team"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
placeholder={
isLoadingMicrosoftTeamsIntegrationTeams ? "Loading..." : "Select a team..."
}
className="w-full"
onValueChange={(value) => onChange(value)}
isLoading={isLoadingMicrosoftTeamsIntegrationTeams}
>
{!isLoadingMicrosoftTeamsIntegrationTeams &&
sortedMicrosoftTeamsIntegrationTeams?.map((team) => (
<SelectItem
key={`secret-requests-microsoft-teams-team-${team.teamId}`}
value={team.teamId}
>
{team.teamName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="secretRequestChannels.channelIds"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Microsoft Teams channels"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger
className={
isLoadingMicrosoftTeamsIntegrationTeams || !selectedSecretRequestTeamId
? "opacity-50"
: ""
}
asChild
disabled={
isLoadingMicrosoftTeamsIntegrationTeams || !selectedSecretRequestTeamId
}
>
{selectedSecretRequestTeamId ? (
<Input
isReadOnly
value={
isLoadingMicrosoftTeamsIntegrationTeams
? "Loading..."
: value
?.filter(Boolean)
.map((entry) => channelIdToName[entry])
.join(", ")
}
className="text-left"
/>
) : (
<Input
isReadOnly
value="Select a team..."
className="py-2 text-left text-sm"
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
maxHeight: "350px",
overflowY: "auto"
}}
side="bottom"
align="start"
>
{selectableSecretRequestChannelIds?.map((channel) => {
const isChecked = value?.includes(channel.channelId);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== channel.channelId)
: [...(value || []), channel.channelId]
);
}}
key={`secret-requests-slack-channel-${channel.channelId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{channel.channelName}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="isAccessRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
<Switch
id="access-request-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Access Requests</p>
</Switch>
</FormControl>
);
}}
/>
{accessRequestNotificationsEnabled && (
<>
<Controller
control={control}
name="accessRequestChannels.teamId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Access Requests Notifications Team"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
placeholder={
isLoadingMicrosoftTeamsIntegrationTeams ? "Loading..." : "Select a team..."
}
className="w-full"
onValueChange={(value) => onChange(value)}
isLoading={isLoadingMicrosoftTeamsIntegrationTeams}
>
{!isLoadingMicrosoftTeamsIntegrationTeams &&
sortedMicrosoftTeamsIntegrationTeams?.map((team) => (
<SelectItem
key={`access-requests-microsoft-teams-team-${team.teamId}`}
value={team.teamId}
>
{team.teamName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="accessRequestChannels.channelIds"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Microsoft Teams channels"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger
className={
isLoadingMicrosoftTeamsIntegrationTeams || !selectedAccessRequestTeamId
? "opacity-50"
: ""
}
asChild
disabled={
isLoadingMicrosoftTeamsIntegrationTeams || !selectedAccessRequestTeamId
}
>
{selectedAccessRequestTeamId ? (
<Input
isReadOnly
value={
isLoadingMicrosoftTeamsIntegrationTeams
? "Loading..."
: value
?.filter(Boolean)
.map((entry) => channelIdToName[entry])
.join(", ")
}
className="text-left"
/>
) : (
<Input
isReadOnly
value="Select a team..."
className="py-2 text-left text-sm"
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
style={{
width: "var(--radix-dropdown-menu-trigger-width)",
maxHeight: "350px",
overflowY: "auto"
}}
side="bottom"
align="start"
>
{selectableAccessRequestChannelIds?.map((channel) => {
const isChecked = value?.includes(channel.channelId);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== channel.channelId)
: [...(value || []), channel.channelId]
);
}}
key={`access-requests-slack-channel-${channel.channelId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{channel.channelName}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
</>
)}
<Button
colorSchema="secondary"
className="mt-4"
type="submit"
isDisabled={!isDirty}
isLoading={isSubmitting}
>
Save
</Button>
</>
)}
</form>
);
};
@@ -0,0 +1,143 @@
import { BsSlack } from "react-icons/bs";
import { faCheck, faEllipsis, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context";
import {
useGetSlackIntegrationChannels,
useGetWorkspaceWorkflowIntegrationConfig
} from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>,
data?: {
integrationId?: string;
integration: WorkflowIntegrationPlatform;
}
) => void;
};
export const SlackConfigRow = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.SLACK
});
const { data: slackChannels } = useGetSlackIntegrationChannels(slackConfig?.integrationId);
const slackChannelIdToName = Object.fromEntries(
(slackChannels || []).map((channel) => [channel.id, channel.name])
);
if (slackConfig?.integration !== WorkflowIntegrationPlatform.SLACK) {
return null;
}
console.log("after slakc");
return (
<Tr>
<Td className="flex max-w-xs items-center overflow-hidden text-ellipsis">
<div className="flex items-center gap-2">
<BsSlack />
Slack
</div>
</Td>
<Td>
{slackConfig.isAccessRequestNotificationEnabled ? (
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
) : (
<FontAwesomeIcon icon={faXmark} className="text-red-500" />
)}
</Td>
<Td>
{slackConfig.isSecretRequestNotificationEnabled ? (
<FontAwesomeIcon icon={faCheck} className="text-green-500" />
) : (
<FontAwesomeIcon icon={faXmark} className="text-red-500" />
)}
</Td>
<Td>
<Badge>
{slackConfig.accessRequestChannels
.split(", ")
.map((channel) => slackChannelIdToName[channel])
.join(", ")}
</Badge>
</Td>
<Td>
<Badge>
{slackConfig.secretRequestChannels
.split(", ")
.map((channel) => slackChannelIdToName[channel])
.join(", ")}
</Badge>
</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">
<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", {
integrationId: slackConfig.integrationId,
integration: WorkflowIntegrationPlatform.SLACK
});
}}
>
Delete
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan I={OrgPermissionActions.Edit} an={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("editIntegration", {
integration: WorkflowIntegrationPlatform.SLACK
});
}}
>
Edit
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};
@@ -0,0 +1,356 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FormControl,
Input,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext";
import {
useGetSlackIntegrationChannels,
useGetWorkflowIntegrations,
useGetWorkspaceWorkflowIntegrationConfig,
useUpdateProjectWorkflowIntegrationConfig
} from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
const formSchema = z.object({
slackIntegrationId: z.string(),
isSecretRequestNotificationEnabled: z.boolean(),
secretRequestChannels: z.string().array(),
isAccessRequestNotificationEnabled: z.boolean(),
accessRequestChannels: z.string().array()
});
type TSlackConfigForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
};
export const SlackIntegrationForm = ({ onClose }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.SLACK
});
const { data: workflowIntegrations } = useGetWorkflowIntegrations(currentWorkspace?.orgId);
const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectWorkflowIntegrationConfig();
const slackIntegrations = workflowIntegrations?.filter(
(integration) => integration.integration === WorkflowIntegrationPlatform.SLACK
);
const {
control,
watch,
handleSubmit,
setValue,
formState: { isDirty, isSubmitting }
} = useForm<TSlackConfigForm>({
resolver: zodResolver(formSchema),
defaultValues: {
isAccessRequestNotificationEnabled: false,
accessRequestChannels: [],
isSecretRequestNotificationEnabled: false,
secretRequestChannels: []
}
});
const handleIntegrationSave = async (data: TSlackConfigForm) => {
try {
if (!currentWorkspace) {
return;
}
await updateProjectSlackConfig({
...data,
workspaceId: currentWorkspace.id,
integration: WorkflowIntegrationPlatform.SLACK,
integrationId: data.slackIntegrationId,
accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "),
secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ")
});
createNotification({
type: "success",
text: "Successfully created slack integration"
});
onClose();
} catch {
createNotification({
type: "error",
text: "Failed to create slack integration"
});
}
};
const secretRequestNotifState = watch("isSecretRequestNotificationEnabled");
const selectedSlackIntegrationId = watch("slackIntegrationId");
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId);
const slackChannelIdToName = Object.fromEntries(
(slackChannels || []).map((channel) => [channel.id, channel.name])
);
const sortedSlackChannels = slackChannels?.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase())
);
useEffect(() => {
if (slackConfig) {
setValue("slackIntegrationId", slackConfig.integrationId);
setValue(
"isSecretRequestNotificationEnabled",
slackConfig.isSecretRequestNotificationEnabled
);
setValue(
"isAccessRequestNotificationEnabled",
slackConfig.isAccessRequestNotificationEnabled
);
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)
);
}
}
}
}, [slackConfig, slackChannels]);
return (
<form onSubmit={handleSubmit(handleIntegrationSave)}>
<div className="flex w-full flex-col justify-start">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<Controller
control={control}
name="slackIntegrationId"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Slack Integration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
{...field}
isDisabled={!isAllowed}
placeholder="None"
onValueChange={(val) => {
console.log("Selecting ", val);
onChange(val);
}}
className="w-full"
defaultValue={slackConfig?.integrationId}
>
{slackIntegrations?.map((slackIntegration) => (
<SelectItem
value={slackIntegration.id}
key={`slack-integration-${slackIntegration.id}`}
>
{slackIntegration.slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
</ProjectPermissionCan>
</div>
{selectedSlackIntegrationId && (
<>
<h2 className="mb-2 flex-1 text-sm text-mineshaft-400">Configure Events</h2>
<Controller
control={control}
name="isSecretRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
className="mb-2 mt-3"
>
<Switch
id="secret-approval-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Secret Approval Requests</p>
</Switch>
</FormControl>
);
}}
/>
{secretRequestNotifState && (
<Controller
control={control}
name="secretRequestChannels"
render={({ field: { 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>
)}
/>
)}
<Controller
control={control}
name="isAccessRequestNotificationEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-2">
<Switch
id="access-request-notification"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Access Requests</p>
</Switch>
</FormControl>
);
}}
/>
{accessRequestNotifState && (
<Controller
control={control}
name="accessRequestChannels"
render={({ field: { 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>
</FormControl>
)}
/>
)}
<Button
colorSchema="secondary"
className="mt-4"
type="submit"
isDisabled={!isDirty}
isLoading={isSubmitting}
>
Save
</Button>
</>
)}
</form>
);
};