Fix merge conflicts

This commit is contained in:
Tuan Dang
2025-05-02 13:06:18 -07:00
233 changed files with 8799 additions and 1213 deletions
@@ -235,7 +235,7 @@ export const OverviewPage = () => {
Default organization
</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select the default organization you want to set for SAML/LDAP/OIDC based
Select the default organization you want to set for SAML/LDAP/OIDC/Github
logins. When selected, user logins will be automatically scoped to the
selected organization.
</div>
@@ -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,183 @@
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-2 max-w-lg text-sm text-mineshaft-300">
Step 1: Create and configure Microsoft Teams bot and Azure Resources. Please refer
to the documentation below for more information.
</div>
<div className="mb-6">
<a
href="https://infisical.com/docs/documentation/platform/workflow-integrations/microsoft-teams-integration"
target="_blank"
rel="noopener noreferrer"
>
<Button colorSchema="secondary">Documentation</Button>
</a>
</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="Microsoft Teams App ID"
tooltipText="You can find the Microsoft Teams App ID in the overview of your Microsoft Teams App inside the Microsoft Teams Developer Portal."
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>
);
};
@@ -13,6 +13,7 @@ export const SignupSsoPage = () => {
const { t } = useTranslation();
const search = useSearch({ from: ROUTE_PATHS.Auth.SignUpSsoPage.id });
const token = search.token as string;
const defaultOrgAllowed = search.defaultOrgAllowed as boolean | undefined;
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
@@ -57,6 +58,7 @@ export const SignupSsoPage = () => {
password={password}
setPassword={setPassword}
providerAuthToken={token}
forceDefaultOrg={defaultOrgAllowed}
/>
);
default:
@@ -30,6 +30,7 @@ type Props = {
name: string;
providerOrganizationName: string;
providerAuthToken?: string;
forceDefaultOrg?: boolean;
};
/**
@@ -51,7 +52,8 @@ export const UserInfoSSOStep = ({
providerOrganizationName,
password,
setPassword,
providerAuthToken
providerAuthToken,
forceDefaultOrg
}: Props) => {
const [nameError, setNameError] = useState(false);
const [organizationName, setOrganizationName] = useState("");
@@ -84,7 +86,7 @@ export const UserInfoSSOStep = ({
} else {
setNameError(false);
}
if (!organizationName) {
if (!organizationName && !forceDefaultOrg) {
setOrganizationNameError(true);
errorCheck = true;
} else {
@@ -160,7 +162,8 @@ export const UserInfoSSOStep = ({
salt: result.salt,
verifier: result.verifier,
organizationName,
attributionSource
attributionSource,
useDefaultOrg: forceDefaultOrg
});
// unset signup JWT token and set JWT token
@@ -267,7 +270,7 @@ export const UserInfoSSOStep = ({
</p>
)}
</div>
{providerOrganizationName === undefined && (
{!forceDefaultOrg && providerOrganizationName === undefined && (
<div className="relative z-0 flex w-full min-w-[20rem] flex-col items-center justify-end rounded-lg py-2 lg:w-1/6">
<p className="mb-1 ml-1 w-full text-left text-sm font-medium text-bunker-300">
Organization Name
@@ -279,7 +282,7 @@ export const UserInfoSSOStep = ({
isRequired
className="h-12"
maxLength={64}
disabled
isDisabled={forceDefaultOrg}
/>
{organizationNameError && (
<p className="ml-1 mt-1 w-full text-left text-xs text-red-600">
@@ -5,7 +5,8 @@ import { z } from "zod";
import { SignupSsoPage } from "./SignUpSsoPage";
const SignupSSOPageQueryParamsSchema = z.object({
token: z.string()
token: z.string(),
defaultOrgAllowed: z.boolean().optional()
});
export const Route = createFileRoute("/_restrict-login-signup/signup/sso")({
@@ -97,7 +97,11 @@ export const Route = createFileRoute("/_restrict-login-signup")({
}
if (!data.organizationId) {
if (location.pathname.endsWith("select-organization")) return;
if (
location.pathname.endsWith("select-organization") ||
location.pathname.endsWith("verify-email")
)
return;
throw redirect({ to: "/login/select-organization" });
}
throw redirect({
@@ -18,6 +18,7 @@ import { CamundaConnectionForm } from "./CamundaConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { HCVaultConnectionForm } from "./HCVaultConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { LdapConnectionForm } from "./LdapConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
@@ -94,6 +95,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <WindmillConnectionForm onSubmit={onSubmit} />;
case AppConnection.Auth0:
return <Auth0ConnectionForm onSubmit={onSubmit} />;
case AppConnection.HCVault:
return <HCVaultConnectionForm onSubmit={onSubmit} />;
case AppConnection.LDAP:
return <LdapConnectionForm onSubmit={onSubmit} />;
case AppConnection.TeamCity:
@@ -164,6 +167,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <WindmillConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Auth0:
return <Auth0ConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.HCVault:
return <HCVaultConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.LDAP:
return <LdapConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.TeamCity:
@@ -0,0 +1,224 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { HCVaultConnectionMethod, THCVaultConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: THCVaultConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.HCVault)
});
const InstanceUrlSchema = z
.string()
.trim()
.min(1, "Instance URL required")
.url("Invalid Instance URL");
const NamespaceSchema = z.string().trim().optional();
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(HCVaultConnectionMethod.AccessToken),
credentials: z.object({
instanceUrl: InstanceUrlSchema,
namespace: NamespaceSchema,
accessToken: z.string().trim().min(1, "Access Token required")
})
}),
rootSchema.extend({
method: z.literal(HCVaultConnectionMethod.AppRole),
credentials: z.object({
instanceUrl: InstanceUrlSchema,
namespace: NamespaceSchema,
roleId: z.string().trim().min(1, "Role ID required"),
secretId: z.string().trim().min(1, "Secret ID required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const HCVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.HCVault,
method: HCVaultConnectionMethod.AppRole
}
});
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method");
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.HCVault].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(HCVaultConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
{method === HCVaultConnectionMethod.AppRole ? " (Recommended)" : ""}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.instanceUrl"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Instance URL"
tooltipClassName="max-w-sm"
tooltipText="The URL at which your Hashicorp Vault instance is hosted."
>
<Input {...field} placeholder="https://vault.example.com" />
</FormControl>
)}
/>
<Controller
name="credentials.namespace"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Namespace"
isOptional
tooltipClassName="max-w-sm"
tooltipText="On self-hosted and enterprise clusters there may not be namespaces."
>
<Input {...field} placeholder="admin" />
</FormControl>
)}
/>
{selectedMethod === HCVaultConnectionMethod.AccessToken ? (
<Controller
name="credentials.accessToken"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Token"
>
<SecretInput
{...field}
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
/>
</FormControl>
)}
/>
) : (
<>
<Controller
name="credentials.roleId"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Role ID"
>
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" />
</FormControl>
)}
/>
<Controller
name="credentials.secretId"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Secret ID"
>
<SecretInput
{...field}
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
/>
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Hashicorp Vault"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -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),
@@ -0,0 +1,14 @@
import { THCVaultSync } from "@app/hooks/api/secretSyncs/types/hc-vault-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: THCVaultSync;
};
export const HCVaultSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -8,6 +8,7 @@ import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol";
import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HCVaultSyncDestinationCol } from "./HCVaultSyncDestinationCol";
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol";
import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol";
@@ -44,6 +45,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <VercelSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Windmill:
return <WindmillSyncDestinationCol secretSync={secretSync} />;
case SecretSync.HCVault:
return <HCVaultSyncDestinationCol secretSync={secretSync} />;
case SecretSync.TeamCity:
return <TeamCitySyncDestinationCol secretSync={secretSync} />;
default:
@@ -94,6 +94,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.workspace;
secondaryText = destinationConfig.path;
break;
case SecretSync.HCVault:
primaryText = destinationConfig.mount;
secondaryText = destinationConfig.path;
break;
case SecretSync.TeamCity:
primaryText = destinationConfig.project;
secondaryText = destinationConfig.buildConfig;
@@ -0,0 +1,19 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { THCVaultSync } from "@app/hooks/api/secretSyncs/types/hc-vault-sync";
type Props = {
secretSync: THCVaultSync;
};
export const HCVaultSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { path, mount }
} = secretSync;
return (
<>
<GenericFieldLabel label="Secrets Engine Mount">{mount}</GenericFieldLabel>
<GenericFieldLabel label="Path">{path}</GenericFieldLabel>
</>
);
};
@@ -18,6 +18,7 @@ import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection";
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection";
import { HCVaultSyncDestinationSection } from "./HCVaultSyncDestinationSection";
import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection";
import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection";
import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection";
@@ -35,7 +36,7 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
const app = APP_CONNECTION_MAP[connection.app].name;
let DestinationComponents: ReactNode;
switch (secretSync.destination) {
switch (destination) {
case SecretSync.AWSParameterStore:
DestinationComponents = <AwsParameterStoreSyncDestinationSection secretSync={secretSync} />;
break;
@@ -74,6 +75,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.Windmill:
DestinationComponents = <WindmillSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.HCVault:
DestinationComponents = <HCVaultSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.TeamCity:
DestinationComponents = <TeamCitySyncDestinationSection secretSync={secretSync} />;
break;
@@ -52,6 +52,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.Camunda:
case SecretSync.Vercel:
case SecretSync.Windmill:
case SecretSync.HCVault:
case SecretSync.TeamCity:
AdditionalSyncOptionsComponent = null;
break;
@@ -1,351 +1,163 @@
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 { faGear, 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,
DeleteActionModal,
EmptyState,
FormControl,
Input,
Select,
SelectItem,
Switch
Table,
TableContainer,
TBody,
Td,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useGetSlackIntegrationChannels,
useGetSlackIntegrations,
useGetWorkspaceSlackConfig,
useUpdateProjectSlackConfig
useDeleteProjectWorkflowIntegration,
useGetWorkspaceWorkflowIntegrationConfig
} 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 { data: slackConfig, isPending: isSlackConfigLoading } =
useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.SLACK
});
const {
control,
watch,
handleSubmit,
setValue,
formState: { isDirty, isSubmitting }
} = useForm<TSlackConfigForm>({
resolver: zodResolver(formSchema),
defaultValues: {
isAccessRequestNotificationEnabled: false,
accessRequestChannels: [],
isSecretRequestNotificationEnabled: false,
secretRequestChannels: []
}
});
const { data: microsoftTeamsConfig, isPending: isMicrosoftTeamsConfigLoading } =
useGetWorkspaceWorkflowIntegrationConfig({
workspaceId: currentWorkspace?.id ?? "",
integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS
});
const secretRequestNotifState = watch("isSecretRequestNotificationEnabled");
const selectedSlackIntegrationId = watch("slackIntegrationId");
const accessRequestNotifState = watch("isAccessRequestNotificationEnabled");
const { mutateAsync: deleteIntegration } = useDeleteProjectWorkflowIntegration();
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"
<TableContainer>
{!!slackConfig || !!microsoftTeamsConfig ? (
<Table>
<THead>
<Tr>
<Td>Provider</Td>
<Td>Access Request Notifications Destination</Td>
<Td>Secret Request Notifications Destination</Td>
<Td />
</Tr>
</THead>
<TBody>
<SlackConfigRow
handlePopUpOpen={handlePopUpOpen}
isSlackConfigLoading={isSlackConfigLoading}
slackConfig={slackConfig}
/>
)}
</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>
);
}}
<MicrosoftTeamsConfigRow
handlePopUpOpen={handlePopUpOpen}
isMicrosoftTeamsConfigLoading={isMicrosoftTeamsConfigLoading}
microsoftTeamsConfig={microsoftTeamsConfig}
/>
</TBody>
</Table>
) : (
<div className="flex h-full w-full items-center justify-center">
<EmptyState
title="No project workflow integrations configured. Add a new workflow integration to get started"
icon={faGear}
/>
{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>
</>
</div>
)}
</form>
</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,153 @@
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" key={platform}>
<div
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">
Already Configured
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.PlatformInputs &&
selectedPlatform === WorkflowIntegrationPlatform.SLACK && (
<motion.div
key="platform-inputs-step-slack"
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-ms-teams"
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,33 @@
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 = {
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} />
)}
{integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && (
<MicrosoftTeamsIntegrationForm onClose={handleFormReset} />
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,148 @@
/* eslint-disable no-nested-ternary */
import { BsMicrosoftTeams } from "react-icons/bs";
import { faEllipsis } 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 } from "@app/context";
import { useGetMicrosoftTeamsIntegrationTeams } from "@app/hooks/api";
import {
ProjectWorkflowIntegrationConfig,
WorkflowIntegrationPlatform
} from "@app/hooks/api/workflowIntegrations/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
microsoftTeamsConfig?: ProjectWorkflowIntegrationConfig | null;
isMicrosoftTeamsConfigLoading: boolean;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>,
data?: {
integrationId?: string;
integration: WorkflowIntegrationPlatform;
}
) => void;
};
export const MicrosoftTeamsConfigRow = ({
handlePopUpOpen,
isMicrosoftTeamsConfigLoading,
microsoftTeamsConfig
}: Props) => {
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;
}
const isLoadingConfig = isMicrosoftTeamsChannelsLoading || isMicrosoftTeamsConfigLoading;
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 &&
!isLoadingConfig &&
microsoftTeamsConfig.accessRequestChannels?.channelIds?.length > 0 ? (
<Badge>
{microsoftTeamsConfig.accessRequestChannels.channelIds
.map((channel) => microsoftTeamsChannelIdToName[channel])
.join(", ")}
</Badge>
) : isLoadingConfig ? (
<Spinner size="xs" />
) : (
<Badge variant="danger">Disabled</Badge>
)}
</Td>
<Td>
{microsoftTeamsConfig.isSecretRequestNotificationEnabled &&
!isLoadingConfig &&
microsoftTeamsConfig.secretRequestChannels?.channelIds?.length > 0 ? (
<Badge>
{microsoftTeamsConfig.secretRequestChannels.channelIds
.map((channel) => microsoftTeamsChannelIdToName[channel])
.join(", ")}
</Badge>
) : isLoadingConfig ? (
<Spinner size="xs" />
) : (
<Badge variant="danger">Disabled</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.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>
<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>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};
@@ -0,0 +1,556 @@
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()
})
.optional(),
accessRequestChannels: z
.object({
teamId: z.string(),
channelIds: z.string().array()
})
.optional()
})
.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 TMicrosoftTeamsConfigForm = 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<TMicrosoftTeamsConfigForm>({
resolver: zodResolver(formSchema),
defaultValues: {
isAccessRequestNotificationEnabled: false,
isSecretRequestNotificationEnabled: false,
accessRequestChannels: {
teamId: "",
channelIds: []
},
secretRequestChannels: {
teamId: "",
channelIds: []
}
}
});
const handleIntegrationSave = async (data: TMicrosoftTeamsConfigForm) => {
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");
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();
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) {
if (Object.entries(microsoftTeamsConfig.accessRequestChannels).length) {
setValue("accessRequestChannels", microsoftTeamsConfig.accessRequestChannels);
}
if (Object.entries(microsoftTeamsConfig.secretRequestChannels).length) {
setValue("secretRequestChannels", microsoftTeamsConfig.secretRequestChannels);
}
}
}
}
}, [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={(v) => {
onChange(v);
setValue("isAccessRequestNotificationEnabled", false);
setValue("isSecretRequestNotificationEnabled", false);
setValue("accessRequestChannels", {
teamId: "",
channelIds: []
});
setValue("secretRequestChannels", {
teamId: "",
channelIds: []
});
}}
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-microsoft-teams-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"
disabled={!isDirty}
isDisabled={!isDirty}
isLoading={isSubmitting}
>
Save
</Button>
</>
)}
</form>
);
};
@@ -0,0 +1,145 @@
/* eslint-disable no-nested-ternary */
import { BsSlack } from "react-icons/bs";
import { faEllipsis } 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 } from "@app/context";
import { useGetSlackIntegrationChannels } from "@app/hooks/api";
import {
ProjectWorkflowIntegrationConfig,
WorkflowIntegrationPlatform
} from "@app/hooks/api/workflowIntegrations/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
slackConfig?: ProjectWorkflowIntegrationConfig | null;
isSlackConfigLoading: boolean;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>,
data?: {
integrationId?: string;
integration: WorkflowIntegrationPlatform;
}
) => void;
};
export const SlackConfigRow = ({ handlePopUpOpen, isSlackConfigLoading, slackConfig }: Props) => {
const { data: slackChannels, isPending: isSlackChannelsLoading } = useGetSlackIntegrationChannels(
slackConfig?.integrationId
);
const slackChannelIdToName = Object.fromEntries(
(slackChannels || []).map((channel) => [channel.id, channel.name])
);
if (slackConfig?.integration !== WorkflowIntegrationPlatform.SLACK) {
return null;
}
const isLoadingConfig = isSlackChannelsLoading || isSlackConfigLoading;
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 &&
!isLoadingConfig &&
slackConfig.accessRequestChannels.length > 0 ? (
<Badge>
{slackConfig.accessRequestChannels
.split(", ")
.map((channel) => slackChannelIdToName[channel])
.join(", ")}
</Badge>
) : isLoadingConfig ? (
<Spinner size="xs" />
) : (
<Badge variant="danger">Disabled</Badge>
)}
</Td>
<Td>
{slackConfig.isSecretRequestNotificationEnabled &&
!isLoadingConfig &&
slackConfig.secretRequestChannels.length > 0 ? (
<Badge>
{slackConfig.secretRequestChannels
.split(", ")
.map((channel) => slackChannelIdToName[channel])
.join(", ")}
</Badge>
) : isLoadingConfig ? (
<Spinner size="xs" />
) : (
<Badge variant="danger">Disabled</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.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>
<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>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
};
@@ -0,0 +1,355 @@
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) => {
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>
);
};