feat: renamed old frontend and fixed some minor bugs

This commit is contained in:
=
2025-01-06 14:56:49 +05:30
parent 8c902d7699
commit 54377a44d3
2577 changed files with 35240 additions and 35116 deletions
@@ -0,0 +1,24 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { OrgTabGroup } from "./components";
export const SettingsPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 py-6 text-white">
<div className="w-full max-w-7xl px-6">
<div className="mb-4">
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
</div>
<OrgTabGroup />
</div>
</div>
</>
);
};
@@ -0,0 +1,97 @@
import {
faArrowUpRightFromSquare,
faBookOpen,
faPlus,
faWrench
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { AddAppConnectionModal, AppConnectionsTable } from "./components";
export const AppConnectionsTab = withPermission(
() => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addConnection"] as const);
// TODO: remove once live
if (!subscription?.appConnections)
return (
<div className="m-auto mt-40 flex w-full max-w-2xl flex-col items-center rounded-md bg-mineshaft-800 px-2 pt-4 text-bunker-300">
<FontAwesomeIcon icon={faWrench} size="2xl" />
<div className="flex flex-col items-center py-4">
<div className="text-lg text-mineshaft-200">
App Connections are currently unavailable.
</div>
<span className="text-mineshaft-300">Check back soon.</span>
</div>
</div>
);
return (
<div>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center">
<div>
<div className="flex items-start gap-1">
<p className="text-xl font-semibold text-mineshaft-100">App Connections</p>
<a
href="https://infisical.com/docs/integrations/app-connections/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="ml-1 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm text-bunker-300">
Create and configure connections with third-party apps for re-use across Infisical
projects
</p>
</div>
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("addConnection");
}}
isDisabled={!isAllowed}
className="ml-auto"
>
Add Connection
</Button>
)}
</OrgPermissionCan>
</div>
<AppConnectionsTable />
<AddAppConnectionModal
isOpen={popUp.addConnection.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addConnection", isOpen)}
/>
</div>
</div>
);
},
{
action: OrgPermissionActions.Read,
subject: OrgPermissionSubjects.AppConnections
}
);
@@ -0,0 +1,47 @@
import { useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { TAppConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { AppConnectionForm } from "./AppConnectionForm";
import { AppConnectionsSelect } from "./AppConnectionList";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onComplete: (appConnection: TAppConnection) => void;
};
const Content = ({ onComplete }: ContentProps) => {
const [selectedApp, setSelectedApp] = useState<AppConnection | null>(null);
if (selectedApp) {
return (
<AppConnectionForm
onComplete={onComplete}
onBack={() => setSelectedApp(null)}
app={selectedApp}
/>
);
}
return <AppConnectionsSelect onSelect={setSelectedApp} />;
};
export const AddAppConnectionModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Add Connection"
subTitle="Select a third-party app to connect to."
>
<Content onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,117 @@
import { createNotification } from "@app/components/notifications";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import {
TAppConnection,
useCreateAppConnection,
useUpdateAppConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { DiscriminativePick } from "@app/types";
import { AppConnectionHeader } from "../AppConnectionHeader";
import { AwsConnectionForm } from "./AwsConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
type FormProps = {
onComplete: (appConnection: TAppConnection) => void;
} & ({ appConnection: TAppConnection } | { app: AppConnection });
type CreateFormProps = FormProps & { app: AppConnection };
type UpdateFormProps = FormProps & {
appConnection: TAppConnection;
};
const CreateForm = ({ app, onComplete }: CreateFormProps) => {
const createAppConnection = useCreateAppConnection();
const { name: appName } = APP_CONNECTION_MAP[app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
) => {
try {
const connection = await createAppConnection.mutateAsync(formData);
createNotification({
text: `Successfully added ${appName} Connection`,
type: "success"
});
onComplete(connection);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to add ${appName} Connection`,
text: err.message,
type: "error"
});
}
};
switch (app) {
case AppConnection.AWS:
return <AwsConnectionForm onSubmit={onSubmit} />;
case AppConnection.GitHub:
return <GitHubConnectionForm />;
default:
throw new Error(`Unhandled App ${app}`);
}
};
const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
const updateAppConnection = useUpdateAppConnection();
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const onSubmit = async (
formData: DiscriminativePick<TAppConnection, "method" | "name" | "app" | "credentials">
) => {
try {
const connection = await updateAppConnection.mutateAsync({
connectionId: appConnection.id,
...formData
});
createNotification({
text: `Successfully updated ${appName} Connection`,
type: "success"
});
onComplete(connection);
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${appName} Connection`,
text: err.message,
type: "error"
});
}
};
switch (appConnection.app) {
case AppConnection.AWS:
return <AwsConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
case AppConnection.GitHub:
return <GitHubConnectionForm appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
};
type Props = { onBack?: () => void } & Pick<FormProps, "onComplete"> &
(
| { app: AppConnection; appConnection?: undefined }
| { app?: undefined; appConnection: TAppConnection }
);
export const AppConnectionForm = ({ onBack, ...props }: Props) => {
const { app, appConnection } = props;
return (
<div>
<AppConnectionHeader
isConnected={Boolean(appConnection)}
app={appConnection?.app ?? app!}
onBack={onBack}
/>
{appConnection ? (
<UpdateForm {...props} appConnection={appConnection} />
) : (
<CreateForm {...props} app={app} />
)}
</div>
);
};
@@ -0,0 +1,187 @@
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 { AwsConnectionMethod, TAwsConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TAwsConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.AWS)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(AwsConnectionMethod.AssumeRole),
credentials: z.object({
roleArn: z.string().trim().min(1, "Role ARN required")
})
}),
rootSchema.extend({
method: z.literal(AwsConnectionMethod.AccessKey),
credentials: z.object({
accessKeyId: z.string().trim().min(1, "Access Key ID required"),
secretAccessKey: z.string().trim().min(1, "Secret Access Key required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const AwsConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.AWS,
method: AwsConnectionMethod.AssumeRole
}
});
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.AWS].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(AwsConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
{method === AwsConnectionMethod.AssumeRole ? " (Recommended)" : ""}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
{selectedMethod === AwsConnectionMethod.AssumeRole ? (
<Controller
name="credentials.roleArn"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Role ARN"
className="group"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
) : (
<>
<Controller
name="credentials.accessKeyId"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Key ID"
>
<Input
placeholder={"*".repeat(20)}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<Controller
name="credentials.secretAccessKey"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Secret Access Key"
className="group"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</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 AWS"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,42 @@
import { useFormContext } from "react-hook-form";
import { z } from "zod";
import { FormControl, Input, TextArea } from "@app/components/v2";
import { slugSchema } from "@app/lib/schemas";
export const genericAppConnectionFieldsSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish()
});
export const GenericAppConnectionsFields = () => {
const {
register,
formState: { errors }
} = useFormContext<{ name: string; description?: string | null }>();
return (
<>
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-app-connection" {...register("name")} />
</FormControl>
<FormControl
errorText={errors.description?.message}
isError={Boolean(errors.description?.message)}
label="Description"
isOptional
>
<TextArea
className="h-20 !resize-none"
placeholder="Connection description..."
{...register("description")}
/>
</FormControl>
</>
);
};
@@ -0,0 +1,164 @@
import crypto from "crypto";
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, ModalClose, Select, SelectItem } from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { isInfisicalCloud } from "@app/helpers/platform";
import {
GitHubConnectionMethod,
TGitHubConnection,
useGetAppConnectionOption
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TGitHubConnection;
};
const formSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.GitHub),
method: z.nativeEnum(GitHubConnectionMethod)
});
type FormData = z.infer<typeof formSchema>;
export const GitHubConnectionForm = ({ appConnection }: Props) => {
const isUpdate = Boolean(appConnection);
const [isRedirecting, setIsRedirecting] = useState(false);
const {
option: { oauthClientId, appClientSlug },
isLoading
} = useGetAppConnectionOption(AppConnection.GitHub);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.GitHub,
method: GitHubConnectionMethod.App
}
});
const {
handleSubmit,
control,
watch,
formState: { isSubmitting, isDirty }
} = form;
const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => {
setIsRedirecting(true);
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"githubConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
switch (formData.method) {
case GitHubConnectionMethod.App:
window.location.assign(
`https://github.com/apps/${appClientSlug}/installations/new?state=${state}`
);
break;
case GitHubConnectionMethod.OAuth:
window.location.assign(
`https://github.com/login/oauth/authorize?client_id=${oauthClientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/app-connections/github/oauth/callback&state=${state}`
);
break;
default:
throw new Error(`Unhandled GitHub Connection method: ${(formData as FormData).method}`);
}
};
let isMissingConfig: boolean;
switch (selectedMethod) {
case GitHubConnectionMethod.OAuth:
isMissingConfig = !oauthClientId;
break;
case GitHubConnectionMethod.App:
isMissingConfig = !appClientSlug;
break;
default:
throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`);
}
const methodDetails = getAppConnectionMethodDetails(selectedMethod);
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.GitHub].name
}. This field cannot be changed after creation.`}
errorText={
!isLoading && isMissingConfig
? `Environment variables have not been configured. ${
isInfisicalCloud()
? "Please contact Infisical."
: `See Docs to configure GitHub ${methodDetails.name} Connections.`
}`
: error?.message
}
isError={Boolean(error?.message) || isMissingConfig}
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(GitHubConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{methodDetails.name}{" "}
{method === GitHubConnectionMethod.App ? " (Recommended)" : ""}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting || isRedirecting}
isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting}
>
{isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,2 @@
export * from "./AppConnectionForm";
export * from "./GenericAppConnectionFields";
@@ -0,0 +1,57 @@
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
type Props = {
app: AppConnection;
isConnected: boolean;
onBack?: () => void;
};
export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => {
const appDetails = APP_CONNECTION_MAP[app];
return (
<div className="mb-4 flex w-full items-start gap-2 border-b border-mineshaft-500 pb-4">
<img
alt={`${appDetails.name} logo`}
src={`/images/integrations/${appDetails.image}`}
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
/>
<div>
<div className="flex items-center text-mineshaft-300">
{appDetails.name}
<a
href={`https://infisical.com/docs/integrations/app-connections/${app}`}
target="_blank"
className="mb-1 ml-1"
rel="noopener noreferrer"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1 text-[10px]"
/>
</div>
</a>
</div>
<p className="text-sm leading-4 text-mineshaft-400">
{isConnected ? `${appDetails.name} Connection` : `Connect to ${appDetails.name}`}
</p>
</div>
{onBack && (
<button
type="button"
className="ml-auto mt-1 text-xs text-mineshaft-400 underline underline-offset-2 hover:text-mineshaft-300"
onClick={onBack}
>
Select another App
</button>
)}
</div>
);
};
@@ -0,0 +1,86 @@
import { faWrench } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Spinner, Tooltip } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { useAppConnectionOptions } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
type Props = {
onSelect: (app: AppConnection) => void;
};
export const AppConnectionsSelect = ({ onSelect }: Props) => {
const { isLoading, data: appConnectionOptions } = useAppConnectionOptions();
if (isLoading) {
return (
<div className="flex h-full flex-col items-center justify-center py-2.5">
<Spinner size="lg" className="text-mineshaft-500" />
<p className="mt-4 text-sm text-mineshaft-400">Loading options...</p>
</div>
);
}
return (
<div className="grid grid-cols-5 gap-2">
{appConnectionOptions?.map((option) => (
<button
type="button"
key={option.app}
onClick={() => onSelect(option.app)}
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
>
<img
src={`/images/integrations/${APP_CONNECTION_MAP[option.app].image}`}
height={50}
width={50}
className="mt-auto"
alt={`${APP_CONNECTION_MAP[option.app].name} logo`}
/>
<div className="mt-auto max-w-xs text-center text-sm font-medium text-gray-300 duration-200 group-hover:text-gray-200">
{APP_CONNECTION_MAP[option.app].name}
</div>
</button>
))}
<Tooltip
side="bottom"
className="max-w-sm py-4"
content={
<>
<p className="mb-2">Infisical is constantly adding support for more connections.</p>
<p>
{`If you don't see the third-party
app you're looking for,`}{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://infisical.com/slack"
rel="noopener noreferrer"
>
let us know on Slack
</a>{" "}
or{" "}
<a
target="_blank"
className="underline hover:text-mineshaft-300"
href="https://github.com/Infisical/infisical/discussions"
rel="noopener noreferrer"
>
make a request on GitHub
</a>
.
</p>
</>
}
>
<div className="group relative flex h-28 flex-col items-center justify-center rounded-md border border-dashed border-mineshaft-600 bg-mineshaft-800 p-4">
<FontAwesomeIcon className="mt-auto text-xl" icon={faWrench} />
<div className="mt-auto max-w-xs text-center text-sm font-medium text-gray-300 duration-200 group-hover:text-gray-200">
Coming Soon
</div>
</div>
</Tooltip>
</div>
);
};
@@ -0,0 +1,169 @@
import { useCallback } from "react";
import {
faAsterisk,
faCheck,
faCopy,
faEdit,
faEllipsisV,
faInfoCircle,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { useToggle } from "@app/hooks";
import { TAppConnection } from "@app/hooks/api/appConnections";
type Props = {
appConnection: TAppConnection;
onDelete: (appConnection: TAppConnection) => void;
onEditCredentials: (appConnection: TAppConnection) => void;
onEditDetails: (appConnection: TAppConnection) => void;
};
export const AppConnectionRow = ({
appConnection,
onDelete,
onEditCredentials,
onEditDetails
}: Props) => {
const { id, name, method, app, description } = appConnection;
const [isIdCopied, setIsIdCopied] = useToggle(false);
const handleCopyId = useCallback(() => {
setIsIdCopied.on();
navigator.clipboard.writeText(id);
createNotification({
text: "Connection ID copied to clipboard",
type: "info"
});
const timer = setTimeout(() => setIsIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isIdCopied]);
const methodDetails = getAppConnectionMethodDetails(method);
return (
<Tr
className={twMerge("group h-12 transition-colors duration-100 hover:bg-mineshaft-700")}
key={`app-connection-${id}`}
>
<Td>
<div className="flex items-center gap-2">
<img
alt={`${APP_CONNECTION_MAP[app].name} integration`}
src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`}
className="mr-0.5 h-5 w-5"
/>
<span className="hidden lg:inline">{APP_CONNECTION_MAP[app].name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<div className="flex w-full items-center">
<p className="truncate">{name}</p>
{description && (
<Tooltip content={description}>
<FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<p className="truncate">
<FontAwesomeIcon
size="sm"
className="mr-1.5 text-mineshaft-300/75"
icon={methodDetails.icon}
/>
{methodDetails.name}
</p>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
>
Copy Connection ID
</DropdownMenuItem>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => onEditDetails(appConnection)}
>
Edit Details
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faAsterisk} />}
onClick={() => onEditCredentials(appConnection)}
>
Edit Credentials
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.AppConnections}
>
{(isAllowed: boolean) => (
<DropdownMenuItem
isDisabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
onClick={() => onDelete(appConnection)}
>
Delete Connection
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};
@@ -0,0 +1,321 @@
import { useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faMagnifyingGlass,
faPlug,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { useSubscription } from "@app/context";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { AppConnectionRow } from "./AppConnectionRow";
import { DeleteAppConnectionModal } from "./DeleteAppConnectionModal";
import { EditAppConnectionCredentialsModal } from "./EditAppConnectionCredentialsModal";
import { EditAppConnectionDetailsModal } from "./EditAppConnectionDetailsModal";
enum AppConnectionsOrderBy {
App = "app",
Name = "name",
Method = "method"
}
type AppConnectionFilters = {
apps: AppConnection[];
};
export const AppConnectionsTable = () => {
const { subscription } = useSubscription();
const { isLoading, data: appConnections = [] } = useListAppConnections({
enabled: subscription?.appConnections
});
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"deleteConnection",
"editCredentials",
"editDetails"
] as const);
const [filters, setFilters] = useState<AppConnectionFilters>({
apps: []
});
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, { initPerPage: 20 });
const filteredAppConnections = useMemo(
() =>
appConnections
.filter((appConnection) => {
const { app, method, name } = appConnection;
if (filters.apps.length && !filters.apps.includes(app)) return false;
const searchValue = search.trim().toLowerCase();
return (
APP_CONNECTION_MAP[app].name.toLowerCase().includes(searchValue) ||
getAppConnectionMethodDetails(method).name.toLowerCase().includes(searchValue) ||
name.toLowerCase().includes(searchValue)
);
})
.sort((a, b) => {
const [connectionOne, connectionTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case AppConnectionsOrderBy.Name:
return connectionOne.name
.toLowerCase()
.localeCompare(connectionTwo.name.toLowerCase());
case AppConnectionsOrderBy.Method:
return getAppConnectionMethodDetails(connectionOne.method)
.name.toLowerCase()
.localeCompare(
getAppConnectionMethodDetails(connectionTwo.method).name.toLowerCase()
);
case AppConnectionsOrderBy.App:
default:
return APP_CONNECTION_MAP[connectionOne.app].name
.toLowerCase()
.localeCompare(APP_CONNECTION_MAP[connectionTwo.app].name.toLowerCase());
}
}),
[appConnections, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredAppConnections.length,
offset,
setPage
});
const handleSort = (column: AppConnectionsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: AppConnectionsOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: AppConnectionsOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered = Boolean(filters.apps.length);
const handleDelete = (appConnection: TAppConnection) =>
handlePopUpOpen("deleteConnection", appConnection);
const handleEditCredentials = (appConnection: TAppConnection) =>
handlePopUpOpen("editCredentials", appConnection);
const handleEditDetails = (appConnection: TAppConnection) =>
handlePopUpOpen("editDetails", appConnection);
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search integrations..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Connections"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Filter by Apps</DropdownMenuLabel>
{appConnections.length ? (
[...new Set(appConnections.map(({ app }) => app))].map((app) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
apps: prev.apps.includes(app)
? prev.apps.filter((a) => a !== app)
: [...prev.apps, app]
}));
}}
key={app}
icon={
filters.apps.includes(app) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${APP_CONNECTION_MAP[app].name} integration`}
src={`/images/integrations/${APP_CONNECTION_MAP[app].image}`}
className="h-4 w-4"
/>
<span>{APP_CONNECTION_MAP[app].name}</span>
</div>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem isDisabled>No Connections Configured</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/4">
<div className="flex items-center">
App
<IconButton
variant="plain"
className={getClassName(AppConnectionsOrderBy.App)}
ariaLabel="sort"
onClick={() => handleSort(AppConnectionsOrderBy.App)}
>
<FontAwesomeIcon icon={getColSortIcon(AppConnectionsOrderBy.App)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={getClassName(AppConnectionsOrderBy.Name)}
ariaLabel="sort"
onClick={() => handleSort(AppConnectionsOrderBy.Name)}
>
<FontAwesomeIcon icon={getColSortIcon(AppConnectionsOrderBy.Name)} />
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Method
<IconButton
variant="plain"
className={getClassName(AppConnectionsOrderBy.Method)}
ariaLabel="sort"
onClick={() => handleSort(AppConnectionsOrderBy.Method)}
>
<FontAwesomeIcon icon={getColSortIcon(AppConnectionsOrderBy.Method)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && (
<TableSkeleton innerKey="app-connections-table" columns={4} key="app-connections" />
)}
{filteredAppConnections.slice(offset, perPage * page).map((connection) => (
<AppConnectionRow
appConnection={connection}
key={connection.id}
onDelete={handleDelete}
onEditCredentials={handleEditCredentials}
onEditDetails={handleEditDetails}
/>
))}
</TBody>
</Table>
{Boolean(filteredAppConnections.length) && (
<Pagination
count={filteredAppConnections.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredAppConnections?.length && (
<EmptyState
title={
appConnections.length
? "No App Connections match search..."
: "No App Connections have been configured"
}
icon={appConnections.length ? faSearch : faPlug}
/>
)}
</TableContainer>
<DeleteAppConnectionModal
isOpen={popUp.deleteConnection.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteConnection", isOpen)}
appConnection={popUp.deleteConnection.data}
/>
<EditAppConnectionCredentialsModal
isOpen={popUp.editCredentials.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editCredentials", isOpen)}
appConnection={popUp.editCredentials.data}
/>
<EditAppConnectionDetailsModal
isOpen={popUp.editDetails.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editDetails", isOpen)}
appConnection={popUp.editDetails.data}
/>
</div>
);
};
@@ -0,0 +1,51 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection, useDeleteAppConnection } from "@app/hooks/api/appConnections";
type Props = {
appConnection?: TAppConnection;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const DeleteAppConnectionModal = ({ isOpen, onOpenChange, appConnection }: Props) => {
const deleteAppConnection = useDeleteAppConnection();
if (!appConnection) return null;
const { id: connectionId, name, app } = appConnection;
const handleDeleteAppConnection = async () => {
try {
await deleteAppConnection.mutateAsync({
connectionId,
app
});
createNotification({
text: `Successfully removed ${APP_CONNECTION_MAP[app].name} connection`,
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: `Failed remove ${APP_CONNECTION_MAP[app].name} connection`,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey="confirm"
onDeleteApproved={handleDeleteAppConnection}
/>
);
};
@@ -0,0 +1,33 @@
import { Modal, ModalContent } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection } from "@app/hooks/api/appConnections";
import { AppConnectionForm } from "./AppConnectionForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
appConnection?: TAppConnection;
};
export const EditAppConnectionCredentialsModal = ({
isOpen,
onOpenChange,
appConnection
}: Props) => {
if (!appConnection) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Edit Connection Credentials"
subTitle={`Update the credentials for this ${
appConnection ? APP_CONNECTION_MAP[appConnection.app].name : "App"
} Connection.`}
>
<AppConnectionForm onComplete={() => onOpenChange(false)} appConnection={appConnection} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,109 @@
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { TAppConnection, useUpdateAppConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { DiscriminativePick } from "@app/types";
import { genericAppConnectionFieldsSchema, GenericAppConnectionsFields } from "./AppConnectionForm";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
appConnection?: TAppConnection;
};
const formSchema = genericAppConnectionFieldsSchema.extend({
app: z.nativeEnum(AppConnection)
});
type FormData = z.infer<typeof formSchema>;
type ContentProps = { appConnection: TAppConnection; onComplete: () => void };
const Content = ({ appConnection, onComplete }: ContentProps) => {
const updateAppConnection = useUpdateAppConnection();
const { name: appName } = APP_CONNECTION_MAP[appConnection.app];
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: appConnection.name,
app: appConnection.app,
description: appConnection.description
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
const onSubmit = async (formData: DiscriminativePick<TAppConnection, "name" | "app">) => {
try {
await updateAppConnection.mutateAsync({
connectionId: appConnection.id,
...formData
});
createNotification({
text: `Successfully updated ${appName} Connection`,
type: "success"
});
onComplete();
} catch (err: any) {
console.error(err);
createNotification({
title: `Failed to update ${appName} Connection`,
text: err.message,
type: "error"
});
}
};
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericAppConnectionsFields />
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Update Details
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
export const EditAppConnectionDetailsModal = ({ isOpen, onOpenChange, appConnection }: Props) => {
if (!appConnection) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
className="max-w-2xl"
title="Edit Connection Name"
subTitle={`Update the name for this ${
appConnection ? APP_CONNECTION_MAP[appConnection.app].name : "App"
} Connection.`}
>
<Content appConnection={appConnection} onComplete={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,2 @@
export * from "./AddAppConnectionModal";
export * from "./AppConnectionsTable";
@@ -0,0 +1 @@
export { AppConnectionsTab } from "./AppConnectionsTab";
@@ -0,0 +1,207 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, FormLabel, IconButton, Input, Spinner } from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useCreateAuditLogStream,
useGetAuditLogStreamDetails,
useUpdateAuditLogStream
} from "@app/hooks/api";
type Props = {
id?: string;
onClose: () => void;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const formSchema = z.object({
url: z.string().url().min(1),
headers: z
.object({
key: z.string(),
value: z.string()
})
.array()
.optional()
});
type TForm = z.infer<typeof formSchema>;
export const AuditLogStreamForm = ({ id = "", onClose }: Props) => {
const isEdit = Boolean(id);
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const auditLogStream = useGetAuditLogStreamDetails(id);
const createAuditLogStream = useCreateAuditLogStream();
const updateAuditLogStream = useUpdateAuditLogStream();
const {
handleSubmit,
control,
setValue,
getValues,
formState: { isSubmitting }
} = useForm<TForm>({
values: auditLogStream?.data,
defaultValues: {
headers: [{ key: "", value: "" }]
}
});
const headerFields = useFieldArray({
control,
name: "headers"
});
const handleAuditLogStreamEdit = async ({ headers, url }: TForm) => {
if (!id) return;
try {
await updateAuditLogStream.mutateAsync({
id,
orgId,
headers,
url
});
createNotification({
type: "success",
text: "Successfully updated stream"
});
onClose();
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to update stream"
});
}
};
const handleFormSubmit = async ({ headers = [], url }: TForm) => {
if (isSubmitting) return;
const sanitizedHeaders = headers.filter(({ key, value }) => Boolean(key) && Boolean(value));
const streamHeaders = sanitizedHeaders.length ? sanitizedHeaders : undefined;
if (isEdit) {
await handleAuditLogStreamEdit({ headers: streamHeaders, url });
return;
}
try {
await createAuditLogStream.mutateAsync({
orgId,
headers: streamHeaders,
url
});
createNotification({
type: "success",
text: "Successfully created stream"
});
onClose();
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to create stream"
});
}
};
if (isEdit && auditLogStream.isPending) {
return (
<div className="flex items-center justify-center p-8">
<Spinner size="lg" />
</div>
);
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} autoComplete="off">
<div>
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Endpoint URL"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<FormLabel label="Headers" isOptional />
{headerFields.fields.map(({ id: headerFieldId }, i) => (
<div key={headerFieldId} className="flex space-x-2">
<Controller
control={control}
name={`headers.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-1/3"
>
<Input {...field} placeholder="Authorization" />
</FormControl>
)}
/>
<Controller
control={control}
name={`headers.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="password"
placeholder="Bearer <token>"
autoComplete="new-password"
/>
</FormControl>
)}
/>
<IconButton
ariaLabel="delete key"
className="h-9"
variant="outline_bg"
onClick={() => {
const header = getValues("headers");
if (header && header?.length > 1) {
headerFields.remove(i);
} else {
setValue("headers", [{ key: "", value: "" }]);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => headerFields.append({ value: "", key: "" })}
>
Add Key
</Button>
</div>
</div>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}
</Button>
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,197 @@
import { faPlug, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
THead,
Tr
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useDeleteAuditLogStream, useGetAuditLogStreams } from "@app/hooks/api";
import { AuditLogStreamForm } from "./AuditLogStreamForm";
export const AuditLogStreamsTab = withPermission(
() => {
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"auditLogStreamForm",
"deleteAuditLogStream",
"upgradePlan"
] as const);
const { subscription } = useSubscription();
const { data: auditLogStreams, isPending: isAuditLogStreamsLoading } =
useGetAuditLogStreams(orgId);
// mutation
const { mutateAsync: deleteAuditLogStream } = useDeleteAuditLogStream();
const handleAuditLogStreamDelete = async () => {
try {
const auditLogStreamId = popUp?.deleteAuditLogStream?.data as string;
await deleteAuditLogStream({
id: auditLogStreamId,
orgId
});
handlePopUpClose("deleteAuditLogStream");
createNotification({
type: "success",
text: "Successfully deleted stream"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to delete stream"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Audit Log Streams</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.auditLogStreams) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("auditLogStreamForm");
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Create
</Button>
)}
</OrgPermissionCan>
</div>
<p className="mb-8 text-gray-400">
Send audit logs from Infisical to external logging providers via HTTP
</p>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>URL</Td>
<Td className="text-right">Action</Td>
</Tr>
</THead>
<TBody>
{isAuditLogStreamsLoading && (
<TableSkeleton columns={2} innerKey="stream-loading" />
)}
{!isAuditLogStreamsLoading && auditLogStreams && auditLogStreams?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No audit log streams found" icon={faPlug} />
</Td>
</Tr>
)}
{!isAuditLogStreamsLoading &&
auditLogStreams?.map(({ id, url }) => (
<Tr key={id}>
<Td className="max-w-xs overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{url}
</Td>
<Td>
<div className="flex items-center justify-end space-x-2">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("auditLogStreamForm", id)}
>
Edit
</Button>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Settings}
>
{(isAllowed) => (
<Button
variant="outline_bg"
className="border-red-800 bg-red-800 hover:border-red-700 hover:bg-red-700"
colorSchema="danger"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("deleteAuditLogStream", id)}
>
Delete
</Button>
)}
</OrgPermissionCan>
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</div>
<Modal
isOpen={popUp.auditLogStreamForm.isOpen}
onOpenChange={(isModalOpen) => {
handlePopUpToggle("auditLogStreamForm", isModalOpen);
}}
>
<ModalContent
title={`${popUp?.auditLogStreamForm?.data ? "Update" : "Create"} Audit Log Stream `}
subTitle="Continuously stream logs from Infisical to third-party logging providers."
>
<AuditLogStreamForm
id={popUp?.auditLogStreamForm?.data as string}
onClose={() => handlePopUpToggle("auditLogStreamForm")}
/>
</ModalContent>
</Modal>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
/>
<DeleteActionModal
isOpen={popUp.deleteAuditLogStream.isOpen}
deleteKey="delete"
title="Are you sure you want to remove this stream?"
onChange={(isOpen) => handlePopUpToggle("deleteAuditLogStream", isOpen)}
onClose={() => handlePopUpClose("deleteAuditLogStream")}
onDeleteApproved={handleAuditLogStreamDelete}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Settings }
);
@@ -0,0 +1 @@
export { AuditLogStreamsTab } from "./AuditLogStreamTab";
@@ -0,0 +1,66 @@
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { usePopUp } from "@app/hooks";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
export const ImportTab = () => {
const { membership } = useOrgPermission();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
return (
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.Workspace}
renderGuardBanner
passThrough={false}
>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<div className="flex items-center gap-2">
<p className="text-xl font-semibold text-mineshaft-100">Import from external source</p>
<div>
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/guides/migrating-from-envkey"
>
<div className="ml-2 inline-block rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-xxs"
/>
</div>
</a>
</div>
</div>
<Button
onClick={() => {
handlePopUpOpen("selectImportPlatform");
}}
isDisabled={membership?.role !== ProjectMembershipRole.Admin}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Import
</Button>
</div>
<p className="mb-4 text-gray-400">Import data from another platform to Infisical.</p>
<SelectImportFromPlatformModal
isOpen={popUp.selectImportPlatform.isOpen}
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
/>
</div>
</OrgPermissionCan>
);
};
@@ -0,0 +1,118 @@
import { useRef } 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 { useImportEnvKey } from "@app/hooks/api/migration/mutations";
import { GenericDropzone } from "./GenericDropzone";
type Props = {
id?: string;
onClose: () => void;
};
export const EnvKeyPlatformModal = ({ onClose }: Props) => {
const formSchema = z.object({
encryptionKey: z.string().min(1),
file: z.instanceof(File)
});
type TFormData = z.infer<typeof formSchema>;
const fileUploadRef = useRef<HTMLInputElement>(null);
const { mutateAsync: importEnvKey } = useImportEnvKey();
const {
control,
handleSubmit,
reset,
setValue,
setError,
formState: { isLoading, isDirty, isSubmitting, isValid }
} = useForm<TFormData>({
resolver: zodResolver(formSchema)
});
const onSubmit = async (data: TFormData) => {
if (!data.file) {
setError("file", {
type: "required",
message: "File is required"
});
return;
}
try {
await importEnvKey({
file: data.file,
decryptionKey: data.encryptionKey
});
createNotification({
title: "Import started",
text: "Your data is being imported. You will receive an email when the import is complete or if the import fails. This may take up to 10 minutes.",
type: "info"
});
onClose();
reset();
if (fileUploadRef.current) {
fileUploadRef.current.value = "";
}
} catch {
reset();
}
};
const onImportFileDrop = (file?: File) => {
if (!file) {
createNotification({
text: "No file selected.",
type: "error"
});
return;
}
setValue("file", file, { shouldDirty: true, shouldValidate: true });
};
return (
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<Controller
control={control}
name="encryptionKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Encryption key"
isRequired
errorText={error?.message}
isError={Boolean(error)}
>
<Input type="password" placeholder="" {...field} />
</FormControl>
)}
/>
<GenericDropzone
ref={fileUploadRef}
text="Select Env Key export file"
onData={onImportFileDrop}
isSmaller
/>
<div className="mt-6 flex items-center space-x-4">
<Button
type="submit"
isLoading={isLoading}
isDisabled={!isDirty || isSubmitting || isLoading || !isValid}
>
Import data
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,117 @@
import React, {
ChangeEvent,
DragEvent,
forwardRef,
useImperativeHandle,
useRef,
useState
} from "react";
import { faUpload } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useToggle } from "@app/hooks";
type Props = {
accept?: string;
onData: (file: File) => void;
isSmaller: boolean;
text?: string;
isDisabled?: boolean;
};
export const GenericDropzone = forwardRef<HTMLInputElement, Props>(
({ onData, isSmaller, text, isDisabled, accept }: Props, ref): JSX.Element => {
const [isDragActive, setDragActive] = useToggle();
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
const updateSelectedFileName = () => {
if (inputRef.current?.files?.[0]) {
setSelectedFileName(inputRef.current.files[0].name);
} else {
setSelectedFileName(null);
}
};
const handleDrag = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive.on();
} else if (e.type === "dragleave") {
setDragActive.off();
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.dataTransfer) {
return;
}
e.dataTransfer.dropEffect = "copy";
setDragActive.off();
const file = e.dataTransfer.files[0];
onData(file);
setSelectedFileName(file.name);
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (!e.target?.files?.[0]) {
return;
}
onData(e.target.files[0]);
updateSelectedFileName();
};
React.useEffect(() => {
updateSelectedFileName();
}, []);
return (
<div>
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={twMerge(
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 px-2 py-4 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
isDragActive && "opacity-100",
!isSmaller && "mx-auto w-full max-w-3xl flex-col space-y-4 py-20"
)}
>
{selectedFileName ? (
<p>{selectedFileName}</p>
) : (
<div className="flex flex-col items-center justify-center space-y-2">
<div>
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
</div>
<div>
<p className="">{text}</p>
</div>
<input
ref={inputRef}
disabled={isDisabled}
id="fileSelect"
type="file"
className="absolute h-full w-full cursor-pointer opacity-0"
accept={accept}
onChange={handleFileUpload}
/>
</div>
)}
</div>
</div>
);
}
);
GenericDropzone.displayName = "GenericDropzone";
@@ -0,0 +1,101 @@
import { useState } from "react";
import { faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { EnvKeyPlatformModal } from "./EnvKeyPlatformModal";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
};
enum WizardSteps {
SelectPlatform = "select-platform",
PlatformInputs = "platform-inputs"
}
const PLATFORM_LIST = [
{
icon: faKey,
platform: "env-key",
title: "Env Key"
}
] as const;
export const SelectImportFromPlatformModal = ({ isOpen, onToggle }: Props) => {
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform);
const [selectedPlatform, setSelectedPlatform] = useState<(typeof PLATFORM_LIST)[number] | null>(
null
);
const handleFormReset = (state: boolean = false) => {
onToggle(state);
setWizardStep(WizardSteps.SelectPlatform);
setSelectedPlatform(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
<ModalContent
title={
selectedPlatform ? `Import from ${selectedPlatform.title}` : "Import from external source"
}
className="my-4"
>
<AnimatePresence exitBeforeEnter>
{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-sm text-mineshaft-300">
Select a platform to import from
</div>
<div className="flex items-center space-x-4">
{PLATFORM_LIST.map((platform, idx) => (
<div
key={`platform-${idx + 1}`}
className="flex h-28 w-32 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"
role="button"
tabIndex={0}
onClick={() => {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}
}}
>
<FontAwesomeIcon icon={platform.icon} size="lg" />
<div className="whitespace-pre-wrap text-center text-sm">{platform.title}</div>
</div>
))}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.PlatformInputs &&
selectedPlatform?.platform === "env-key" && (
<motion.div
key="env-key-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EnvKeyPlatformModal onClose={() => handleFormReset(false)} />
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { ImportTab } from "./ImportTab";
@@ -0,0 +1,214 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } 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 { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
FormLabel,
IconButton,
Input,
Select,
SelectItem,
Spinner
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useOrgPermission
} from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles";
import { useGetOrgRoles } from "@app/hooks/api";
import {
useGetExternalGroupOrgRoleMappings,
useUpdateExternalGroupOrgRoleMappings
} from "@app/hooks/api/externalGroupOrgRoleMappings";
const formSchema = z.object({
mappings: z
.object({
groupName: z.string().trim().min(1, { message: "Group name is required" }),
roleSlug: z.string()
})
.array()
});
type TForm = z.infer<typeof formSchema>;
export const ExternalGroupOrgRoleMappings = () => {
const { currentOrg } = useOrganization();
const { data: roles, isPending: isRolesLoading } = useGetOrgRoles(currentOrg.id);
const { data: mappings } = useGetExternalGroupOrgRoleMappings();
const updateMappings = useUpdateExternalGroupOrgRoleMappings();
const { permission } = useOrgPermission();
const {
control,
formState: { isDirty },
handleSubmit,
reset
} = useForm<TForm>({
defaultValues: { mappings: [] },
resolver: zodResolver(formSchema)
});
useEffect(() => {
if (!mappings || !roles) return;
reset({
mappings: mappings.map((mapping) => ({
groupName: mapping.groupName,
roleSlug:
mapping.role === "custom"
? roles.find((role) => mapping.roleId === role.id)!.slug
: mapping.role
}))
});
}, [mappings, roles]);
const mappingField = useFieldArray({ control, name: "mappings" });
const handleUpdateMappings = async (form: TForm) => {
try {
await updateMappings.mutateAsync(form);
createNotification({
text: "Group organization role mappings updated.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
text: "Failed to update group organization role mappings.",
type: "error"
});
}
};
const disableScimEdit = permission.cannot(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
return (
<div className="py-4">
<h2 className="text-md text-mineshaft-100">SCIM Group to Organization Role Mappings</h2>
<p className="text-sm text-mineshaft-300">
Assign newly provisioned users a default organization role based on their SCIM group.
</p>
<form onSubmit={handleSubmit(handleUpdateMappings)} className="pt-4">
{isRolesLoading || isRolesLoading ? (
<Spinner className="self-center" size="sm" />
) : (
<div className="mb-2 flex flex-col space-y-2">
{mappingField.fields.map(({ id: scopeFieldId }, i) => (
<div key={scopeFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && (
<FormLabel
label="SCIM Group Name"
className="text-xs text-mineshaft-400"
tooltipClassName="max-w-md whitespace-pre-line"
tooltipText="The name associated with this group in your SCIM provider"
/>
)}
<Controller
control={control}
name={`mappings.${i}.groupName`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input
isDisabled={disableScimEdit}
{...field}
placeholder="SCIM group identifier..."
/>
</FormControl>
)}
/>
</div>
<div className="flex-1">
{i === 0 && (
<span className="text-xs text-mineshaft-400">
Role to Assign Users in this Group
</span>
)}
<Controller
control={control}
name={`mappings.${i}.roleSlug`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
isDisabled={disableScimEdit}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{roles?.map((role) => (
<SelectItem value={role.slug} key={`role-${role.id}`}>
{role.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 h-9"
variant="outline_bg"
isDisabled={disableScimEdit}
onClick={() => {
mappingField.remove(i);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
isDisabled={disableScimEdit}
onClick={() =>
mappingField.append({
groupName: "",
roleSlug: isCustomOrgRole(currentOrg!.defaultMembershipRole)
? roles?.find((role) => currentOrg?.defaultMembershipRole === role.id)
?.slug || ""
: currentOrg!.defaultMembershipRole
})
}
>
Add Mapping
</Button>
</div>
{isDirty && (
<div className="flex w-full justify-end">
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Scim}>
{(isAllowed) => (
<Button isDisabled={!isAllowed} colorSchema="secondary" type="submit">
Update Mappings
</Button>
)}
</OrgPermissionCan>
</div>
)}
</div>
)}
</form>
</div>
);
};
@@ -0,0 +1,293 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useCreateLDAPGroupMapping,
useDeleteLDAPGroupMapping,
useGetLDAPConfig,
useGetLDAPGroupMaps,
useGetOrganizationGroups
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
ldapGroupCN: z.string().min(1, "LDAP Group CN is required"),
groupSlug: z.string().min(1, "Group Slug is required")
});
export type TFormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["ldapGroupMap", "deleteLdapGroupMap"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteLdapGroupMap"]>,
data?: {
ldapGroupMapId: string;
ldapGroupCN: string;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["ldapGroupMap", "deleteLdapGroupMap"]>,
state?: boolean
) => void;
};
export const LDAPGroupMapModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { data: ldapConfig } = useGetLDAPConfig(currentOrg?.id ?? "");
const { data: groups } = useGetOrganizationGroups(currentOrg?.id ?? "");
const { data: groupMaps, isPending } = useGetLDAPGroupMaps(ldapConfig?.id ?? "");
const { mutateAsync: createLDAPGroupMapping, isPending: createIsLoading } =
useCreateLDAPGroupMapping();
const { mutateAsync: deleteLDAPGroupMapping } = useDeleteLDAPGroupMapping();
const navigate = useNavigate();
const { control, handleSubmit, reset, setValue } = useForm<TFormData>({
resolver: zodResolver(schema),
defaultValues: {
ldapGroupCN: "",
groupSlug: ""
}
});
const onFormSubmit = async ({ groupSlug, ldapGroupCN }: TFormData) => {
try {
if (!ldapConfig) return;
await createLDAPGroupMapping({
ldapConfigId: ldapConfig.id,
groupSlug,
ldapGroupCN
});
reset();
createNotification({
text: `Successfully added LDAP group mapping for ${ldapGroupCN}`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to add LDAP group mapping for ${ldapGroupCN}`,
type: "error"
});
}
};
const onDeleteGroupMapSubmit = async ({
ldapConfigId,
ldapGroupMapId,
ldapGroupCN
}: {
ldapConfigId: string;
ldapGroupMapId: string;
ldapGroupCN: string;
}) => {
try {
await deleteLDAPGroupMapping({
ldapConfigId,
ldapGroupMapId
});
handlePopUpToggle("deleteLdapGroupMap", false);
createNotification({
text: `Successfully deleted LDAP group mapping ${ldapGroupCN}`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to delete LDAP group mapping ${ldapGroupCN}`,
type: "error"
});
}
};
useEffect(() => {
if (groups && groups.length > 0) {
setValue("groupSlug", groups[0].slug);
}
}, [groups, popUp.ldapGroupMap.isOpen]);
return (
<Modal
isOpen={popUp?.ldapGroupMap?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("ldapGroupMap", isOpen);
reset();
}}
>
<ModalContent title="Manage LDAP Group Mappings">
{groups && groups.length > 0 && (
<>
<h2 className="mb-4">New Group Mapping</h2>
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<div className="flex">
<Controller
control={control}
name="ldapGroupCN"
render={({ field, fieldState: { error } }) => (
<FormControl
label="LDAP Group CN"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="Engineering" />
</FormControl>
)}
/>
<Controller
control={control}
name="groupSlug"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Infisical Group"
errorText={error?.message}
isError={Boolean(error)}
className="ml-4 w-full"
>
<div className="flex">
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(groups || []).map(({ name, id, slug }) => (
<SelectItem value={slug} key={`internal-group-${id}`}>
{name}
</SelectItem>
))}
</Select>
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={createIsLoading}
>
Add mapping
</Button>
</div>
</FormControl>
)}
/>
</div>
</form>
<h2 className="mb-4">Group Mappings</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>LDAP Group CN</Th>
<Th>Infisical Group</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="ldap-group-maps" />}
{!isPending &&
groupMaps?.map(({ id, ldapGroupCN, group }) => {
return (
<Tr className="h-10 items-center" key={`ldap-group-map-${id}`}>
<Td>{ldapGroupCN}</Td>
<Td>{group.name}</Td>
<Td>
<IconButton
onClick={() => {
handlePopUpOpen("deleteLdapGroupMap", {
ldapGroupMapId: id,
ldapGroupCN
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{groupMaps?.length === 0 && (
<EmptyState title="No LDAP group mappings found" icon={faUsers} />
)}
</TableContainer>
<DeleteActionModal
isOpen={popUp.deleteLdapGroupMap.isOpen}
title={`Are you sure want to delete the group mapping for ${
(popUp?.deleteLdapGroupMap?.data as { ldapGroupCN: string })?.ldapGroupCN || ""
}?`}
onChange={(isOpen) => handlePopUpToggle("deleteLdapGroupMap", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const deleteLdapGroupMapData = popUp?.deleteLdapGroupMap?.data as {
ldapGroupMapId: string;
ldapGroupCN: string;
};
return onDeleteGroupMapSubmit({
ldapConfigId: ldapConfig?.id ?? "",
ldapGroupMapId: deleteLdapGroupMapData.ldapGroupMapId,
ldapGroupCN: deleteLdapGroupMapData.ldapGroupCN
});
}}
/>
</>
)}
{groups && groups.length === 0 && (
<div>
<div>
You do not have any Infisical groups in your organization. Create one in order to
proceed.
</div>
<Button
className="mt-4"
size="sm"
onClick={() =>
navigate({
to: "/organization/access-management"
})
}
>
Create
</Button>
</div>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,371 @@
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,
DeleteActionModal,
FormControl,
Input,
Modal,
ModalContent,
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import {
useCreateLDAPConfig,
useGetLDAPConfig,
useTestLDAPConnection,
useUpdateLDAPConfig
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const LDAPFormSchema = z.object({
url: z.string().default(""),
bindDN: z.string().default(""),
bindPass: z.string().default(""),
searchBase: z.string().default(""),
searchFilter: z.string().default(""),
uniqueUserAttribute: z.string().default(""),
groupSearchBase: z.string().default(""),
groupSearchFilter: z.string().default(""),
caCert: z.string().optional()
});
export type TLDAPFormData = z.infer<typeof LDAPFormSchema>;
type Props = {
popUp: UsePopUpState<["addLDAP"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addLDAP"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addLDAP"]>, state?: boolean) => void;
hideDelete?: boolean;
};
export const LDAPModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelete }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync: createMutateAsync, isPending: createIsLoading } = useCreateLDAPConfig();
const { mutateAsync: updateMutateAsync, isPending: updateIsLoading } = useUpdateLDAPConfig();
const { mutateAsync: testLDAPConnection } = useTestLDAPConnection();
const { data } = useGetLDAPConfig(currentOrg?.id ?? "");
const { control, handleSubmit, reset, watch } = useForm<TLDAPFormData>({
resolver: zodResolver(LDAPFormSchema)
});
const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle();
const handleLdapSoftDelete = async () => {
if (!currentOrg) {
return;
}
try {
await updateMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url: "",
bindDN: "",
bindPass: "",
searchBase: "",
searchFilter: "",
uniqueUserAttribute: "",
groupSearchBase: "",
groupSearchFilter: "",
caCert: ""
});
createNotification({
text: "Successfully deleted OIDC configuration.",
type: "success"
});
} catch {
createNotification({
text: "Failed deleting OIDC configuration.",
type: "error"
});
}
};
const watchUrl = watch("url");
const watchBindDN = watch("bindDN");
const watchBindPass = watch("bindPass");
const watchSearchBase = watch("searchBase");
const watchSearchFilter = watch("searchFilter");
const watchGroupSearchBase = watch("groupSearchBase");
const watchGroupSearchFilter = watch("groupSearchFilter");
const watchCaCert = watch("caCert");
const watchUniqueUserAttribute = watch("uniqueUserAttribute");
useEffect(() => {
if (data) {
reset({
url: data?.url ?? "",
bindDN: data?.bindDN ?? "",
bindPass: data?.bindPass ?? "",
searchBase: data?.searchBase ?? "",
searchFilter: data?.searchFilter ?? "",
groupSearchBase: data?.groupSearchBase ?? "",
groupSearchFilter: data?.groupSearchFilter ?? "",
caCert: data?.caCert ?? "",
uniqueUserAttribute: data?.uniqueUserAttribute ?? ""
});
}
}, [data]);
const onSSOModalSubmit = async ({
url,
bindDN,
bindPass,
uniqueUserAttribute,
searchBase,
searchFilter,
groupSearchBase,
groupSearchFilter,
caCert,
shouldCloseModal = true
}: TLDAPFormData & { shouldCloseModal?: boolean }) => {
try {
if (!currentOrg) return;
if (!data) {
await createMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
uniqueUserAttribute,
groupSearchBase,
groupSearchFilter,
caCert
});
} else {
await updateMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url,
bindDN,
bindPass,
searchBase,
searchFilter,
uniqueUserAttribute,
groupSearchBase,
groupSearchFilter,
caCert
});
}
if (shouldCloseModal) {
handlePopUpClose("addLDAP");
}
createNotification({
text: `Successfully ${!data ? "added" : "updated"} LDAP configuration`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${!data ? "add" : "update"} LDAP configuration`,
type: "error"
});
}
};
const handleTestLDAPConnection = async () => {
try {
await onSSOModalSubmit({
url: watchUrl,
bindDN: watchBindDN,
bindPass: watchBindPass,
searchBase: watchSearchBase,
searchFilter: watchSearchFilter,
groupSearchBase: watchGroupSearchBase,
groupSearchFilter: watchGroupSearchFilter,
uniqueUserAttribute: watchUniqueUserAttribute,
caCert: watchCaCert,
shouldCloseModal: false
});
if (!data) return;
const result = await testLDAPConnection(data.id);
if (!result) {
createNotification({
text: "Failed to test the LDAP connection: Bind operation was unsuccessful",
type: "error"
});
return;
}
createNotification({
text: "Successfully tested the LDAP connection: Bind operation was successful",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to test the LDAP connection",
type: "error"
});
}
};
return (
<>
<Modal
isOpen={popUp?.addLDAP?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addLDAP", isOpen);
reset();
}}
>
<ModalContent title="Manage LDAP configuration">
<form onSubmit={handleSubmit(onSSOModalSubmit)}>
<Controller
control={control}
name="url"
render={({ field, fieldState: { error } }) => (
<FormControl label="URL" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="ldaps://ldap.myorg.com:636" />
</FormControl>
)}
/>
<Controller
control={control}
name="bindDN"
render={({ field, fieldState: { error } }) => (
<FormControl label="Bind DN" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} placeholder="cn=infisical,ou=Users,dc=example,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="bindPass"
render={({ field, fieldState: { error } }) => (
<FormControl label="Bind Pass" errorText={error?.message} isError={Boolean(error)}>
<Input {...field} type="password" placeholder="********" />
</FormControl>
)}
/>
<Controller
control={control}
name="searchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User Search Base / User DN"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="ou=people,dc=acme,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="uniqueUserAttribute"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Unique User Attribute (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="uidNumber" />
</FormControl>
)}
/>
<Controller
control={control}
name="searchFilter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User Search Filter (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="(uid={{username}})" />
</FormControl>
)}
/>
<Controller
control={control}
name="groupSearchBase"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Group Search Base / Group DN (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="ou=groups,dc=acme,dc=com" />
</FormControl>
)}
/>
<Controller
control={control}
name="groupSearchFilter"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Group Filter (Optional)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="(&(objectClass=posixGroup)(memberUid={{.Username}}))"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="caCert"
render={({ field, fieldState: { error } }) => (
<FormControl
label="CA Certificate"
errorText={error?.message}
isError={Boolean(error)}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
<div className="mt-8 flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!data ? "Add" : "Update"}
</Button>
<Button colorSchema="secondary" onClick={handleTestLDAPConnection}>
Test Connection
</Button>
</div>
{!hideDelete && (
<Button colorSchema="danger" onClick={() => setIsDeletePopupOpen.on()}>
Delete
</Button>
)}
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={isDeletePopupOpen}
title="Are you sure want to delete LDAP?"
onChange={() => setIsDeletePopupOpen.toggle()}
deleteKey="confirm"
onDeleteApproved={handleLdapSoftDelete}
/>
</>
);
};
@@ -0,0 +1,458 @@
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,
DeleteActionModal,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import { useGetOIDCConfig } from "@app/hooks/api";
import { useCreateOIDCConfig, useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations";
import { UsePopUpState } from "@app/hooks/usePopUp";
enum ConfigurationType {
CUSTOM = "custom",
DISCOVERY_URL = "discoveryURL"
}
type Props = {
popUp: UsePopUpState<["addOIDC"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addOIDC"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addOIDC"]>, state?: boolean) => void;
hideDelete?: boolean;
};
const schema = z
.object({
configurationType: z.string(),
issuer: z.string().optional(),
discoveryURL: z.string().optional(),
authorizationEndpoint: z.string().optional(),
jwksUri: z.string().optional(),
tokenEndpoint: z.string().optional(),
userinfoEndpoint: z.string().optional(),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
allowedEmailDomains: z.string().optional()
})
.superRefine((data, ctx) => {
if (data.configurationType === ConfigurationType.CUSTOM) {
if (!data.issuer) {
ctx.addIssue({
path: ["issuer"],
message: "Issuer is required",
code: z.ZodIssueCode.custom
});
}
if (!data.authorizationEndpoint) {
ctx.addIssue({
path: ["authorizationEndpoint"],
message: "Authorization endpoint is required",
code: z.ZodIssueCode.custom
});
}
if (!data.jwksUri) {
ctx.addIssue({
path: ["jwksUri"],
message: "JWKS URI is required",
code: z.ZodIssueCode.custom
});
}
if (!data.tokenEndpoint) {
ctx.addIssue({
path: ["tokenEndpoint"],
message: "Token endpoint is required",
code: z.ZodIssueCode.custom
});
}
if (!data.userinfoEndpoint) {
ctx.addIssue({
path: ["userinfoEndpoint"],
message: "Userinfo endpoint is required",
code: z.ZodIssueCode.custom
});
}
} else {
// eslint-disable-next-line no-lonely-if
if (!data.discoveryURL) {
ctx.addIssue({
path: ["discoveryURL"],
message: "Discovery URL is required",
code: z.ZodIssueCode.custom
});
}
}
});
export type OIDCFormData = z.infer<typeof schema>;
export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelete }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync: createMutateAsync, isPending: createIsLoading } = useCreateOIDCConfig();
const { mutateAsync: updateMutateAsync, isPending: updateIsLoading } = useUpdateOIDCConfig();
const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle(false);
const { data } = useGetOIDCConfig(currentOrg?.slug ?? "");
const { control, handleSubmit, reset, setValue, watch } = useForm<OIDCFormData>({
resolver: zodResolver(schema),
defaultValues: {
configurationType: ConfigurationType.DISCOVERY_URL
}
});
const [isClientIdFocused, setIsClientIdFocused] = useToggle();
const [isClientSecretFocused, setIsClientSecretFocused] = useToggle();
const configurationTypeValue = watch("configurationType");
const handleOidcSoftDelete = async () => {
if (!currentOrg) {
return;
}
try {
await updateMutateAsync({
issuer: "",
discoveryURL: "",
authorizationEndpoint: "",
allowedEmailDomains: "",
jwksUri: "",
tokenEndpoint: "",
userinfoEndpoint: "",
clientId: "",
clientSecret: "",
isActive: false,
orgSlug: currentOrg.slug
});
createNotification({
text: "Successfully deleted OIDC configuration.",
type: "success"
});
} catch {
createNotification({
text: "Failed deleting OIDC configuration.",
type: "error"
});
}
};
useEffect(() => {
if (data) {
setValue("issuer", data.issuer);
setValue("authorizationEndpoint", data.authorizationEndpoint);
setValue("jwksUri", data.jwksUri);
setValue("tokenEndpoint", data.tokenEndpoint);
setValue("userinfoEndpoint", data.userinfoEndpoint);
setValue("discoveryURL", data.discoveryURL);
setValue("clientId", data.clientId);
setValue("clientSecret", data.clientSecret);
setValue("allowedEmailDomains", data.allowedEmailDomains);
setValue("configurationType", data.configurationType);
}
}, [data]);
const onOIDCModalSubmit = async ({
issuer,
authorizationEndpoint,
allowedEmailDomains,
jwksUri,
tokenEndpoint,
userinfoEndpoint,
configurationType,
discoveryURL,
clientId,
clientSecret
}: OIDCFormData) => {
try {
if (!currentOrg) {
return;
}
if (!data) {
await createMutateAsync({
issuer,
configurationType,
discoveryURL,
authorizationEndpoint,
allowedEmailDomains,
jwksUri,
tokenEndpoint,
userinfoEndpoint,
clientId,
clientSecret,
isActive: true,
orgSlug: currentOrg.slug
});
} else {
await updateMutateAsync({
issuer,
configurationType,
discoveryURL,
authorizationEndpoint,
allowedEmailDomains,
jwksUri,
tokenEndpoint,
userinfoEndpoint,
clientId,
clientSecret,
isActive: true,
orgSlug: currentOrg.slug
});
}
handlePopUpClose("addOIDC");
createNotification({
text: `Successfully ${!data ? "added" : "updated"} OIDC SSO configuration`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${!data ? "add" : "update"} OIDC SSO configuration`,
type: "error"
});
}
};
return (
<>
<Modal
isOpen={popUp?.addOIDC?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addOIDC", isOpen);
reset();
}}
>
<ModalContent title="Manage OIDC configuration">
<form onSubmit={handleSubmit(onOIDCModalSubmit)}>
<Controller
control={control}
name="configurationType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Configuration Type"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
className="w-full"
defaultValue="discoveryURL"
{...field}
onValueChange={(e) => onChange(e)}
>
<SelectItem value={ConfigurationType.DISCOVERY_URL}>Discovery URL</SelectItem>
<SelectItem value={ConfigurationType.CUSTOM}>Custom</SelectItem>
</Select>
</FormControl>
)}
/>
{configurationTypeValue === ConfigurationType.DISCOVERY_URL && (
<Controller
control={control}
name="discoveryURL"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Discovery Document URL"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://accounts.google.com/.well-known/openid-configuration"
autoComplete="off"
/>
</FormControl>
)}
/>
)}
{configurationTypeValue === ConfigurationType.CUSTOM && (
<>
<Controller
control={control}
name="issuer"
render={({ field, fieldState: { error } }) => (
<FormControl label="Issuer" errorText={error?.message} isError={Boolean(error)}>
<Input
{...field}
placeholder="https://accounts.google.com"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="authorizationEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Authorization Endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://accounts.google.com/o/oauth2/v2/auth"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="tokenEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Token Endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://oauth2.googleapis.com/token"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="userinfoEndpoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label="User info endpoint"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://openidconnect.googleapis.com/v1/userinfo"
autoComplete="off"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="jwksUri"
render={({ field, fieldState: { error } }) => (
<FormControl
label="JWKS URI"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="https://www.googleapis.com/oauth2/v3/certs"
autoComplete="off"
/>
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="allowedEmailDomains"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Allowed Email Domains (defaults to any)"
errorText={error?.message}
isError={Boolean(error)}
>
<Input {...field} placeholder="infisical.com, google.com" autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="clientId"
render={({ field, fieldState: { error } }) => (
<FormControl label="Client ID" errorText={error?.message} isError={Boolean(error)}>
<Input
placeholder="Client ID"
type={isClientIdFocused ? "text" : "password"}
onFocus={() => setIsClientIdFocused.on()}
{...field}
onBlur={() => {
field.onBlur();
setIsClientIdFocused.off();
}}
autoComplete="off"
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="clientSecret"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Client Secret"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder="Client Secret"
type={isClientSecretFocused ? "text" : "password"}
autoComplete="off"
onFocus={() => setIsClientSecretFocused.on()}
onBlur={() => {
field.onBlur();
setIsClientSecretFocused.off();
}}
className="bg-mineshaft-800"
/>
</FormControl>
)}
/>
<div className="mt-8 flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!data ? "Add" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addOIDC")}
>
Cancel
</Button>
</div>
{!hideDelete && (
<Button colorSchema="danger" onClick={() => setIsDeletePopupOpen.on()}>
Delete
</Button>
)}
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={isDeletePopupOpen}
title="Are you sure want to delete OIDC?"
onChange={() => setIsDeletePopupOpen.toggle()}
deleteKey="confirm"
onDeleteApproved={handleOidcSoftDelete}
/>
</>
);
};
@@ -0,0 +1,195 @@
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { Button, ContentLoader } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useServerConfig,
useSubscription
} from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useGetLDAPConfig, useGetOIDCConfig, useGetSSOConfig } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
import { LDAPModal } from "./LDAPModal";
import { OIDCModal } from "./OIDCModal";
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
import { OrgGenericAuthSection } from "./OrgGenericAuthSection";
import { OrgLDAPSection } from "./OrgLDAPSection";
import { OrgOIDCSection } from "./OrgOIDCSection";
import { OrgScimSection } from "./OrgSCIMSection";
import { OrgSSOSection } from "./OrgSSOSection";
import { SSOModal } from "./SSOModal";
export const OrgAuthTab = withPermission(
() => {
const {
config: { enabledLoginMethods }
} = useServerConfig();
const { currentOrg } = useOrganization();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addLDAP",
"addSSO",
"addOIDC",
"upgradePlan"
] as const);
const { subscription } = useSubscription();
const { data: oidcConfig, isPending: isLoadingOidcConfig } = useGetOIDCConfig(
currentOrg?.slug ?? ""
);
const { data: samlConfig, isPending: isLoadingSamlConfig } = useGetSSOConfig(
currentOrg?.id ?? ""
);
const { data: ldapConfig, isPending: isLoadingLdapConfig } = useGetLDAPConfig(
currentOrg?.id ?? ""
);
const areConfigsLoading = isLoadingOidcConfig || isLoadingSamlConfig || isLoadingLdapConfig;
const shouldDisplaySection = (method: LoginMethod) =>
!enabledLoginMethods || enabledLoginMethods.includes(method);
const isOidcConfigured = oidcConfig && (oidcConfig.discoveryURL || oidcConfig.issuer);
const isSamlConfigured = samlConfig && samlConfig.entryPoint;
const isLdapConfigured = ldapConfig && ldapConfig.url;
const shouldShowCreateIdentityProviderView =
!isOidcConfigured && !isSamlConfigured && !isLdapConfigured;
const createIdentityProviderView = (shouldDisplaySection(LoginMethod.SAML) ||
shouldDisplaySection(LoginMethod.OIDC) ||
shouldDisplaySection(LoginMethod.LDAP)) && (
<>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<p className="text-xl font-semibold text-gray-200">Connect an Identity Provider</p>
<p className="mb-2 mt-1 text-gray-400">
Connect your identity provider to simplify user management
</p>
{shouldDisplaySection(LoginMethod.SAML) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
(shouldDisplaySection(LoginMethod.OIDC) ||
shouldDisplaySection(LoginMethod.LDAP)) &&
"border-b border-mineshaft-500 pb-4"
)}
>
<p className="text-lg text-gray-200">SAML</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan", { feature: "SAML SSO", plan: "Pro" });
return;
}
handlePopUpOpen("addSSO");
}}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.OIDC) && (
<div
className={twMerge(
"mt-4 flex items-center justify-between",
shouldDisplaySection(LoginMethod.LDAP) && "border-b border-mineshaft-500 pb-4"
)}
>
<p className="text-lg text-gray-200">OIDC</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan", { feature: "OIDC SSO", plan: "Pro" });
return;
}
handlePopUpOpen("addOIDC");
}}
>
Connect
</Button>
</div>
)}
{shouldDisplaySection(LoginMethod.LDAP) && (
<div className="mt-4 flex items-center justify-between">
<p className="text-lg text-gray-200">LDAP</p>
<Button
colorSchema="secondary"
onClick={() => {
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan", { feature: "LDAP", plan: "Enterprise" });
return;
}
handlePopUpOpen("addLDAP");
}}
>
Connect
</Button>
</div>
)}
</div>
<SSOModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<OIDCModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<LDAPModal
hideDelete
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</>
);
if (areConfigsLoading) {
return <ContentLoader />;
}
return (
<>
<OrgGenericAuthSection />
{shouldShowCreateIdentityProviderView ? (
createIdentityProviderView
) : (
<>
{isSamlConfigured && shouldDisplaySection(LoginMethod.SAML) && (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgGeneralAuthSection />
<OrgSSOSection />
</div>
)}
{isOidcConfigured && shouldDisplaySection(LoginMethod.OIDC) && <OrgOIDCSection />}
{isLdapConfigured && shouldDisplaySection(LoginMethod.LDAP) && <OrgLDAPSection />}
</>
)}
<OrgScimSection />
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={`You can use ${
(popUp.upgradePlan.data as { feature: string })?.feature
} if you switch to Infisical's ${
(popUp.upgradePlan.data as { plan: string })?.plan
} plan.`}
/>
</>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Sso }
);
@@ -0,0 +1,98 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useLogoutUser, useUpdateOrg } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
export const OrgGeneralAuthSection = () => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const { mutateAsync } = useUpdateOrg();
const logout = useLogoutUser();
const handleEnforceOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
authEnforced: value
});
createNotification({
text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`,
type: "success"
});
if (value) {
await logout.mutateAsync();
window.open(`/api/v1/sso/redirect/saml2/organizations/${currentOrg.slug}`);
window.close();
}
} catch (err) {
console.error(err);
createNotification({
text: (err as { response: { data: { message: string } } }).response.data.message,
type: "error"
});
}
};
return (
<>
{/* <div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Allow users to send invites</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="allow-org-invites"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Allow members to invite new users to this organization</p>
</div> */}
<div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Enforce SAML SSO</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate via SAML to access this organization
</p>
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can enforce SAML SSO if you switch to Infisical's Pro plan."
/>
</>
);
};
@@ -0,0 +1,117 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { FormControl, Select, SelectItem, Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const OrgGenericAuthSection = () => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const { mutateAsync } = useUpdateOrg();
const handleEnforceMfaToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.enforceMfa) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
enforceMfa: value
});
createNotification({
text: `Successfully ${value ? "enforced" : "un-enforced"} MFA`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: (err as { response: { data: { message: string } } }).response.data.message,
type: "error"
});
}
};
const handleUpdateSelectedMfa = async (selectedMfaMethod: MfaMethod) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.enforceMfa) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
selectedMfaMethod
});
createNotification({
text: "Successfully updated selected MFA method",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: (err as { response: { data: { message: string } } }).response.data.message,
type: "error"
});
}
};
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Enforce Multi-factor Authentication</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-mfa"
onCheckedChange={(value) => handleEnforceMfaToggle(value)}
isChecked={currentOrg?.enforceMfa ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate with MFA in order to access the organization
</p>
{currentOrg?.enforceMfa && (
<FormControl label="Selected 2FA method" className="mt-3">
<Select
className="min-w-[20rem] border border-mineshaft-500"
onValueChange={handleUpdateSelectedMfa}
defaultValue={currentOrg.selectedMfaMethod ?? MfaMethod.EMAIL}
>
<SelectItem value={MfaMethod.EMAIL} key="mfa-method-email">
Email
</SelectItem>
<SelectItem value={MfaMethod.TOTP} key="mfa-method-totp">
Mobile Authenticator
</SelectItem>
</Select>
</FormControl>
)}
</div>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can enforce user MFA if you switch to Infisical's Pro plan."
/>
</div>
);
};
@@ -0,0 +1,172 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useCreateLDAPConfig, useGetLDAPConfig, useUpdateLDAPConfig } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { LDAPGroupMapModal } from "./LDAPGroupMapModal";
import { LDAPModal } from "./LDAPModal";
export const OrgLDAPSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data } = useGetLDAPConfig(currentOrg?.id ?? "");
const { mutateAsync } = useUpdateLDAPConfig();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addLDAP",
"ldapGroupMap",
"deleteLdapGroupMap",
"upgradePlan"
] as const);
const { mutateAsync: createMutateAsync } = useCreateLDAPConfig();
const handleLDAPToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
isActive: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} LDAP`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enable" : "disable"} LDAP`,
type: "error"
});
}
};
const addLDAPBtnClick = async () => {
try {
if (subscription?.ldap && currentOrg) {
if (!data) {
// case: LDAP is not configured
// -> initialize empty LDAP configuration
await createMutateAsync({
organizationId: currentOrg.id,
isActive: false,
url: "",
bindDN: "",
bindPass: "",
uniqueUserAttribute: "",
searchBase: "",
searchFilter: "",
groupSearchBase: "",
groupSearchFilter: ""
});
}
handlePopUpOpen("addLDAP");
} else {
handlePopUpOpen("upgradePlan");
}
} catch (err) {
console.error(err);
}
};
const openLDAPGroupMapModal = () => {
if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("ldapGroupMap");
};
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">LDAP</h2>
<div className="flex">
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Button onClick={addLDAPBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
Manage
</Button>
)}
</OrgPermissionCan>
</div>
</div>
<p className="text-sm text-mineshaft-300">Manage LDAP authentication configuration</p>
</div>
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">LDAP Group Mappings</h2>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Button
onClick={openLDAPGroupMapModal}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Manage how LDAP groups are mapped to internal groups in Infisical
</p>
</div>
{data && (
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable LDAP</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Ldap}>
{(isAllowed) => (
<Switch
id="enable-saml-sso"
onCheckedChange={(value) => handleLDAPToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
>
Enable
</Switch>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Allow members to authenticate into Infisical with LDAP
</p>
</div>
)}
<LDAPModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<LDAPGroupMapModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use LDAP authentication if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -0,0 +1,163 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useGetOIDCConfig, useLogoutUser, useUpdateOrg } from "@app/hooks/api";
import { useUpdateOIDCConfig } from "@app/hooks/api/oidcConfig/mutations";
import { usePopUp } from "@app/hooks/usePopUp";
import { OIDCModal } from "./OIDCModal";
export const OrgOIDCSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data, isPending } = useGetOIDCConfig(currentOrg?.slug ?? "");
const { mutateAsync } = useUpdateOIDCConfig();
const { mutateAsync: updateOrg } = useUpdateOrg();
const logout = useLogoutUser();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addOIDC",
"upgradePlan"
] as const);
const handleOIDCToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgSlug: currentOrg?.slug,
isActive: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} OIDC SSO`,
type: "success"
});
} catch (err) {
console.error(err);
}
};
const handleEnforceOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.oidcSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await updateOrg({
orgId: currentOrg?.id,
authEnforced: value
});
createNotification({
text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`,
type: "success"
});
if (value) {
await logout.mutateAsync();
window.open(`/api/v1/sso/oidc/login?orgSlug=${currentOrg.slug}`);
window.close();
}
} catch (err) {
console.error(err);
}
};
const addOidcButtonClick = async () => {
if (subscription?.oidcSSO && currentOrg) {
handlePopUpOpen("addOIDC");
} else {
handlePopUpOpen("upgradePlan");
}
};
return (
<div className="mb-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">OIDC</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Button
onClick={addOidcButtonClick}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Manage
</Button>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">Manage OIDC authentication configuration</p>
</div>
{data && (
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable OIDC</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-oidc-sso"
onCheckedChange={(value) => handleOIDCToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">
Allow members to authenticate into Infisical with OIDC
</p>
</div>
)}
<div className="py-4">
<div className="mb-2 flex justify-between">
<h3 className="text-md text-mineshaft-100">Enforce OIDC SSO</h3>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
isChecked={currentOrg?.authEnforced ?? false}
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Enforce members to authenticate via OIDC to access this organization
</p>
</div>
<OIDCModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use OIDC SSO if you switch to Infisical's Pro plan."
/>
</div>
);
};
@@ -0,0 +1,118 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ExternalGroupOrgRoleMappings } from "./ExternalGroupOrgRoleMappings";
import { ScimTokenModal } from "./ScimTokenModal";
export const OrgScimSection = () => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"scimToken",
"deleteScimToken",
"upgradePlan"
] as const);
const { mutateAsync } = useUpdateOrg();
const addScimTokenBtnClick = () => {
if (subscription?.scim) {
handlePopUpOpen("scimToken");
} else {
handlePopUpOpen("upgradePlan");
}
};
const handleEnableSCIMToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.scim) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
orgId: currentOrg?.id,
scimEnabled: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} SCIM provisioning`,
type: "success"
});
} catch (err) {
createNotification({
text: (err as { response: { data: { message: string } } }).response.data.message,
type: "error"
});
}
};
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<p className="text-xl font-semibold text-gray-200">Provision users via SCIM</p>
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">SCIM</h2>
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Scim}>
{(isAllowed) => (
<Button
onClick={addScimTokenBtnClick}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Configure
</Button>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">Manage SCIM configuration</p>
</div>
<ExternalGroupOrgRoleMappings />
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable SCIM</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Scim}>
{(isAllowed) => (
<Switch
id="enable-scim"
onCheckedChange={(value) => {
if (subscription?.scim) {
handleEnableSCIMToggle(value);
} else {
handlePopUpOpen("upgradePlan");
}
}}
isChecked={currentOrg?.scimEnabled ?? false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Allow member provisioning/deprovisioning with SCIM
</p>
</div>
<ScimTokenModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SCIM Provisioning if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -0,0 +1,131 @@
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Switch } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { useCreateSSOConfig, useGetSSOConfig, useUpdateSSOConfig } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { SSOModal } from "./SSOModal";
export const OrgSSOSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data, isPending } = useGetSSOConfig(currentOrg?.id ?? "");
const { mutateAsync } = useUpdateSSOConfig();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"upgradePlan",
"addSSO"
] as const);
const { mutateAsync: createMutateAsync } = useCreateSSOConfig();
const handleSamlSSOToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
if (!subscription?.samlSSO) {
handlePopUpOpen("upgradePlan");
return;
}
await mutateAsync({
organizationId: currentOrg?.id,
isActive: value
});
createNotification({
text: `Successfully ${value ? "enabled" : "disabled"} SAML SSO`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enable" : "disable"} SAML SSO`,
type: "error"
});
}
};
const addSSOBtnClick = async () => {
try {
if (subscription?.samlSSO && currentOrg) {
if (!data) {
// case: SAML SSO is not configured
// -> initialize empty SAML SSO configuration
await createMutateAsync({
organizationId: currentOrg.id,
authProvider: "okta-saml",
isActive: false,
entryPoint: "",
issuer: "",
cert: ""
});
}
handlePopUpOpen("addSSO");
} else {
handlePopUpOpen("upgradePlan");
}
} catch (err) {
console.error(err);
}
};
return (
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">SAML</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Button onClick={addSSOBtnClick} colorSchema="secondary" isDisabled={!isAllowed}>
Manage
</Button>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">Manage SAML authentication configuration</p>
</div>
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable SAML</h2>
{!isPending && (
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enable-saml-sso"
onCheckedChange={(value) => handleSamlSSOToggle(value)}
isChecked={data ? data.isActive : false}
isDisabled={!isAllowed}
/>
)}
</OrgPermissionCan>
)}
</div>
<p className="text-sm text-mineshaft-300">
Allow members to authenticate into Infisical with SAML
</p>
</div>
<SSOModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use SAML SSO if you switch to Infisical's Pro plan."
/>
</>
);
};
@@ -0,0 +1,343 @@
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,
DeleteActionModal,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import { useCreateSSOConfig, useGetSSOConfig, useUpdateSSOConfig } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
enum AuthProvider {
OKTA_SAML = "okta-saml",
AZURE_SAML = "azure-saml",
JUMPCLOUD_SAML = "jumpcloud-saml",
KEYCLOAK_SAML = "keycloak-saml",
GOOGLE_SAML = "google-saml"
}
const ssoAuthProviders = [
{ label: "Okta SAML", value: AuthProvider.OKTA_SAML },
{ label: "Azure / Entra SAML", value: AuthProvider.AZURE_SAML },
{ label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML },
{ label: "Keycloak SAML", value: AuthProvider.KEYCLOAK_SAML },
{ label: "Google SAML", value: AuthProvider.GOOGLE_SAML }
];
const schema = z
.object({
authProvider: z.string().min(1, "SSO Type is required"),
entryPoint: z.string().default(""),
issuer: z.string().default(""),
cert: z.string().default("")
})
.required();
export type AddSSOFormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["addSSO"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addSSO"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addSSO"]>, state?: boolean) => void;
hideDelete?: boolean;
};
export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDelete }: Props) => {
const { currentOrg } = useOrganization();
const { mutateAsync: createMutateAsync, isPending: createIsLoading } = useCreateSSOConfig();
const { mutateAsync: updateMutateAsync, isPending: updateIsLoading } = useUpdateSSOConfig();
const [isDeletePopupOpen, setIsDeletePopupOpen] = useToggle();
const { data } = useGetSSOConfig(currentOrg?.id ?? "");
const { control, handleSubmit, reset, watch, setValue, getValues } = useForm<AddSSOFormData>({
defaultValues: {
authProvider: AuthProvider.OKTA_SAML
},
resolver: zodResolver(schema)
});
useEffect(() => {
if (data) {
reset({
authProvider: data?.authProvider ?? "",
entryPoint: data?.entryPoint ?? "",
issuer: data?.issuer ?? "",
cert: data?.cert ?? ""
});
}
}, [data]);
const handleSamlSoftDelete = async () => {
if (!currentOrg) {
return;
}
try {
await updateMutateAsync({
organizationId: currentOrg.id,
isActive: false,
entryPoint: "",
issuer: "",
cert: ""
});
createNotification({
text: "Successfully deleted SAML SSO configuration.",
type: "success"
});
} catch {
createNotification({
text: "Failed deleting SAML SSO configuration.",
type: "error"
});
}
};
const onSSOModalSubmit = async ({ authProvider, entryPoint, issuer, cert }: AddSSOFormData) => {
try {
if (!currentOrg) return;
if (!data) {
await createMutateAsync({
organizationId: currentOrg.id,
authProvider,
isActive: false,
entryPoint,
issuer,
cert
});
} else {
await updateMutateAsync({
organizationId: currentOrg.id,
authProvider,
isActive: false,
entryPoint,
issuer,
cert
});
}
handlePopUpClose("addSSO");
createNotification({
text: `Successfully ${!data ? "added" : "updated"} SAML SSO configuration`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${!data ? "add" : "update"} SAML SSO configuration`,
type: "error"
});
}
};
const renderLabels = (authProvider: string) => {
switch (authProvider) {
case AuthProvider.OKTA_SAML:
return {
acsUrl: "Single sign-on URL",
entityId: "Audience URI (SP Entity ID)",
entryPoint: "Identity Provider Single Sign-On URL",
entryPointPlaceholder: "https://your-domain.okta.com/app/app-name/xxx/sso/saml",
issuer: "Identity Provider Issuer",
issuerPlaceholder: "http://www.okta.com/xxx"
};
case AuthProvider.AZURE_SAML:
return {
acsUrl: "Reply URL (Assertion Consumer Service URL)",
entityId: "Identifier (Entity ID)",
entryPoint: "Login URL",
entryPointPlaceholder: "https://login.microsoftonline.com/xxx/saml2",
issuer: "Azure / Entra Application ID",
issuerPlaceholder: "abc-def-ghi-jkl-mno"
};
case AuthProvider.JUMPCLOUD_SAML:
return {
acsUrl: "ACS URL",
entityId: "SP Entity ID",
entryPoint: "IDP URL",
entryPointPlaceholder: "https://sso.jumpcloud.com/saml2/xxx",
issuer: "IdP Entity ID",
issuerPlaceholder: "xxx"
};
case AuthProvider.KEYCLOAK_SAML:
return {
acsUrl: "Valid redirect URI",
entityId: "SP Entity ID",
entryPoint: "IDP URL",
entryPointPlaceholder: "https://keycloak.mysite.com/realms/myrealm/protocol/saml",
issuer: "Client ID",
issuerPlaceholder: window.origin
};
case AuthProvider.GOOGLE_SAML:
return {
acsUrl: "ACS URL",
entityId: "SP Entity ID",
entryPoint: "SSO URL",
entryPointPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx",
issuer: "Issuer",
issuerPlaceholder: window.origin
};
default:
return {
acsUrl: "ACS URL",
entityId: "Entity ID",
entryPoint: "Entrypoint",
entryPointPlaceholder: "Enter entrypoint...",
issuer: "Issuer",
issuerPlaceholder: "Enter placeholder..."
};
}
};
const authProvider = watch("authProvider");
useEffect(() => {
if (authProvider === AuthProvider.GOOGLE_SAML && getValues("issuer") === "") {
setValue("issuer", window.origin);
}
}, [authProvider]);
return (
<>
<Modal
isOpen={popUp?.addSSO?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addSSO", isOpen);
reset();
}}
>
<ModalContent title="Manage SAML configuration">
<form onSubmit={handleSubmit(onSSOModalSubmit)}>
<Controller
control={control}
name="authProvider"
defaultValue="okta-saml"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{ssoAuthProviders.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{authProvider && data && (
<>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">
{renderLabels(authProvider).acsUrl}
</h3>
<p className="text-md break-all text-gray-400">{`${window.origin}/api/v1/sso/saml2/${data.id}`}</p>
</div>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">
{renderLabels(authProvider).entityId}
</h3>
<p className="text-md text-gray-400">{window.origin}</p>
</div>
<Controller
control={control}
name="entryPoint"
render={({ field, fieldState: { error } }) => (
<FormControl
label={renderLabels(authProvider).entryPoint}
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder={renderLabels(authProvider).entryPointPlaceholder}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="issuer"
render={({ field, fieldState: { error } }) => (
<FormControl
label={renderLabels(authProvider).issuer}
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...field}
placeholder={renderLabels(authProvider).issuerPlaceholder}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="cert"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Certificate"
errorText={error?.message}
isError={Boolean(error)}
>
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex justify-between">
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={createIsLoading || updateIsLoading}
>
{!data ? "Add" : "Update"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addSSO")}
>
Cancel
</Button>
</div>
{!hideDelete && (
<Button colorSchema="danger" onClick={() => setIsDeletePopupOpen.on()}>
Delete
</Button>
)}
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={isDeletePopupOpen}
title="Are you sure want to delete SAML SSO?"
onChange={() => setIsDeletePopupOpen.toggle()}
deleteKey="confirm"
onDeleteApproved={handleSamlSoftDelete}
/>
</>
);
};
@@ -0,0 +1,318 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format } from "date-fns";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
EmptyState,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import { useCreateScimToken, useDeleteScimToken, useGetScimTokens } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
description: z.string().optional(),
ttlDays: z.string()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["scimToken", "deleteScimToken"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteScimToken"]>,
data?: {
scimTokenId: string;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["scimToken", "deleteScimToken"]>,
state?: boolean
) => void;
};
export const ScimTokenModal = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { t } = useTranslation();
const [token, setToken] = useState("");
const [isScimUrlCopied, setIsScimUrlCopied] = useToggle(false);
const [isScimTokenCopied, setIsScimTokenCopied] = useToggle(false);
const { data, isPending } = useGetScimTokens(currentOrg?.id ?? "");
const { mutateAsync: createScimTokenMutateAsync } = useCreateScimToken();
const { mutateAsync: deleteScimTokenMutateAsync } = useDeleteScimToken();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
description: "",
ttlDays: "365"
}
});
useEffect(() => {
let timer: NodeJS.Timeout;
if (isScimUrlCopied) {
timer = setTimeout(() => setIsScimUrlCopied.off(), 2000);
}
if (isScimTokenCopied) {
timer = setTimeout(() => setIsScimTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isScimTokenCopied, isScimUrlCopied]);
const onFormSubmit = async ({ description, ttlDays }: FormData) => {
try {
if (!currentOrg?.id) return;
const { scimToken } = await createScimTokenMutateAsync({
organizationId: currentOrg.id,
description,
ttlDays: Number(ttlDays)
});
setToken(scimToken);
createNotification({
text: "Successfully created SCIM token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create SCIM token",
type: "error"
});
}
};
const onDeleteScimTokenSubmit = async (scimTokenId: string) => {
try {
if (!currentOrg?.id) return;
await deleteScimTokenMutateAsync({
organizationId: currentOrg.id,
scimTokenId
});
handlePopUpToggle("deleteScimToken", false);
createNotification({
text: "Successfully deleted SCIM token",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete SCIM token",
type: "error"
});
}
};
const hasToken = Boolean(token);
const scimUrl = `${window.origin}/api/v1/scim`;
return (
<Modal
isOpen={popUp?.scimToken?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("scimToken", isOpen);
reset();
setToken("");
}}
>
<ModalContent title="Manage SCIM credentials">
<h2 className="mb-4">SCIM URL</h2>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{scimUrl}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(scimUrl);
setIsScimUrlCopied.on();
}}
>
<FontAwesomeIcon icon={isScimUrlCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
<h2 className="mb-4">New SCIM Token</h2>
{hasToken ? (
<div>
<div className="mb-4 flex items-center justify-between">
<p>We will only show this token once</p>
<Button
colorSchema="secondary"
type="submit"
onClick={() => {
reset();
setToken("");
}}
>
Got it
</Button>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{token}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(token);
setIsScimTokenCopied.on();
}}
>
<FontAwesomeIcon icon={isScimTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
</div>
) : (
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-8">
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Description (optional)"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Description" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue="365"
name="ttlDays"
render={({ field, fieldState: { error } }) => (
<FormControl label="TTL (days)" isError={Boolean(error)} errorText={error?.message}>
<div className="flex">
<Input {...field} placeholder="0" type="number" min="0" step="1" />
<Button
className="ml-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create
</Button>
</div>
</FormControl>
)}
/>
</form>
)}
<h2 className="mb-4">SCIM Tokens</h2>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Description</Th>
<Th>Expires At</Th>
<Th>Created At</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="org-scim-tokens" />}
{!isPending &&
data &&
data.length > 0 &&
data.map(({ id, description, ttlDays, createdAt }) => {
let expiresAt;
if (ttlDays > 0) {
expiresAt = new Date(new Date(createdAt).getTime() + ttlDays * 86400 * 1000);
}
return (
<Tr className="h-10 items-center" key={`mi-client-secret-${id}`}>
<Td>{description === "" ? "-" : description}</Td>
<Td>{expiresAt ? format(expiresAt, "yyyy-MM-dd HH:mm:ss") : "-"}</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss")}</Td>
<Td>
<IconButton
onClick={() => {
handlePopUpOpen("deleteScimToken", {
scimTokenId: id
});
}}
size="lg"
colorSchema="primary"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
</Td>
</Tr>
);
})}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState title="No SCIM tokens have been created yet" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteActionModal
isOpen={popUp.deleteScimToken.isOpen}
title="Are you sure want to delete the SCIM token?"
onChange={(isOpen) => handlePopUpToggle("scimToken", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const deleteScimTokenData = popUp?.deleteScimToken?.data as {
scimTokenId: string;
};
return onDeleteScimTokenSubmit(deleteScimTokenData.scimTokenId);
}}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { OrgAuthTab } from "./OrgAuthTab";
@@ -0,0 +1,73 @@
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
import { useOrganization, useOrgPermission } from "@app/context";
import { useDeleteOrgById } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { navigateUserToOrg } from "@app/pages/auth/LoginPage/Login.utils";
export const OrgDeleteSection = () => {
const navigate = useNavigate();
const { currentOrg } = useOrganization();
const { membership } = useOrgPermission();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteOrg"
] as const);
const { mutateAsync, isPending } = useDeleteOrgById();
const handleDeleteOrgSubmit = async () => {
try {
if (!currentOrg?.id) return;
await mutateAsync({
organizationId: currentOrg?.id
});
createNotification({
text: "Successfully deleted organization",
type: "success"
});
await navigateUserToOrg(navigate);
handlePopUpClose("deleteOrg");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete organization",
type: "error"
});
}
};
return (
<>
<hr className="border-mineshaft-600" />
<div className="py-4">
<p className="text-md mb-4 text-mineshaft-100">Danger Zone</p>
<Button
isLoading={isPending}
colorSchema="danger"
variant="outline_bg"
type="submit"
onClick={() => handlePopUpOpen("deleteOrg")}
isDisabled={Boolean(membership && membership.role !== "admin")}
>
{`Delete ${currentOrg?.name}`}
</Button>
</div>
<DeleteActionModal
isOpen={popUp.deleteOrg.isOpen}
title="Are you sure want to delete this organization?"
subTitle={`Permanently remove ${currentOrg?.name} and all of its data. This action is not reversible, so please be careful.`}
onChange={(isOpen) => handlePopUpToggle("deleteOrg", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleDeleteOrgSubmit}
/>
</>
);
};
@@ -0,0 +1 @@
export { OrgDeleteSection } from "./OrgDeleteSection";
@@ -0,0 +1,116 @@
import { useState } from "react";
import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
import { AwsKmsForm } from "./AwsKmsForm";
import { GcpKmsForm } from "./GcpKmsForm";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
};
enum WizardSteps {
SelectProvider = "select-provider",
ProviderInputs = "provider-inputs"
}
const EXTERNAL_KMS_LIST = [
{
icon: faAws,
provider: ExternalKmsProvider.Aws,
title: "AWS KMS"
},
{
icon: faGoogle,
provider: ExternalKmsProvider.Gcp,
title: "GCP KMS"
}
];
export const AddExternalKmsForm = ({ isOpen, onToggle }: Props) => {
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectProvider);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
const handleFormReset = (state: boolean = false) => {
onToggle(state);
setWizardStep(WizardSteps.SelectProvider);
setSelectedProvider(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={(state) => handleFormReset(state)}>
<ModalContent
title="Add a Key Management System"
subTitle="Configure an external key management system (KMS)"
className="my-4"
bodyClassName="overflow-visible"
>
<AnimatePresence mode="wait">
{wizardStep === WizardSteps.SelectProvider && (
<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 KMS Provider</div>
<div className="flex items-center space-x-4">
{EXTERNAL_KMS_LIST.map(({ icon, provider, title }) => (
<div
key={`kms-${provider}`}
className="flex h-28 w-32 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"
role="button"
tabIndex={0}
onClick={() => {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedProvider(provider);
setWizardStep(WizardSteps.ProviderInputs);
}
}}
>
<FontAwesomeIcon icon={icon} size="lg" />
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
))}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === ExternalKmsProvider.Aws && (
<motion.div
key="kms-aws"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AwsKmsForm onCancel={() => onToggle(false)} onCompleted={() => onToggle(false)} />
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === ExternalKmsProvider.Gcp && (
<motion.div
key="kms-gcp"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<GcpKmsForm onCancel={() => onToggle(false)} onCompleted={() => onToggle(false)} />
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,273 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { Badge, Button, FormControl, Input, Select, SelectItem } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useAddExternalKms, useUpdateExternalKms } from "@app/hooks/api";
import {
AddExternalKmsSchema,
AddExternalKmsType,
ExternalKmsProvider,
Kms,
KmsAwsCredentialType
} from "@app/hooks/api/kms/types";
const AWS_REGIONS = [
{ name: "US East (Ohio)", slug: "us-east-2" },
{ name: "US East (N. Virginia)", slug: "us-east-1" },
{ name: "US West (N. California)", slug: "us-west-1" },
{ name: "US West (Oregon)", slug: "us-west-2" },
{ name: "Africa (Cape Town)", slug: "af-south-1" },
{ name: "Asia Pacific (Hong Kong)", slug: "ap-east-1" },
{ name: "Asia Pacific (Hyderabad)", slug: "ap-south-2" },
{ name: "Asia Pacific (Jakarta)", slug: "ap-southeast-3" },
{ name: "Asia Pacific (Melbourne)", slug: "ap-southeast-4" },
{ name: "Asia Pacific (Mumbai)", slug: "ap-south-1" },
{ name: "Asia Pacific (Osaka)", slug: "ap-northeast-3" },
{ name: "Asia Pacific (Seoul)", slug: "ap-northeast-2" },
{ name: "Asia Pacific (Singapore)", slug: "ap-southeast-1" },
{ name: "Asia Pacific (Sydney)", slug: "ap-southeast-2" },
{ name: "Asia Pacific (Tokyo)", slug: "ap-northeast-1" },
{ name: "Canada (Central)", slug: "ca-central-1" },
{ name: "Europe (Frankfurt)", slug: "eu-central-1" },
{ name: "Europe (Ireland)", slug: "eu-west-1" },
{ name: "Europe (London)", slug: "eu-west-2" },
{ name: "Europe (Milan)", slug: "eu-south-1" },
{ name: "Europe (Paris)", slug: "eu-west-3" },
{ name: "Europe (Spain)", slug: "eu-south-2" },
{ name: "Europe (Stockholm)", slug: "eu-north-1" },
{ name: "Europe (Zurich)", slug: "eu-central-2" },
{ name: "Middle East (Bahrain)", slug: "me-south-1" },
{ name: "Middle East (UAE)", slug: "me-central-1" },
{ name: "South America (Sao Paulo)", slug: "sa-east-1" },
{ name: "AWS GovCloud (US-East)", slug: "us-gov-east-1" },
{ name: "AWS GovCloud (US-West)", slug: "us-gov-west-1" }
];
type Props = {
onCompleted: () => void;
onCancel: () => void;
kms?: Kms;
};
export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
const {
control,
handleSubmit,
watch,
setValue,
formState: { isSubmitting }
} = useForm<AddExternalKmsType>({
resolver: zodResolver(AddExternalKmsSchema),
defaultValues: {
name: kms?.name,
description: kms?.description ?? "",
provider: {
type: ExternalKmsProvider.Aws,
inputs: {
credential: {
type: kms?.external?.providerInput?.credential?.type,
data: {
accessKey: kms?.external?.providerInput?.credential?.data?.accessKey,
secretKey: kms?.external?.providerInput?.credential?.data?.secretKey,
assumeRoleArn: kms?.external?.providerInput?.credential?.data?.assumeRoleArn,
externalId: kms?.external?.providerInput?.credential?.data?.externalId
}
},
awsRegion: kms?.external?.providerInput?.awsRegion,
kmsKeyId: kms?.external?.providerInput?.kmsKeyId
}
}
}
});
const { currentOrg } = useOrganization();
const { mutateAsync: addAwsExternalKms } = useAddExternalKms(currentOrg.id);
const { mutateAsync: updateAwsExternalKms } = useUpdateExternalKms(currentOrg.id);
const selectedAwsAuthType = watch("provider.inputs.credential.type");
const handleAwsKmsFormSubmit = async (data: AddExternalKmsType) => {
const { name, description, provider } = data;
try {
if (kms) {
await updateAwsExternalKms({
kmsId: kms.id,
name,
description,
provider
});
createNotification({
text: "Successfully updated AWS External KMS",
type: "success"
});
} else {
await addAwsExternalKms({
name,
description,
provider
});
createNotification({
text: "Successfully added AWS External KMS",
type: "success"
});
}
onCompleted();
} catch (err) {
console.error(err);
}
};
return (
<form onSubmit={handleSubmit(handleAwsKmsFormSubmit)} autoComplete="off">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Alias" 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>
)}
/>
<Controller
control={control}
name="provider.inputs.credential.type"
defaultValue={KmsAwsCredentialType.AssumeRole}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Authentication Mode"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
setValue("provider.inputs.credential.data.accessKey", "");
setValue("provider.inputs.credential.data.secretKey", "");
setValue("provider.inputs.credential.data.assumeRoleArn", "");
setValue("provider.inputs.credential.data.externalId", "");
onChange(e);
}}
className="w-full"
>
<SelectItem value={KmsAwsCredentialType.AssumeRole}>AWS Assume Role</SelectItem>
<SelectItem value={KmsAwsCredentialType.AccessKey}>Access Key</SelectItem>
</Select>
</FormControl>
)}
/>
{selectedAwsAuthType === KmsAwsCredentialType.AccessKey ? (
<>
<Controller
control={control}
name="provider.inputs.credential.data.accessKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Key ID"
errorText={error?.message}
isError={Boolean(error)}
>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.inputs.credential.data.secretKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Access Key"
errorText={error?.message}
isError={Boolean(error)}
>
<Input type="password" autoComplete="new-password" placeholder="" {...field} />
</FormControl>
)}
/>
</>
) : (
<>
<Controller
control={control}
name="provider.inputs.credential.data.assumeRoleArn"
render={({ field, fieldState: { error } }) => (
<FormControl
label="IAM Role ARN For Role Assumption"
errorText={error?.message}
isError={Boolean(error)}
>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.inputs.credential.data.externalId"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Assume Role External ID"
errorText={error?.message}
isError={Boolean(error)}
>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="provider.inputs.awsRegion"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="AWS Region" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mineshaft-500"
>
{AWS_REGIONS.map((awsRegion) => (
<SelectItem value={awsRegion.slug} key={`kms-aws-region-${awsRegion.slug}`}>
{awsRegion.name} <Badge variant="success">{awsRegion.slug}</Badge>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.inputs.kmsKeyId"
render={({ field, fieldState: { error } }) => (
<FormControl label="AWS KMS Key ID" errorText={error?.message} isError={Boolean(error)}>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
<div className="mt-6 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,360 @@
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import { Badge, Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { useOrganization } from "@app/context";
import {
useAddExternalKms,
useExternalKmsFetchGcpKeys,
useUpdateExternalKms
} from "@app/hooks/api";
import {
AddExternalKmsGcpFormSchema,
AddExternalKmsGcpFormSchemaType,
ExternalKmsGcpCredentialSchema,
ExternalKmsGcpCredentialSchemaType,
ExternalKmsProvider,
Kms
} from "@app/hooks/api/kms/types";
type Props = {
onCompleted: () => void;
onCancel: () => void;
kms?: Kms;
};
const GCP_REGIONS = [
{ label: "Johannesburg", value: "africa-south1" },
{ label: "Taiwan", value: "asia-east1" },
{ label: "Hong Kong", value: "asia-east2" },
{ label: "Tokyo", value: "asia-northeast1" },
{ label: "Osaka", value: "asia-northeast2" },
{ label: "Seoul", value: "asia-northeast3" },
{ label: "Mumbai", value: "asia-south1" },
{ label: "Delhi", value: "asia-south2" },
{ label: "Singapore", value: "asia-southeast1" },
{ label: "Jakarta", value: "asia-southeast2" },
{ label: "Sydney", value: "australia-southeast1" },
{ label: "Melbourne", value: "australia-southeast2" },
{ label: "Warsaw", value: "europe-central2" },
{ label: "Finland", value: "europe-north1" },
{ label: "Belgium", value: "europe-west1" },
{ label: "London", value: "europe-west2" },
{ label: "Frankfurt", value: "europe-west3" },
{ label: "Netherlands", value: "europe-west4" },
{ label: "Zurich", value: "europe-west6" },
{ label: "Milan", value: "europe-west8" },
{ label: "Paris", value: "europe-west9" },
{ label: "Berlin", value: "europe-west10" },
{ label: "Turin", value: "europe-west12" },
{ label: "Madrid", value: "europe-southwest1" },
{ label: "Doha", value: "me-central1" },
{ label: "Dammam", value: "me-central2" },
{ label: "Tel Aviv", value: "me-west1" },
{ label: "Montréal", value: "northamerica-northeast1" },
{ label: "Toronto", value: "northamerica-northeast2" },
{ label: "São Paulo", value: "southamerica-east1" },
{ label: "Santiago", value: "southamerica-west1" },
{ label: "Iowa", value: "us-central1" },
{ label: "South Carolina", value: "us-east1" },
{ label: "North Virginia", value: "us-east4" },
{ label: "Columbus", value: "us-east5" },
{ label: "Dallas", value: "us-south1" },
{ label: "Oregon", value: "us-west1" },
{ label: "Los Angeles", value: "us-west2" },
{ label: "Salt Lake City", value: "us-west3" },
{ label: "Las Vegas", value: "us-west4" }
];
const formatOptionLabel = ({ value, label }: { value: string; label: string }) => (
<div className="flex w-full flex-row items-center justify-between">
<span>{label}</span>
<Badge variant="success">{value}</Badge>
</div>
);
export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
const [isCredentialValid, setIsCredentialValid] = useState<boolean>(false);
const [keys, setKeys] = useState<{ value: string; label: string }[]>([]);
const {
control,
handleSubmit,
setError,
clearErrors,
getValues,
resetField,
setValue,
formState: { isSubmitting }
} = useForm<AddExternalKmsGcpFormSchemaType>({
resolver: zodResolver(AddExternalKmsGcpFormSchema),
defaultValues: {
formType: kms ? "updateGcpKms" : "newGcpKms",
name: kms?.name ?? "",
description: kms?.description ?? "",
gcpRegion: kms
? {
label:
GCP_REGIONS.find((r) => r.value === kms.external.providerInput.gcpRegion)?.label ??
"",
value: kms.external.providerInput.gcpRegion
}
: undefined,
keyObject: undefined
}
});
const { currentOrg } = useOrganization();
const { mutateAsync: addGcpExternalKms } = useAddExternalKms(currentOrg.id);
const { mutateAsync: updateGcpExternalKms } = useUpdateExternalKms(currentOrg.id);
const { mutateAsync: fetchGcpKeys, isPending: isFetchGcpKeysLoading } =
useExternalKmsFetchGcpKeys(currentOrg?.id);
// transforms the credential file into a JSON object
async function getCredentialFileJson(): Promise<ExternalKmsGcpCredentialSchemaType | null> {
const files = getValues("credentialFile");
if (!files || !files.length) {
return null;
}
const file = files[0];
if (file.type !== "application/json") {
setError("credentialFile", {
message: "Only .json files are accepted."
});
return null;
}
const jsonContents = await file.text();
const parsedJson = ExternalKmsGcpCredentialSchema.safeParse(JSON.parse(jsonContents));
if (!parsedJson.success) {
setError("credentialFile", {
message: "Invalid Service Account credential JSON."
});
return null;
}
clearErrors("credentialFile");
return parsedJson.data;
}
// handles the form submission
const handleGcpKmsFormSubmit = async (data: AddExternalKmsGcpFormSchemaType) => {
const { name, description, gcpRegion: gcpRegionObject, keyObject } = data;
const gcpRegion = gcpRegionObject.value;
if (!keys.find((k) => k.value === keyObject?.value)) {
setError("keyObject", {
message: "Please select a valid key."
});
resetField("keyObject");
return;
}
try {
if (kms) {
await updateGcpExternalKms({
kmsId: kms.id,
name,
description,
provider: {
type: ExternalKmsProvider.Gcp,
inputs: {
gcpRegion,
keyName: keyObject?.value
}
}
});
createNotification({
text: "Successfully updated GCP External KMS",
type: "success"
});
} else {
const credentialJson = await getCredentialFileJson();
if (!credentialJson) {
return;
}
await addGcpExternalKms({
name,
description,
provider: {
type: ExternalKmsProvider.Gcp,
inputs: {
gcpRegion,
keyName: keyObject?.value,
credential: credentialJson
}
}
});
createNotification({
text: "Successfully added GCP External KMS",
type: "success"
});
}
onCompleted();
} catch (err) {
console.error(err);
}
};
const fetchGCPKeys = async () => {
// @ts-expect-error - issue with the way react-select renders the placeholder. We need to set the value to null explicitly otherwise it will not re-render
setValue("keyObject", null);
setKeys([]);
const credentialJson = kms ? undefined : await getCredentialFileJson();
if (!kms && !credentialJson) {
return;
}
const gcpRegion = getValues("gcpRegion").value;
if (!gcpRegion.length) {
setError("gcpRegion", {
message: "Please select a GCP region to fetch GCP Keys."
});
return;
}
const res = await fetchGcpKeys({
gcpRegion,
...(kms ? { kmsId: kms.id } : { credential: credentialJson! })
});
setIsCredentialValid(!!res.keys);
const returnedKeys = res.keys.map((key) => {
const parts = key.split("/");
const keyLabel = `${parts[5]}/${parts[7]}`;
return {
value: key,
label: keyLabel
};
});
setKeys(returnedKeys);
if (kms) {
const existingKey = returnedKeys.find((k) => k.value === kms.external.providerInput.keyName);
if (existingKey) {
setValue("keyObject", existingKey);
}
}
};
const getPlaceholderText = () => {
if (isFetchGcpKeysLoading) {
return "Loading keys in this region...";
}
if (!isCredentialValid) {
return "Upload a valid credential file";
}
if (keys.length) {
return "Select a key";
}
return "No valid keys found in this region";
};
useEffect(() => {
if (kms && !isCredentialValid) {
fetchGCPKeys();
}
}, [kms]);
return (
<form onSubmit={handleSubmit(handleGcpKmsFormSubmit)} autoComplete="off">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Alias" 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>
)}
/>
<Controller
control={control}
name="gcpRegion"
render={({ field, fieldState: { error } }) => (
<FormControl label="GCP Region" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
className="w-full"
placeholder="Select a GCP region"
name="gcpRegion"
options={GCP_REGIONS}
value={field.value}
onChange={(e) => {
resetField("keyObject");
field.onChange(e);
fetchGCPKeys();
}}
formatOptionLabel={formatOptionLabel}
/>
</FormControl>
)}
/>
{!kms && (
<Controller
control={control}
name="credentialFile"
render={({ field: { value, onChange, ref, ...rest }, fieldState: { error } }) => (
<FormControl
label="Service Account Credential JSON"
errorText={error?.message}
isError={Boolean(error)}
>
<Input
{...rest}
ref={ref}
type="file"
accept=".json"
placeholder=""
value={value?.filename}
onChange={(e) => {
onChange(e.target.files);
fetchGCPKeys();
}}
/>
</FormControl>
)}
/>
)}
<Controller
control={control}
name="keyObject"
render={({ field, fieldState: { error } }) => (
<FormControl label="GCP Key Name" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
className="w-full"
placeholder={getPlaceholderText()}
isDisabled={!isCredentialValid || !keys.length}
name="key"
options={keys}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
)}
/>
{kms && (
<span className="text-xs text-mineshaft-300">
To change your GCP credentials, create a new external KMS and assign it to project you
want to use it with.
</span>
)}
<div className="mt-6 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,221 @@
import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faEllipsis, faLock, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
THead,
Tr
} from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription
} from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useGetExternalKmsList, useRemoveExternalKms } from "@app/hooks/api";
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
import { AddExternalKmsForm } from "./AddExternalKmsForm";
import { UpdateExternalKmsForm } from "./UpdateExternalKmsForm";
export const OrgEncryptionTab = withPermission(
() => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const orgId = currentOrg?.id || "";
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"upgradePlan",
"addExternalKms",
"editExternalKms",
"removeExternalKms"
] as const);
const { data: externalKmsList, isPending: isExternalKmsListLoading } =
useGetExternalKmsList(orgId);
const { mutateAsync: removeExternalKms } = useRemoveExternalKms(currentOrg.id);
const handleRemoveExternalKms = async () => {
const { kmsId } = popUp?.removeExternalKms?.data as {
kmsId: string;
};
try {
await removeExternalKms(kmsId);
createNotification({
text: "Successfully deleted external KMS",
type: "success"
});
handlePopUpToggle("removeExternalKms", false);
} catch (err) {
console.error(err);
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Key Management System (KMS)</p>
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Kms}>
{(isAllowed) => (
<Button
onClick={() => {
if (subscription && !subscription?.externalKms) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("addExternalKms");
}}
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add
</Button>
)}
</OrgPermissionCan>
</div>
<p className="mb-4 text-gray-400">
Encrypt your organization&apos;s data with external KMS.
</p>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>Provider</Td>
<Td>Alias</Td>
</Tr>
</THead>
<TBody>
{isExternalKmsListLoading && <TableSkeleton columns={2} innerKey="kms-loading" />}
{!isExternalKmsListLoading && externalKmsList && externalKmsList?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No external KMS found" icon={faLock} />
</Td>
</Tr>
)}
{!isExternalKmsListLoading &&
externalKmsList?.map((kms) => (
<Tr key={kms.id}>
<Td className="flex max-w-xs items-center overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
{kms.externalKms.provider === ExternalKmsProvider.Aws && (
<FontAwesomeIcon icon={faAws} />
)}
{kms.externalKms.provider === ExternalKmsProvider.Gcp && (
<FontAwesomeIcon icon={faGoogle} />
)}
<div className="ml-2">{kms.externalKms.provider.toUpperCase()}</div>
</Td>
<Td>{kms.name}</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.Kms}
>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
if (subscription && !subscription?.externalKms) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("editExternalKms", {
kmsId: kms.id
});
}}
>
Edit
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
an={OrgPermissionSubjects.Kms}
>
{(isAllowed) => (
<DropdownMenuItem
disabled={!isAllowed}
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeExternalKms", {
name: kms.name,
kmsId: kms.id,
provider: kms.externalKms.provider
});
}}
>
Delete
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can configure external KMS if you switch to Infisical's Enterprise plan."
/>
<AddExternalKmsForm
isOpen={popUp.addExternalKms.isOpen}
onToggle={(state) => handlePopUpToggle("addExternalKms", state)}
/>
<UpdateExternalKmsForm
isOpen={popUp.editExternalKms.isOpen}
kmsId={(popUp.editExternalKms.data as { kmsId: string })?.kmsId}
onOpenChange={(state) => handlePopUpToggle("editExternalKms", state)}
/>
<DeleteActionModal
isOpen={popUp.removeExternalKms.isOpen}
title={`Are you sure want to remove ${
(popUp?.removeExternalKms?.data as { slug: string })?.slug || ""
} from ${(popUp?.removeExternalKms?.data as { provider: string })?.provider || ""}?`}
onChange={(isOpen) => handlePopUpToggle("removeExternalKms", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleRemoveExternalKms}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Kms }
);
@@ -0,0 +1,37 @@
import { ContentLoader, Modal, ModalContent } from "@app/components/v2";
import { useGetExternalKmsById } from "@app/hooks/api";
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
import { AwsKmsForm } from "./AwsKmsForm";
import { GcpKmsForm } from "./GcpKmsForm";
type Props = {
isOpen: boolean;
kmsId: string;
onOpenChange: (state: boolean) => void;
};
export const UpdateExternalKmsForm = ({ isOpen, kmsId, onOpenChange }: Props) => {
const { data: externalKms, isPending } = useGetExternalKmsById(kmsId);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Edit configuration" bodyClassName="overflow-visible">
{isPending && <ContentLoader />}
{externalKms?.external?.provider === ExternalKmsProvider.Aws && (
<AwsKmsForm
kms={externalKms}
onCancel={() => onOpenChange(false)}
onCompleted={() => onOpenChange(false)}
/>
)}
{externalKms?.external?.provider === ExternalKmsProvider.Gcp && (
<GcpKmsForm
kms={externalKms}
onCancel={() => onOpenChange(false)}
onCompleted={() => onOpenChange(false)}
/>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { OrgEncryptionTab } from "./OrgEncryptionTab";
@@ -0,0 +1,16 @@
import { useOrgPermission } from "@app/context";
import { OrgDeleteSection } from "../OrgDeleteSection";
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
import { OrgNameChangeSection } from "../OrgNameChangeSection";
export const OrgGeneralTab = () => {
const { membership } = useOrgPermission();
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<OrgNameChangeSection />
<OrgIncidentContactsSection />
{membership && membership.role === "admin" && <OrgDeleteSection />}
</div>
);
};
@@ -0,0 +1 @@
export { OrgGeneralTab } from "./OrgGeneralTab";
@@ -0,0 +1,104 @@
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, Modal, ModalContent } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useAddIncidentContact } from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { UsePopUpState } from "@app/hooks/usePopUp";
const addContactFormSchema = z.object({
email: z.string().email().trim()
});
type TAddContactForm = z.infer<typeof addContactFormSchema>;
type Props = {
popUp: UsePopUpState<["addContact"]>;
handlePopUpClose: (popUpName: keyof UsePopUpState<["addContact"]>) => void;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addContact"]>, state?: boolean) => void;
};
export const AddOrgIncidentContactModal = ({
popUp,
handlePopUpClose,
handlePopUpToggle
}: Props) => {
const { currentOrg } = useOrganization();
const { data: serverDetails } = useFetchServerStatus();
const { control, handleSubmit, reset } = useForm<TAddContactForm>({
resolver: zodResolver(addContactFormSchema)
});
const { mutateAsync, isPending } = useAddIncidentContact();
const onFormSubmit = async ({ email }: TAddContactForm) => {
try {
if (!currentOrg?.id) return;
await mutateAsync({
orgId: currentOrg.id,
email
});
createNotification({
text: "Successfully added incident contact",
type: "success"
});
if (serverDetails?.emailConfigured) {
handlePopUpClose("addContact");
}
reset();
} catch (err) {
console.error(err);
createNotification({
text: "Failed to add incident contact",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.addContact?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addContact", isOpen);
reset();
}}
>
<ModalContent
title="Add an Incident Contact"
subTitle="This contact will be notified in the unlikely event of a severe incident."
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center space-x-4">
<Button size="sm" type="submit" isLoading={isPending} isDisabled={isPending}>
Add Incident Contact
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addContact")}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan, PermissionDeniedBanner } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { usePopUp } from "@app/hooks";
import { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal";
import { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
export const OrgIncidentContactsSection = () => {
const { t } = useTranslation();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addContact"
] as const);
const { permission } = useOrgPermission();
return (
<>
<hr className="border-mineshaft-600" />
<div className="flex items-center justify-between pt-4">
<p className="text-md text-mineshaft-100">{t("section.incident.incident-contacts")}</p>
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.IncidentAccount}>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addContact")}
>
Add contact
</Button>
)}
</OrgPermissionCan>
</div>
<div className="py-4">
{permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount) ? (
<OrgIncidentContactsTable />
) : (
<PermissionDeniedBanner />
)}
</div>
<AddOrgIncidentContactModal
popUp={popUp}
handlePopUpClose={handlePopUpClose}
handlePopUpToggle={handlePopUpToggle}
/>
</>
);
};
@@ -0,0 +1,119 @@
import { useState } from "react";
import { faContactBook, faMagnifyingGlass, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
EmptyState,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteIncidentContact, useGetOrgIncidentContact } from "@app/hooks/api";
export const OrgIncidentContactsTable = () => {
const { currentOrg } = useOrganization();
const { data: contacts, isPending } = useGetOrgIncidentContact(currentOrg?.id ?? "");
const [searchContact, setSearchContact] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeContact",
"setUpEmail"
] as const);
const { mutateAsync } = useDeleteIncidentContact();
const onRemoveIncidentContact = async () => {
try {
const incidentContactId = (popUp?.removeContact?.data as { id: string })?.id;
if (!currentOrg?.id) return;
await mutateAsync({
orgId: currentOrg.id,
incidentContactId
});
createNotification({
text: "Successfully removed incident contact",
type: "success"
});
handlePopUpClose("removeContact");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to remove incident contact",
type: "error"
});
}
};
const filteredContacts = contacts
? contacts.filter(({ email }) => email.toLocaleLowerCase().includes(searchContact))
: [];
return (
<div>
<Input
value={searchContact}
onChange={(e) => setSearchContact(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search incident contact by email..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Email</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="incident-contact" />}
{filteredContacts?.map(({ email, id }) => (
<Tr key={email}>
<Td className="w-full">{email}</Td>
<Td className="mr-4">
<OrgPermissionCan
I={OrgPermissionActions.Delete}
an={OrgPermissionSubjects.IncidentAccount}
>
{(isAllowed) => (
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeContact", { email, id })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</OrgPermissionCan>
</Td>
</Tr>
))}
</TBody>
</Table>
{filteredContacts?.length === 0 && !isPending && (
<EmptyState title="No incident contacts found" icon={faContactBook} />
)}
</TableContainer>
<DeleteActionModal
isOpen={popUp.removeContact.isOpen}
deleteKey="remove"
title="Do you want to remove this email from incident contact?"
onChange={(isOpen) => handlePopUpToggle("removeContact", isOpen)}
onDeleteApproved={onRemoveIncidentContact}
/>
</div>
);
};
@@ -0,0 +1,2 @@
export { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal";
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
@@ -0,0 +1,168 @@
import { useEffect, useState } 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 { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, Select, SelectItem, Spinner } from "@app/components/v2";
import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useOrgPermission
} from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles";
import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api";
const formSchema = z.object({
name: z.string().max(64, "Too long, maximum length is 64 characters"),
slug: z
.string()
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens"),
defaultMembershipRole: z.string()
});
type FormData = z.infer<typeof formSchema>;
export const OrgNameChangeSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { permission } = useOrgPermission();
const { handleSubmit, control, reset } = useForm<FormData>({
resolver: zodResolver(formSchema)
});
const { mutateAsync, isPending } = useUpdateOrg();
const canReadOrgRoles = permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
const { data: roles, isPending: isRolesLoading } = useGetOrgRoles(currentOrg.id, canReadOrgRoles);
const [isFormInitialized, setIsFormInitialized] = useState(false);
useEffect(() => {
if (currentOrg) {
reset({
name: currentOrg.name,
slug: currentOrg.slug,
...(canReadOrgRoles &&
roles?.length && {
// will always be present, can't remove role if default
defaultMembershipRole: isCustomOrgRole(currentOrg.defaultMembershipRole)
? roles?.find((role) => currentOrg.defaultMembershipRole === role.id)?.slug || ""
: currentOrg.defaultMembershipRole
})
});
setIsFormInitialized(true);
}
}, [currentOrg, roles]);
const onFormSubmit = async ({ name, slug, defaultMembershipRole }: FormData) => {
try {
if (!currentOrg?.id || !roles?.length) return;
await mutateAsync({
orgId: currentOrg?.id,
name,
slug,
defaultMembershipRoleSlug: defaultMembershipRole
});
createNotification({
text: "Successfully updated organization details",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update organization details",
type: "error"
});
}
};
if (!isFormInitialized) {
return (
<div className="flex h-[25.25rem] w-full items-center justify-center">
<Spinner size="lg" />
</div>
);
}
return (
<form onSubmit={handleSubmit(onFormSubmit)} className="py-4">
<div className="">
<h2 className="text-md mb-2 text-mineshaft-100">Organization Name</h2>
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} className="max-w-md">
<Input placeholder="Acme Corp" {...field} />
</FormControl>
)}
control={control}
name="name"
/>
</div>
<div className="py-4">
<h2 className="text-md mb-2 text-mineshaft-100">Organization Slug</h2>
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} className="max-w-md">
<Input placeholder="acme" {...field} />
</FormControl>
)}
control={control}
name="slug"
/>
</div>
{canReadOrgRoles && (
<div className="pb-4">
<h2 className="text-md mb-2 text-mineshaft-100">Default Organization Member Role</h2>
<p className="text-mineshaft-400" />
<Controller
defaultValue=""
control={control}
name="defaultMembershipRole"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
helperText="Users joining your organization will be assigned this role unless otherwise specified."
isError={Boolean(error)}
errorText={error?.message}
className="max-w-md"
>
<Select
isDisabled={isRolesLoading}
className="w-full capitalize"
value={value}
onValueChange={!roles?.length ? undefined : onChange}
>
{roles?.map((role) => {
return (
<SelectItem key={role.id} value={role.slug}>
{role.name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
</div>
)}
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
>
Save
</Button>
)}
</OrgPermissionCan>
</form>
);
};
@@ -0,0 +1 @@
export { OrgNameChangeSection } from "./OrgNameChangeSection";
@@ -0,0 +1,53 @@
import { useState } from "react";
import { useSearch } from "@tanstack/react-router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { AppConnectionsTab } from "../AppConnectionsTab";
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
import { ImportTab } from "../ImportTab";
import { OrgAuthTab } from "../OrgAuthTab";
import { OrgEncryptionTab } from "../OrgEncryptionTab";
import { OrgGeneralTab } from "../OrgGeneralTab";
import { OrgWorkflowIntegrationTab } from "../OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab";
import { ProjectTemplatesTab } from "../ProjectTemplatesTab";
export const OrgTabGroup = () => {
const search = useSearch({
from: ROUTE_PATHS.Organization.SettingsPage.id
});
const tabs = [
{ name: "General", key: "tab-org-general", component: OrgGeneralTab },
{ name: "Security", key: "tab-org-security", component: OrgAuthTab },
{ name: "Encryption", key: "tab-org-encryption", component: OrgEncryptionTab },
{
name: "Workflow Integrations",
key: "workflow-integrations",
component: OrgWorkflowIntegrationTab
},
{ name: "App Connections", key: "app-connections", component: AppConnectionsTab },
{ name: "Audit Log Streams", key: "tag-audit-log-streams", component: AuditLogStreamsTab },
{ name: "Import", key: "tab-import", component: ImportTab },
{ name: "Project Templates", key: "project-templates", component: ProjectTemplatesTab }
];
const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key);
return (
<Tabs value={selectedTab} onValueChange={setSelectedTab}>
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
</TabList>
{tabs.map(({ key, component: Component }) => (
<TabPanel value={key} key={`tab-panel-${key}`}>
<Component />
</TabPanel>
))}
</Tabs>
);
};
@@ -0,0 +1 @@
export { OrgTabGroup } from "./OrgTabGroup";
@@ -0,0 +1,93 @@
import { useState } from "react";
import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
type Props = {
isOpen?: boolean;
onToggle: (isOpen: boolean) => void;
};
enum WizardSteps {
SelectPlatform = "select-platform",
PlatformInputs = "platform-inputs"
}
const PLATFORM_LIST = [
{
icon: faSlack,
platform: WorkflowIntegrationPlatform.SLACK,
title: "Slack"
}
];
export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => {
const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
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 ?? "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 platform</div>
<div className="flex items-center space-x-4">
{PLATFORM_LIST.map(({ icon, platform, title }) => (
<div
key={platform}
className="flex h-28 w-32 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"
role="button"
tabIndex={0}
onClick={() => {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
setSelectedPlatform(platform);
setWizardStep(WizardSteps.PlatformInputs);
}
}}
>
<FontAwesomeIcon icon={icon} size="lg" />
<div className="whitespace-pre-wrap text-center text-sm">{title}</div>
</div>
))}
</div>
</motion.div>
)}
{wizardStep === WizardSteps.PlatformInputs &&
selectedPlatform === WorkflowIntegrationPlatform.SLACK && (
<motion.div
key="slack-platform"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<SlackIntegrationForm onClose={() => onToggle(false)} />
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,26 @@
import { Modal, ModalContent } from "@app/components/v2";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { SlackIntegrationForm } from "./SlackIntegrationForm";
type Props = {
isOpen: boolean;
id: string;
workflowPlatform: WorkflowIntegrationPlatform;
onOpenChange: (state: boolean) => void;
};
export const IntegrationFormDetails = ({ isOpen, id, onOpenChange, workflowPlatform }: Props) => {
const modalTitle =
workflowPlatform === WorkflowIntegrationPlatform.SLACK ? "Slack integration" : "Integration";
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title={modalTitle}>
{workflowPlatform === WorkflowIntegrationPlatform.SLACK && (
<SlackIntegrationForm id={id} onClose={() => onOpenChange(false)} />
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,238 @@
import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { faEllipsis, faGear, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import axios from "axios";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { withPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import {
fetchSlackReinstallUrl,
useDeleteSlackIntegration,
useGetWorkflowIntegrations
} from "@app/hooks/api";
import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types";
import { AddWorkflowIntegrationForm } from "./AddWorkflowIntegrationForm";
import { IntegrationFormDetails } from "./IntegrationFormDetails";
export const OrgWorkflowIntegrationTab = withPermission(
() => {
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"addWorkflowIntegration",
"integrationDetails",
"removeIntegration"
] as const);
const { currentOrg } = useOrganization();
const { data: workflowIntegrations, isLoading: isWorkflowIntegrationsLoading } =
useGetWorkflowIntegrations(currentOrg?.id);
const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration();
const handleRemoveIntegration = async () => {
if (!currentOrg) {
return;
}
const { platform, id } = popUp.removeIntegration.data;
if (platform === WorkflowIntegrationPlatform.SLACK) {
await deleteSlackIntegration({
id,
orgId: currentOrg?.id
});
}
handlePopUpClose("removeIntegration");
createNotification({
text: "Successfully deleted integration",
type: "success"
});
};
const triggerReinstall = async (platform: WorkflowIntegrationPlatform, id: string) => {
if (platform === WorkflowIntegrationPlatform.SLACK) {
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"
});
}
}
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<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">
Connect Infisical to other platforms for notification and workflow integrations.
</p>
<TableContainer>
<Table>
<THead>
<Tr>
<Td>Provider</Td>
<Td>Alias</Td>
</Tr>
</THead>
<TBody>
{isWorkflowIntegrationsLoading && (
<TableSkeleton columns={2} innerKey="integrations-loading" />
)}
{!isWorkflowIntegrationsLoading &&
workflowIntegrations &&
workflowIntegrations.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState title="No workflow integrations found" icon={faGear} />
</Td>
</Tr>
)}
{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>
<Td>{workflowIntegration.slug}</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">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("integrationDetails", {
id: workflowIntegration.id,
platform: workflowIntegration.integration
});
}}
>
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>
<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", {
id: workflowIntegration.id,
slug: workflowIntegration.slug,
platform: workflowIntegration.integration
});
}}
>
Delete
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
<AddWorkflowIntegrationForm
isOpen={popUp.addWorkflowIntegration.isOpen}
onToggle={(state) => handlePopUpToggle("addWorkflowIntegration", state)}
/>
<IntegrationFormDetails
isOpen={popUp.integrationDetails?.isOpen}
workflowPlatform={popUp.integrationDetails?.data?.platform}
id={popUp.integrationDetails?.data?.id}
onOpenChange={(state) => handlePopUpToggle("integrationDetails", state)}
/>
<DeleteActionModal
isOpen={popUp.removeIntegration.isOpen}
title={`Are you sure want to remove ${popUp?.removeIntegration?.data?.slug}?`}
onChange={(isOpen) => handlePopUpToggle("removeIntegration", isOpen)}
deleteKey="confirm"
onDeleteApproved={handleRemoveIntegration}
/>
</div>
);
},
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Settings }
);
@@ -0,0 +1,138 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import axios from "axios";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useToggle } from "@app/hooks";
import {
fetchSlackInstallUrl,
useGetSlackIntegrationById,
useUpdateSlackIntegration
} from "@app/hooks/api";
import { slugSchema } from "@app/lib/schemas";
type Props = {
id?: string;
onClose: () => void;
};
const slackFormSchema = z.object({
slug: slugSchema({ min: 1, field: "Alias" }),
description: z.string().optional()
});
type TSlackFormData = z.infer<typeof slackFormSchema>;
export const SlackIntegrationForm = ({ id, onClose }: Props) => {
const {
control,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TSlackFormData>({
resolver: zodResolver(slackFormSchema)
});
const navigate = useNavigate();
const [isConnectLoading, setIsConnectLoading] = useToggle(false);
const { currentOrg } = useOrganization();
const { data: slackIntegration } = useGetSlackIntegrationById(id);
const { mutateAsync: updateSlackIntegration } = useUpdateSlackIntegration();
useEffect(() => {
if (slackIntegration) {
setValue("slug", slackIntegration.slug);
setValue("description", slackIntegration.description ?? "");
}
}, [slackIntegration]);
const triggerSlackInstall = async (slug: string, description?: string) => {
setIsConnectLoading.on();
try {
const slackInstallUrl = await fetchSlackInstallUrl({
slug,
description
});
if (slackInstallUrl) {
navigate({ to: slackInstallUrl });
}
} catch (err) {
if (axios.isAxiosError(err)) {
createNotification({
text: (err.response?.data as { message: string })?.message,
type: "error"
});
}
} finally {
setIsConnectLoading.off();
}
};
const handleSlackFormSubmit = async ({ slug, description }: TSlackFormData) => {
if (id && slackIntegration) {
if (!currentOrg) {
return;
}
await updateSlackIntegration({
id,
orgId: currentOrg?.id,
slug,
description
});
onClose();
createNotification({
text: "Successfully updated Slack integration",
type: "success"
});
} else {
await triggerSlackInstall(slug, description);
}
};
return (
<form onSubmit={handleSubmit(handleSlackFormSubmit)} autoComplete="off">
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl label="Alias" isRequired errorText={error?.message} isError={Boolean(error)}>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl label="Description" errorText={error?.message} isError={Boolean(error)}>
<Input placeholder="" {...field} />
</FormControl>
)}
/>
{slackIntegration && (
<FormControl label="Connected Slack workspace">
<Input value={slackIntegration?.teamName} isReadOnly className="bg-white/[0.07]" />
</FormControl>
)}
<div className="mt-6 flex items-center space-x-4">
<Button
type="submit"
isLoading={isSubmitting || isConnectLoading}
isDisabled={!isDirty || isConnectLoading || isSubmitting}
>
{slackIntegration ? "Save" : "Connect Slack"}
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
);
};
@@ -0,0 +1,9 @@
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc";
import { ProjectTemplatesSection } from "./components";
export const ProjectTemplatesTab = withPermission(() => <ProjectTemplatesSection />, {
action: OrgPermissionActions.Read,
subject: OrgPermissionSubjects.ProjectTemplates
});
@@ -0,0 +1,49 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { TProjectTemplate, useDeleteProjectTemplate } from "@app/hooks/api/projectTemplates";
type Props = {
template?: TProjectTemplate;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const DeleteProjectTemplateModal = ({ isOpen, onOpenChange, template }: Props) => {
const deleteTemplate = useDeleteProjectTemplate();
if (!template) return null;
const { id: templateId, name } = template;
const handleDeleteProjectTemplate = async () => {
try {
await deleteTemplate.mutateAsync({
templateId
});
createNotification({
text: "Successfully removed project template",
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
createNotification({
text: "Failed remove project template",
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
onChange={onOpenChange}
title={`Are you sure want to delete ${name}?`}
deleteKey="confirm"
onDeleteApproved={handleDeleteProjectTemplate}
/>
);
};
@@ -0,0 +1,55 @@
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, EmptyState, Spinner } from "@app/components/v2";
import {
InfisicalProjectTemplate,
TProjectTemplate,
useGetProjectTemplateById
} from "@app/hooks/api/projectTemplates";
import { EditProjectTemplate } from "./components";
type Props = {
template: TProjectTemplate;
onBack: () => void;
};
export const EditProjectTemplateSection = ({ template, onBack }: Props) => {
const isInfisicalTemplate = Object.values(InfisicalProjectTemplate).includes(
template.name as InfisicalProjectTemplate
);
const { data: projectTemplate, isPending } = useGetProjectTemplateById(template.id, {
initialData: template,
enabled: !isInfisicalTemplate
});
return (
<div>
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={onBack}
className="mb-4"
>
Back to Templates
</Button>
{/* eslint-disable-next-line no-nested-ternary */}
{isPending ? (
<div className="flex h-[60vh] w-full items-center justify-center p-24">
<Spinner />
</div>
) : projectTemplate ? (
<EditProjectTemplate
isInfisicalTemplate={isInfisicalTemplate}
projectTemplate={projectTemplate}
onBack={onBack}
/>
) : (
<EmptyState title="Error: Unable to find project template." className="py-12" />
)}
</div>
);
};
@@ -0,0 +1,119 @@
import { faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { usePopUp } from "@app/hooks";
import { TProjectTemplate, useDeleteProjectTemplate } from "@app/hooks/api/projectTemplates";
import { ProjectTemplateDetailsModal } from "../../ProjectTemplateDetailsModal";
import { ProjectTemplateEnvironmentsForm } from "./ProjectTemplateEnvironmentsForm";
import { ProjectTemplateRolesSection } from "./ProjectTemplateRolesSection";
type Props = {
projectTemplate: TProjectTemplate;
onBack: () => void;
isInfisicalTemplate: boolean;
};
export const EditProjectTemplate = ({ isInfisicalTemplate, projectTemplate, onBack }: Props) => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeTemplate",
"editDetails"
] as const);
const { id: templateId, name, description } = projectTemplate;
const deleteProjectTemplate = useDeleteProjectTemplate();
const handleRemoveTemplate = async () => {
try {
await deleteProjectTemplate.mutateAsync({
templateId
});
createNotification({
text: "Successfully removed project template",
type: "success"
});
onBack();
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove project template",
type: "error"
});
}
handlePopUpClose("removeTemplate");
};
return (
<>
<div className="mb-4 flex items-start justify-between border-b border-bunker-400 pb-4">
<div className="flex-col">
<h3 className="text-xl font-semibold">{name}</h3>
<h2 className="text-sm text-mineshaft-400">{description || "Project Template"}</h2>
</div>
{!isInfisicalTemplate && (
<div className="flex gap-2">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPencil} />}
size="xs"
colorSchema="secondary"
onClick={() => handlePopUpOpen("editDetails")}
>
Edit Details
</Button>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
onClick={() => {
handlePopUpOpen("removeTemplate");
}}
leftIcon={<FontAwesomeIcon icon={faTrash} />}
size="xs"
colorSchema="danger"
>
Delete Template
</Button>
)}
</OrgPermissionCan>
</div>
)}
</div>
<ProjectTemplateEnvironmentsForm
isInfisicalTemplate={isInfisicalTemplate}
projectTemplate={projectTemplate}
/>
<ProjectTemplateRolesSection
isInfisicalTemplate={isInfisicalTemplate}
projectTemplate={projectTemplate}
/>
<ProjectTemplateDetailsModal
isOpen={popUp.editDetails.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("editDetails", isOpen)}
projectTemplate={projectTemplate}
/>
<DeleteActionModal
isOpen={popUp.removeTemplate.isOpen}
title={`Are you sure want to delete ${projectTemplate.name}?`}
deleteKey="confirm"
onChange={(isOpen) => handlePopUpToggle("removeTemplate", isOpen)}
onDeleteApproved={handleRemoveTemplate}
/>
</>
);
};
@@ -0,0 +1,211 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { faChevronLeft, faPlus, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent, ModalTrigger } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { isCustomProjectRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates";
import { slugSchema } from "@app/lib/schemas";
import { GeneralPermissionPolicies } from "@app/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies";
import { NewPermissionRule } from "@app/pages/project/RoleDetailsBySlugPage/components/NewPermissionRule";
import { PermissionEmptyState } from "@app/pages/project/RoleDetailsBySlugPage/components/PermissionEmptyState";
import {
formRolePermission2API,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form
} from "@app/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils";
import { renderConditionalComponents } from "@app/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection";
type Props = {
projectTemplate: TProjectTemplate;
role?: TProjectTemplate["roles"][number];
onGoBack: () => void;
isDisabled?: boolean;
};
const formSchema = z.object({
slug: slugSchema(),
name: z.string().trim().min(1),
permissions: projectRoleFormSchema.shape.permissions
});
type TFormSchema = z.infer<typeof formSchema>;
export const ProjectTemplateEditRoleForm = ({
onGoBack,
projectTemplate,
role,
isDisabled
}: Props) => {
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
const formMethods = useForm<TFormSchema>({
values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined,
resolver: zodResolver(formSchema)
});
const {
handleSubmit,
control,
formState: { isDirty, isSubmitting }
} = formMethods;
const updateProjectTemplate = useUpdateProjectTemplate();
const onSubmit = async (form: TFormSchema) => {
try {
await updateProjectTemplate.mutateAsync({
templateId: projectTemplate.id,
roles: [
...projectTemplate.roles.filter(
(r) => r.slug !== role?.slug && isCustomProjectRole(r.slug) // filter out default roles as well
),
{
...form,
permissions: formRolePermission2API(form.permissions)
}
]
});
onGoBack();
createNotification({
text: "Template roles successfully updated",
type: "success"
});
} catch (e: any) {
console.error(e);
createNotification({
text: "Failed to update template roles",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<FormProvider {...formMethods}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<Button
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
className="text-base font-semibold text-mineshaft-200"
variant="link"
onClick={onGoBack}
>
{isDisabled ? "Back" : "Cancel"}
</Button>
{!isDisabled && (
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<Modal
isOpen={popUp.createPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
>
<ModalTrigger asChild>
<Button
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={isDisabled}
>
New Policy
</Button>
</ModalTrigger>
<ModalContent
title="New Policy"
subTitle="Policies grant additional permissions."
>
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
</ModalContent>
</Modal>
</div>
</div>
)}
</div>
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
{isDisabled ? (
<div className="flex flex-col">
<span className="text-lg font-semibold">{role?.name}</span>
<span className="text-mineshaft-400">{role?.slug}</span>
</div>
) : (
<div className="flex w-full gap-2">
<Controller
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Name"
className="mb-0 flex-1"
>
<Input {...field} autoFocus placeholder="Role name..." />
</FormControl>
)}
/>
<Controller
control={control}
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Slug"
className="mb-0 flex-1"
>
<Input {...field} placeholder="Role slug..." />
</FormControl>
)}
/>
</div>
)}
</div>
<div className="p-4">
<div className="mb-2 text-lg">Policies</div>
<PermissionEmptyState />
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
</div>
</FormProvider>
</form>
);
};
@@ -0,0 +1,281 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faArrowDown, faArrowUp, faPlus, faSave, faTrash } 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 { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates";
import { slugSchema } from "@app/lib/schemas";
type Props = {
projectTemplate: TProjectTemplate;
isInfisicalTemplate: boolean;
};
const formSchema = z.object({
environments: z
.object({
name: z.string().trim().min(1),
slug: slugSchema({ min: 1, max: 32 })
})
.array()
});
type TFormSchema = z.infer<typeof formSchema>;
export const ProjectTemplateEnvironmentsForm = ({
projectTemplate,
isInfisicalTemplate
}: Props) => {
const {
control,
handleSubmit,
formState: { isDirty, errors },
reset
} = useForm<TFormSchema>({
defaultValues: {
environments: projectTemplate.environments
},
resolver: zodResolver(formSchema)
});
const {
fields: environments,
move,
remove,
append
} = useFieldArray({ control, name: "environments" });
const updateProjectTemplate = useUpdateProjectTemplate();
const onFormSubmit = async (form: TFormSchema) => {
try {
const { environments: updatedEnvs } = await updateProjectTemplate.mutateAsync({
environments: form.environments.map((env, index) => ({
...env,
position: index + 1
})),
templateId: projectTemplate.id
});
reset({ environments: updatedEnvs });
createNotification({
text: "Project template updated successfully",
type: "success"
});
} catch (e: any) {
console.error(e);
createNotification({
text: e.message ?? "Failed to update project template",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<div>
<h2 className="text-lg font-semibold">Project Environments</h2>
{!isInfisicalTemplate && (
<p className="text-sm text-mineshaft-400">
Add, rename, remove and reorder environments for this project template
</p>
)}
</div>
{!isInfisicalTemplate && (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Button
type="submit"
colorSchema="primary"
className="ml-auto w-40"
variant={isDirty ? "solid" : "outline_bg"}
leftIcon={<FontAwesomeIcon icon={faSave} />}
isDisabled={!isAllowed || !isDirty}
>
{isDirty ? "Save" : "No"} Changes
</Button>
)}
</OrgPermissionCan>
)}
</div>
{errors.environments && (
<span className="my-4 text-sm text-red">{errors.environments.message}</span>
)}
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Friendly Name</Th>
<Th>Slug</Th>
{!isInfisicalTemplate && (
<Th>
<div className="flex w-full justify-end normal-case">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Button
onClick={() => append({ name: "", slug: "" })}
colorSchema="secondary"
className="ml-auto"
variant="solid"
size="xs"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Add Environment
</Button>
)}
</OrgPermissionCan>
</div>
</Th>
)}
</Tr>
</THead>
<TBody>
{environments.map(({ id, name, slug }, pos) => (
<Tr key={id}>
<Td>
{isInfisicalTemplate ? (
name
) : (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Controller
control={control}
name={`environments.${pos}.name`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input isDisabled={!isAllowed} {...field} placeholder="Name..." />
</FormControl>
)}
/>
)}
</OrgPermissionCan>
)}
</Td>
<Td>
{isInfisicalTemplate ? (
slug
) : (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Controller
control={control}
name={`environments.${pos}.slug`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input isDisabled={!isAllowed} {...field} placeholder="Slug..." />
</FormControl>
)}
/>
)}
</OrgPermissionCan>
)}
</Td>
{!isInfisicalTemplate && (
<Td className="flex items-center justify-end">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<IconButton
className={`mr-3 py-2 ${
pos === environments.length - 1 ? "pointer-events-none opacity-50" : ""
}`}
onClick={() => move(pos, pos + 1)}
colorSchema="primary"
variant="plain"
ariaLabel="Increase position"
isDisabled={pos === environments.length - 1 || !isAllowed}
>
<FontAwesomeIcon icon={faArrowDown} />
</IconButton>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<IconButton
className={`mr-3 py-2 ${
pos === 0 ? "pointer-events-none opacity-50" : ""
}`}
onClick={() => move(pos, pos - 1)}
colorSchema="primary"
variant="plain"
ariaLabel="Decrease position"
isDisabled={pos === 0 || !isAllowed}
>
<FontAwesomeIcon icon={faArrowUp} />
</IconButton>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<IconButton
onClick={() => remove(pos)}
colorSchema="danger"
variant="plain"
ariaLabel="Remove environment"
isDisabled={!isAllowed || environments.length === 1}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</OrgPermissionCan>
</Td>
)}
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</form>
);
};
@@ -0,0 +1,223 @@
import { faPlus, faTrash, faUnlock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
IconButton,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrgPermission } from "@app/context";
import { isCustomProjectRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { TProjectTemplate, useUpdateProjectTemplate } from "@app/hooks/api/projectTemplates";
import { TProjectRole } from "@app/hooks/api/roles/types";
import { ProjectTemplateEditRoleForm } from "./ProjectTemplateEditRoleForm";
type Props = {
projectTemplate: TProjectTemplate;
isInfisicalTemplate: boolean;
};
export const ProjectTemplateRolesSection = ({ projectTemplate, isInfisicalTemplate }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"removeRole",
"editRole"
] as const);
const { permission } = useOrgPermission();
const { roles } = projectTemplate;
const updateProjectTemplate = useUpdateProjectTemplate();
const handleRemoveRole = async (slug: string) => {
try {
await updateProjectTemplate.mutateAsync({
templateId: projectTemplate.id,
roles: projectTemplate.roles.filter(
(role) => role.slug !== slug && isCustomProjectRole(role.slug) // filter out default roles as well
)
});
createNotification({
text: "Successfully removed role from template",
type: "success"
});
handlePopUpClose("removeRole");
} catch (e) {
console.error(e);
createNotification({
text: "Error removing role from template",
type: "error"
});
}
};
const editRole = popUp?.editRole?.data as TProjectRole;
const roleToDelete = popUp?.removeRole?.data as TProjectRole;
return (
<div className="relative">
<AnimatePresence>
{popUp?.editRole.isOpen ? (
<motion.div
key="edit-role"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="absolute min-h-[10rem] w-full"
>
<ProjectTemplateEditRoleForm
onGoBack={() => handlePopUpClose("editRole")}
projectTemplate={projectTemplate}
role={editRole}
isDisabled={
permission.cannot(
OrgPermissionActions.Edit,
OrgPermissionSubjects.ProjectTemplates
) ||
(editRole && !isCustomProjectRole(editRole.slug))
}
/>
<div className="h-4 w-full" />
</motion.div>
) : (
<motion.div
key="role-list"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 0 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
className="absolute w-full"
>
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<div>
<h2 className="text-lg font-semibold">Project Roles</h2>
<p className="text-sm text-mineshaft-400">
{isInfisicalTemplate
? "Click a role to view the associated permissions"
: "Add, edit and remove roles for this project template"}
</p>
</div>
{!isInfisicalTemplate && (
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Button
onClick={() => {
handlePopUpOpen("editRole");
}}
colorSchema="primary"
className="ml-auto"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed}
>
Add Role
</Button>
)}
</OrgPermissionCan>
)}
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{roles.length ? (
roles.map((role) => {
return (
<Tr
key={role.slug}
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
handlePopUpOpen("editRole", role);
}
}}
onClick={() => handlePopUpOpen("editRole", role)}
>
<Td>{role.name}</Td>
<Td>{role.slug}</Td>
<Td>
{isCustomProjectRole(role.slug) && (
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.ProjectTemplates}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="delete-icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handlePopUpOpen("removeRole", role);
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</OrgPermissionCan>
</div>
)}
</Td>
</Tr>
);
})
) : (
<Tr>
<Td colSpan={2}>
<EmptyState title="No roles assigned to template" icon={faUnlock} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.removeRole.isOpen}
deleteKey="remove"
title={`Are you sure you want to remove the role ${roleToDelete?.slug}?`}
onChange={(isOpen) => handlePopUpToggle("removeRole", isOpen)}
onDeleteApproved={() => handleRemoveRole(roleToDelete?.slug)}
/>
</div>
<div className="h-4 w-full" />
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -0,0 +1 @@
export * from "./EditProjectTemplateSection";
@@ -0,0 +1,143 @@
import { 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,
Modal,
ModalClose,
ModalContent,
TextArea
} from "@app/components/v2";
import {
TProjectTemplate,
useCreateProjectTemplate,
useUpdateProjectTemplate
} from "@app/hooks/api/projectTemplates";
import { slugSchema } from "@app/lib/schemas";
const formSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
description: z.string().max(500).optional()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onComplete?: (template: TProjectTemplate) => void;
projectTemplate?: TProjectTemplate;
};
type FormProps = {
projectTemplate?: TProjectTemplate;
onComplete: (template: TProjectTemplate) => void;
};
const ProjectTemplateForm = ({ onComplete, projectTemplate }: FormProps) => {
const createProjectTemplate = useCreateProjectTemplate();
const updateProjectTemplate = useUpdateProjectTemplate();
const {
handleSubmit,
register,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: projectTemplate?.name,
description: projectTemplate?.description
}
});
const onFormSubmit = async (data: FormData) => {
const mutation = projectTemplate
? updateProjectTemplate.mutateAsync({ templateId: projectTemplate.id, ...data })
: createProjectTemplate.mutateAsync(data);
try {
const template = await mutation;
createNotification({
text: `Successfully ${
projectTemplate ? "updated template details" : "created project template"
}`,
type: "success"
});
onComplete(template);
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${
projectTemplate ? "update template details" : "create project template"
}`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-project-template" {...register("name")} />
</FormControl>
<FormControl
label="Description (optional)"
errorText={errors.description?.message}
isError={Boolean(errors.description?.message)}
>
<TextArea
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
{...register("description")}
/>
</FormControl>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{projectTemplate ? "Update" : "Add"} Template
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
export const ProjectTemplateDetailsModal = ({
isOpen,
onOpenChange,
projectTemplate,
onComplete
}: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title={projectTemplate ? "Edit Project Template Details" : "Create Project Template"}
>
<ProjectTemplateForm
projectTemplate={projectTemplate}
onComplete={(template) => {
if (onComplete) onComplete(template);
onOpenChange(false);
}}
/>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,117 @@
import { useState } from "react";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { TProjectTemplate } from "@app/hooks/api/projectTemplates";
import { usePopUp } from "@app/hooks/usePopUp";
import { EditProjectTemplateSection } from "./EditProjectTemplateSection";
import { ProjectTemplateDetailsModal } from "./ProjectTemplateDetailsModal";
import { ProjectTemplatesTable } from "./ProjectTemplatesTable";
export const ProjectTemplatesSection = () => {
const { subscription } = useSubscription();
const [editTemplate, setEditTemplate] = useState<TProjectTemplate | null>(null);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"upgradePlan",
"addTemplate"
] as const);
return (
<div className="relative">
<AnimatePresence>
{editTemplate ? (
<motion.div
key="edit-project-template"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="absolute min-h-[10rem] w-full"
>
<EditProjectTemplateSection
template={editTemplate}
onBack={() => setEditTemplate(null)}
/>
</motion.div>
) : (
<motion.div
key="project-templates-list"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="absolute min-h-[10rem] w-full"
>
<div>
<p className="mb-6 text-bunker-300">
Create and configure templates with predefined roles and environments to streamline
project setup
</p>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-start">
<p className="text-xl font-semibold text-mineshaft-100">Project Templates</p>
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/project-templates"
>
<div className="ml-2 mt-[0.32rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (!subscription?.projectTemplates) {
handlePopUpOpen("upgradePlan");
return;
}
handlePopUpOpen("addTemplate");
}}
isDisabled={!isAllowed}
className="ml-auto"
>
Add Template
</Button>
)}
</OrgPermissionCan>
</div>
<ProjectTemplatesTable onEdit={setEditTemplate} />
<ProjectTemplateDetailsModal
onComplete={(template) => setEditTemplate(template)}
isOpen={popUp.addTemplate.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addTemplate", isOpen)}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can create project templates if you switch to Infisical's Enterprise plan."
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -0,0 +1,191 @@
import { useMemo, useState } from "react";
import {
faCircleInfo,
faClone,
faMagnifyingGlass,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { OrgPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { usePopUp } from "@app/hooks";
import { TProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
import { DeleteProjectTemplateModal } from "./DeleteProjectTemplateModal";
type Props = {
onEdit: (projectTemplate: TProjectTemplate) => void;
};
export const ProjectTemplatesTable = ({ onEdit }: Props) => {
const { subscription } = useSubscription();
const { isPending, data: projectTemplates = [] } = useListProjectTemplates({
enabled: subscription?.projectTemplates
});
const [search, setSearch] = useState("");
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteTemplate"] as const);
const filteredTemplates = useMemo(
() =>
projectTemplates?.filter((template) =>
template.name.toLowerCase().includes(search.toLowerCase().trim())
) ?? [],
[search, projectTemplates]
);
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search templates..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Roles</Th>
<Th>Environments</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && (
<TableSkeleton
innerKey="project-templates-table"
columns={4}
key="project-templates"
/>
)}
{filteredTemplates.map((template) => {
const { id, name, roles, environments, description } = template;
return (
<Tr
onClick={() => onEdit(template)}
className="cursor-pointer hover:bg-mineshaft-700"
key={id}
>
<Td>
{name}
{description && (
<Tooltip content={description}>
<FontAwesomeIcon
size="sm"
className="ml-2 text-mineshaft-400"
icon={faCircleInfo}
/>
</Tooltip>
)}
</Td>
<Td className="pl-8">
{roles.length}
{roles.length > 0 && (
<Tooltip
content={
<ul className="ml-2 list-disc">
{roles.map((role) => (
<li key={role.name}>{role.name}</li>
))}
</ul>
}
>
<FontAwesomeIcon
size="sm"
className="ml-2 text-mineshaft-400"
icon={faCircleInfo}
/>
</Tooltip>
)}
</Td>
<Td className="pl-14">
{environments.length}
{environments.length > 0 && (
<Tooltip
content={
<ul className="ml-2 list-disc">
{environments
.sort((a, b) => (a.position > b.position ? 1 : -1))
.map((env) => (
<li key={env.slug}>{env.name}</li>
))}
</ul>
}
>
<FontAwesomeIcon
size="sm"
className="ml-2 text-mineshaft-400"
icon={faCircleInfo}
/>
</Tooltip>
)}
</Td>
<Td className="w-5">
{name !== "default" && (
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<IconButton
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteTemplate", template);
}}
variant="plain"
colorSchema="danger"
ariaLabel="Delete template"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</OrgPermissionCan>
)}
</Td>
</Tr>
);
})}
{!isPending && filteredTemplates?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title={
search.trim()
? "No project templates match search"
: "No project templates found"
}
icon={faClone}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<DeleteProjectTemplateModal
isOpen={popUp.deleteTemplate.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteTemplate", isOpen)}
template={popUp.deleteTemplate.data}
/>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectTemplatesSection } from "./ProjectTemplatesSection";
@@ -0,0 +1 @@
export { ProjectTemplatesTab } from "./ProjectTemplatesTab";
@@ -0,0 +1 @@
export { OrgTabGroup } from "./OrgTabGroup";
@@ -0,0 +1,19 @@
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { SettingsPage } from "./SettingsPage";
const SettingsPageQueryParams = z.object({
selectedTab: z.string().catch("")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/organization/_layout/settings"
)({
component: SettingsPage,
validateSearch: zodValidator(SettingsPageQueryParams),
search: {
middlewares: [stripSearchParams({ selectedTab: "" })]
}
});