mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 10:18:16 +00:00
feat: renamed old frontend and fixed some minor bugs
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+97
@@ -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
|
||||
}
|
||||
);
|
||||
+47
@@ -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>
|
||||
);
|
||||
};
|
||||
+117
@@ -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>
|
||||
);
|
||||
};
|
||||
+187
@@ -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>
|
||||
);
|
||||
};
|
||||
+42
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+164
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./AppConnectionForm";
|
||||
export * from "./GenericAppConnectionFields";
|
||||
+57
@@ -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>
|
||||
);
|
||||
};
|
||||
+86
@@ -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>
|
||||
);
|
||||
};
|
||||
+169
@@ -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>
|
||||
);
|
||||
};
|
||||
+321
@@ -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>
|
||||
);
|
||||
};
|
||||
+51
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+33
@@ -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>
|
||||
);
|
||||
};
|
||||
+109
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./AddAppConnectionModal";
|
||||
export * from "./AppConnectionsTable";
|
||||
@@ -0,0 +1 @@
|
||||
export { AppConnectionsTab } from "./AppConnectionsTab";
|
||||
+207
@@ -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>
|
||||
);
|
||||
};
|
||||
+197
@@ -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>
|
||||
);
|
||||
};
|
||||
+118
@@ -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>
|
||||
);
|
||||
};
|
||||
+117
@@ -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";
|
||||
+101
@@ -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";
|
||||
+214
@@ -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>
|
||||
);
|
||||
};
|
||||
+293
@@ -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 }
|
||||
);
|
||||
+98
@@ -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."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+117
@@ -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>
|
||||
);
|
||||
};
|
||||
+172
@@ -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>
|
||||
);
|
||||
};
|
||||
+163
@@ -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>
|
||||
);
|
||||
};
|
||||
+118
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+318
@@ -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";
|
||||
+73
@@ -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";
|
||||
+116
@@ -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>
|
||||
);
|
||||
};
|
||||
+273
@@ -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>
|
||||
);
|
||||
};
|
||||
+360
@@ -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>
|
||||
);
|
||||
};
|
||||
+221
@@ -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'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 }
|
||||
);
|
||||
+37
@@ -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";
|
||||
+16
@@ -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";
|
||||
+104
@@ -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>
|
||||
);
|
||||
};
|
||||
+53
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+119
@@ -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>
|
||||
);
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal";
|
||||
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
|
||||
+168
@@ -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";
|
||||
+93
@@ -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>
|
||||
);
|
||||
};
|
||||
+26
@@ -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>
|
||||
);
|
||||
};
|
||||
+238
@@ -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 }
|
||||
);
|
||||
+138
@@ -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>
|
||||
);
|
||||
};
|
||||
+9
@@ -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
|
||||
});
|
||||
+49
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+55
@@ -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>
|
||||
);
|
||||
};
|
||||
+119
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+211
@@ -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>
|
||||
);
|
||||
};
|
||||
+281
@@ -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>
|
||||
);
|
||||
};
|
||||
+223
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./EditProjectTemplate";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./EditProjectTemplateSection";
|
||||
+143
@@ -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>
|
||||
);
|
||||
};
|
||||
+117
@@ -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>
|
||||
);
|
||||
};
|
||||
+191
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -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: "" })]
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user