Merge pull request #3486 from Infisical/daniel/ms-teams-integration

feat(workflow-integrations): microsoft teams
This commit is contained in:
Daniel Hougaard
2025-05-02 00:46:19 +04:00
committed by GitHub
104 changed files with 6874 additions and 1013 deletions
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ContentLoader } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useOrganization } from "@app/context";
import { useCreateMicrosoftTeamsIntegration } from "@app/hooks/api";
const stateSchema = z.object({
redirectUri: z.string(),
tenantId: z.string(),
slug: z.string(),
description: z.string().optional(),
csrfToken: z.string(),
clientId: z.string()
});
export const OAuthCallbackPage = () => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const [isReady, setIsReady] = useState(false);
const search = useSearch({
from: ROUTE_PATHS.Organization.Settings.OauthCallbackPage.id
});
const createMicrosoftTeamsWorkflowIntegration = useCreateMicrosoftTeamsIntegration();
const { state: rawState, code } = search;
const state = stateSchema.parse(rawState);
const clearState = () => {
if (state.csrfToken !== localStorage.getItem("latestCSRFToken")) {
throw new Error("Invalid CSRF token");
}
localStorage.removeItem("latestCSRFToken");
};
const handleMicrosoftTeams = useCallback(async () => {
clearState();
if (!code) {
throw new Error("No code provided");
}
await createMicrosoftTeamsWorkflowIntegration.mutateAsync({
orgId: currentOrg.id,
tenantId: state.tenantId,
code,
slug: state.slug,
description: state.description ?? "",
redirectUri: state.redirectUri
});
navigate({
to: ROUTE_PATHS.Organization.SettingsPage.path
});
}, []);
// Ensure that the localstorage is ready for use, to avoid the form data being malformed
useEffect(() => {
if (!isReady) {
setIsReady(!!localStorage.length);
}
}, [localStorage.length]);
useEffect(() => {
if (!isReady) return;
(async () => {
try {
await handleMicrosoftTeams();
createNotification({
text: "Successfully created Microsoft Teams workflow integration",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create Microsoft Teams workflow integration",
type: "error"
});
}
})();
}, [isReady]);
return (
<div className="flex h-full w-full items-center justify-center">
<ContentLoader text="Please wait! Authentication in process." />
</div>
);
};
@@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { OAuthCallbackPage } from "./OauthCallbackPage";
const SettingsOAuthCallbackPageQueryParamsSchema = z.object({
state: z
.object({
clientId: z.string(),
tenantId: z.string(),
slug: z.string(),
description: z.string().optional(),
redirectUri: z.string(),
csrfToken: z.string()
})
.nullable()
.catch(null),
code: z.string().catch("")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/settings/oauth/callback"
)({
component: OAuthCallbackPage,
validateSearch: zodValidator(SettingsOAuthCallbackPageQueryParamsSchema)
});
@@ -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,192 @@
import crypto from "crypto";
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 {
useGetMicrosoftTeamsClientId,
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()
.uuid("Tenant ID must be a valid UUID"),
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: updateMicrosoftTeamsIntegration } = useUpdateMicrosoftTeamsIntegration();
const { data: microsoftTeamsClientId } = useGetMicrosoftTeamsClientId();
useEffect(() => {
if (microsoftTeamsIntegration) {
setValue("slug", microsoftTeamsIntegration.slug);
setValue("description", microsoftTeamsIntegration.description ?? "");
setValue("tenantId", microsoftTeamsIntegration.tenantId);
}
}, [microsoftTeamsIntegration]);
const handleMicrosoftTeamsFormSubmit = async ({
slug,
description,
tenantId
}: TMicrosoftTeamsFormData) => {
if (!microsoftTeamsClientId) {
createNotification({
text: "Microsoft Teams client ID is not set. Please contact your instance administrator.",
type: "error"
});
return;
}
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 {
const csrfToken = crypto.randomBytes(32).toString("hex");
localStorage.setItem("latestCSRFToken", csrfToken);
const state = {
redirectUri: `${window.location.origin}/organization/settings/oauth/callback`,
tenantId,
slug,
description,
csrfToken,
clientId: microsoftTeamsClientId.clientId
};
const url = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?
client_id=${microsoftTeamsClientId.clientId}
&redirect_uri=${state.redirectUri}
&response_type=code
&response_mode=query
&scope=https://graph.microsoft.com/.default
&state=${encodeURIComponent(JSON.stringify(state))}
&prompt=consent
&admin_consent=true`;
window.location.href = url;
}
};
return (
<form onSubmit={handleSubmit(handleMicrosoftTeamsFormSubmit)} autoComplete="off">
<div className="mb-4 text-xs text-mineshaft-200">
For seamless installations, ensure that the Infisical bot is already installed in your
Microsoft Teams tenant. For more information, please refer to the{" "}
<a
className="text-primary-500"
href="https://infisical.com/docs/documentation/platform/workflow-integrations/microsoft-teams-integration"
target="_blank"
rel="noopener noreferrer"
>
Microsoft Teams Workflow Integration Documentation
</a>
, which will guide you through the download and installation process.
</div>
<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>
);
};
@@ -1,5 +1,5 @@
import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { faEllipsis, faGear, faPlus } from "@fortawesome/free-solid-svg-icons";
import { BsMicrosoftTeams, BsSlack } from "react-icons/bs";
import { faEllipsis, faGear, faInfoCircle, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import axios from "axios";
import { twMerge } from "tailwind-merge";
@@ -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,
@@ -20,6 +21,7 @@ import {
TBody,
Td,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
@@ -27,14 +29,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 +67,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 +84,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 +98,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 +156,7 @@ export const OrgWorkflowIntegrationTab = withPermission(
<Tr>
<Td>Provider</Td>
<Td>Alias</Td>
<Td>Status</Td>
</Tr>
</THead>
<TBody>
@@ -134,11 +174,23 @@ export const OrgWorkflowIntegrationTab = withPermission(
)}
{workflowIntegrations?.map((workflowIntegration) => (
<Tr key={workflowIntegration.id}>
<Td className="flex max-w-xs items-center overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
<FontAwesomeIcon icon={faSlack} />
<div className="ml-2">{workflowIntegration.integration.toUpperCase()}</div>
<Td className="flex max-w-xs items-center gap-2 overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{workflowIntegration.integration === WorkflowIntegrationPlatform.SLACK ? (
<BsSlack />
) : (
<BsMicrosoftTeams />
)}
<div className="capitalize">
{workflowIntegration.integration.replaceAll("-", " ")}
</div>
{workflowIntegration.description && (
<Tooltip content={workflowIntegration.description}>
<FontAwesomeIcon icon={faInfoCircle} className="text-gray-400" size="sm" />
</Tooltip>
)}
</Td>
<Td>{workflowIntegration.slug}</Td>
<Td>{renderStatus(workflowIntegration.status)}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
@@ -159,29 +211,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.id);
}}
>
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}
@@ -11,7 +11,7 @@ const SettingsPageQueryParams = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/settings"
"/_authenticate/_inject-org-details/_org-layout/organization/settings/"
)({
component: SettingsPage,
validateSearch: zodValidator(SettingsPageQueryParams),