Merge pull request #2701 from scott-ray-wilson/project-templates-feature

Feature: Project Templates
This commit is contained in:
Maidul Islam
2024-11-10 21:03:11 -07:00
committed by GitHub
78 changed files with 2892 additions and 104 deletions

View File

@@ -1,4 +1,4 @@
import { cloneElement, ReactNode } from "react";
import { cloneElement, ReactElement, ReactNode } from "react";
import { faExclamationTriangle, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as Label from "@radix-ui/react-label";
@@ -82,7 +82,7 @@ export type FormControlProps = {
children: JSX.Element;
className?: string;
icon?: ReactNode;
tooltipText?: string;
tooltipText?: ReactElement | string;
};
export const FormControl = ({

View File

@@ -22,7 +22,8 @@ export enum OrgPermissionSubjects {
Identity = "identity",
Kms = "kms",
AdminConsole = "organization-admin-console",
AuditLogs = "audit-logs"
AuditLogs = "audit-logs",
ProjectTemplates = "project-templates"
}
export enum OrgPermissionAdminConsoleAction {
@@ -45,6 +46,7 @@ export type OrgPermissionSet =
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs];
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates];
export type TOrgPermission = MongoAbility<OrgPermissionSet>;

View File

@@ -1,3 +1,5 @@
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
enum OrgMembershipRole {
Admin = "admin",
Member = "member",
@@ -18,3 +20,6 @@ export const formatProjectRoleName = (name: string) => {
if (name === ProjectMemberRole.Member) return "developer";
return name;
};
export const isCustomProjectRole = (slug: string) =>
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);

View File

@@ -0,0 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";

View File

@@ -0,0 +1,58 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { projectTemplateKeys } from "@app/hooks/api/projectTemplates/queries";
import {
TCreateProjectTemplateDTO,
TDeleteProjectTemplateDTO,
TProjectTemplateResponse,
TUpdateProjectTemplateDTO
} from "@app/hooks/api/projectTemplates/types";
export const useCreateProjectTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: TCreateProjectTemplateDTO) => {
const { data } = await apiRequest.post<TProjectTemplateResponse>(
"/api/v1/project-templates",
payload
);
return data.projectTemplate;
},
onSuccess: () => queryClient.invalidateQueries(projectTemplateKeys.list())
});
};
export const useUpdateProjectTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ templateId, ...params }: TUpdateProjectTemplateDTO) => {
const { data } = await apiRequest.patch<TProjectTemplateResponse>(
`/api/v1/project-templates/${templateId}`,
params
);
return data.projectTemplate;
},
onSuccess: (_, { templateId }) => {
queryClient.invalidateQueries(projectTemplateKeys.list());
queryClient.invalidateQueries(projectTemplateKeys.byId(templateId));
}
});
};
export const useDeleteProjectTemplate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ templateId }: TDeleteProjectTemplateDTO) => {
const { data } = await apiRequest.delete(`/api/v1/project-templates/${templateId}`);
return data;
},
onSuccess: (_, { templateId }) => {
queryClient.invalidateQueries(projectTemplateKeys.list());
queryClient.invalidateQueries(projectTemplateKeys.byId(templateId));
}
});
};

View File

@@ -0,0 +1,61 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import {
TListProjectTemplates,
TProjectTemplate,
TProjectTemplateResponse
} from "@app/hooks/api/projectTemplates/types";
export const projectTemplateKeys = {
all: ["project-template"] as const,
list: () => [...projectTemplateKeys.all, "list"] as const,
byId: (templateId: string) => [...projectTemplateKeys.all, templateId] as const
};
export const useListProjectTemplates = (
options?: Omit<
UseQueryOptions<
TProjectTemplate[],
unknown,
TProjectTemplate[],
ReturnType<typeof projectTemplateKeys.list>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: projectTemplateKeys.list(),
queryFn: async () => {
const { data } = await apiRequest.get<TListProjectTemplates>("/api/v1/project-templates");
return data.projectTemplates;
},
...options
});
};
export const useGetProjectTemplateById = (
templateId: string,
options?: Omit<
UseQueryOptions<
TProjectTemplate,
unknown,
TProjectTemplate,
ReturnType<typeof projectTemplateKeys.byId>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: projectTemplateKeys.byId(templateId),
queryFn: async () => {
const { data } = await apiRequest.get<TProjectTemplateResponse>(
`/api/v1/project-templates/${templateId}`
);
return data.projectTemplate;
},
...options
});
};

View File

@@ -0,0 +1,31 @@
import { TProjectRole } from "@app/hooks/api/roles/types";
export type TProjectTemplate = {
id: string;
name: string;
description?: string;
roles: Pick<TProjectRole, "slug" | "name" | "permissions">[];
environments: { name: string; slug: string; position: number }[];
createdAt: string;
updatedAt: string;
};
export type TListProjectTemplates = { projectTemplates: TProjectTemplate[] };
export type TProjectTemplateResponse = { projectTemplate: TProjectTemplate };
export type TCreateProjectTemplateDTO = {
name: string;
description?: string;
};
export type TUpdateProjectTemplateDTO = Partial<
Pick<TProjectTemplate, "name" | "description" | "roles" | "environments">
> & { templateId: string };
export type TDeleteProjectTemplateDTO = {
templateId: string;
};
export enum InfisicalProjectTemplate {
Default = "default"
}

View File

@@ -43,4 +43,5 @@ export type SubscriptionPlan = {
externalKms: boolean;
pkiEst: boolean;
enforceMfa: boolean;
projectTemplates: boolean;
};

View File

@@ -208,19 +208,21 @@ export const useGetWorkspaceIntegrations = (workspaceId: string) =>
export const createWorkspace = ({
projectName,
kmsKeyId
kmsKeyId,
template
}: CreateWorkspaceDTO): Promise<{ data: { project: Workspace } }> => {
return apiRequest.post("/api/v2/workspace", { projectName, kmsKeyId });
return apiRequest.post("/api/v2/workspace", { projectName, kmsKeyId, template });
};
export const useCreateWorkspace = () => {
const queryClient = useQueryClient();
return useMutation<{ data: { project: Workspace } }, {}, CreateWorkspaceDTO>({
mutationFn: async ({ projectName, kmsKeyId }) =>
mutationFn: async ({ projectName, kmsKeyId, template }) =>
createWorkspace({
projectName,
kmsKeyId
kmsKeyId,
template
}),
onSuccess: () => {
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);

View File

@@ -57,6 +57,7 @@ export type TGetUpgradeProjectStatusDTO = {
export type CreateWorkspaceDTO = {
projectName: string;
kmsKeyId?: string;
template?: string;
};
export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string };

View File

@@ -21,6 +21,7 @@ import {
faEnvelope,
faInfinity,
faInfo,
faInfoCircle,
faMobile,
faPlus,
faQuestion,
@@ -78,6 +79,7 @@ import {
useSelectOrganization
} from "@app/hooks/api";
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
import { Workspace } from "@app/hooks/api/types";
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
@@ -124,7 +126,8 @@ const formSchema = yup.object({
.trim()
.max(64, "Too long, maximum length is 64 characters"),
addMembers: yup.bool().required().label("Add Members"),
kmsKeyId: yup.string().label("KMS Key ID")
kmsKeyId: yup.string().label("KMS Key ID"),
template: yup.string().label("Project Template Name")
});
type TAddProjectFormData = yup.InferType<typeof formSchema>;
@@ -273,7 +276,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
putUserInOrg();
}, [router.query.id]);
const onCreateProject = async ({ name, addMembers, kmsKeyId }: TAddProjectFormData) => {
const canReadProjectTemplates = permission.can(
OrgPermissionActions.Read,
OrgPermissionSubjects.ProjectTemplates
);
const { data: projectTemplates = [] } = useListProjectTemplates({
enabled: canReadProjectTemplates && subscription?.projectTemplates
});
const onCreateProject = async ({ name, addMembers, kmsKeyId, template }: TAddProjectFormData) => {
// type check
if (!currentOrg) return;
if (!user) return;
@@ -284,7 +296,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
}
} = await createWs.mutateAsync({
projectName: name,
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined,
template
});
if (addMembers) {
@@ -909,20 +922,72 @@ export const AppLayout = ({ children }: LayoutProps) => {
subTitle="This project will contain your secrets and configurations."
>
<form onSubmit={handleSubmit(onCreateProject)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Project Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your project name" />
</FormControl>
)}
/>
<div className="flex gap-2">
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Project Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-1"
>
<Input {...field} placeholder="Type your project name" />
</FormControl>
)}
/>
<Controller
control={control}
name="template"
render={({ field: { value, onChange } }) => (
<OrgPermissionCan
I={OrgPermissionActions.Read}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<FormControl
label="Project Template"
icon={<FontAwesomeIcon icon={faInfoCircle} size="sm" />}
tooltipText={
<>
<p>
Create this project from a template to provision it with custom
environments and roles.
</p>
{subscription && !subscription.projectTemplates && (
<p className="pt-2">Project templates are a paid feature.</p>
)}
</>
}
>
<Select
defaultValue={InfisicalProjectTemplate.Default}
placeholder={InfisicalProjectTemplate.Default}
isDisabled={!isAllowed || !subscription?.projectTemplates}
value={value}
onValueChange={onChange}
className="w-44"
>
{projectTemplates.length
? projectTemplates.map((template) => (
<SelectItem key={template.id} value={template.name}>
{template.name}
</SelectItem>
))
: Object.values(InfisicalProjectTemplate).map((template) => (
<SelectItem key={template} value={template}>
{template}
</SelectItem>
))}
</Select>
</FormControl>
)}
</OrgPermissionCan>
)}
/>
</div>
<div className="mt-4 pl-1">
<Controller
control={control}

View File

@@ -0,0 +1 @@
export * from "./slugSchema";

View File

@@ -0,0 +1,12 @@
import slugify from "@sindresorhus/slugify";
import { z } from "zod";
export const slugSchema = z
.string()
.trim()
.min(1)
.max(32)
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.refine((v) => slugify(v) === v, {
message: "Invalid slug format"
});

View File

@@ -19,6 +19,7 @@ import {
faExclamationCircle,
faFileShield,
faHandPeace,
faInfoCircle,
faList,
faMagnifyingGlass,
faNetworkWired,
@@ -69,6 +70,7 @@ import {
useRegisterUserAction
} from "@app/hooks/api";
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
import { Workspace } from "@app/hooks/api/types";
@@ -482,7 +484,8 @@ const formSchema = yup.object({
.trim()
.max(64, "Too long, maximum length is 64 characters"),
addMembers: yup.bool().required().label("Add Members"),
kmsKeyId: yup.string().label("KMS Key ID")
kmsKeyId: yup.string().label("KMS Key ID"),
template: yup.string().label("Project Template Name")
});
type TAddProjectFormData = yup.InferType<typeof formSchema>;
@@ -537,7 +540,7 @@ const OrganizationPage = () => {
enabled: permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Kms)
});
const onCreateProject = async ({ name, addMembers, kmsKeyId }: TAddProjectFormData) => {
const onCreateProject = async ({ name, addMembers, kmsKeyId, template }: TAddProjectFormData) => {
// type check
if (!currentOrg) return;
if (!user) return;
@@ -548,7 +551,8 @@ const OrganizationPage = () => {
}
} = await createWs.mutateAsync({
projectName: name,
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined
kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined,
template
});
if (addMembers) {
@@ -579,6 +583,15 @@ const OrganizationPage = () => {
const { subscription } = useSubscription();
const canReadProjectTemplates = permission.can(
OrgPermissionActions.Read,
OrgPermissionSubjects.ProjectTemplates
);
const { data: projectTemplates = [] } = useListProjectTemplates({
enabled: canReadProjectTemplates && subscription?.projectTemplates
});
const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
@@ -1037,20 +1050,72 @@ const OrganizationPage = () => {
subTitle="This project will contain your secrets and configurations."
>
<form onSubmit={handleSubmit(onCreateProject)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Project Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your project name" />
</FormControl>
)}
/>
<div className="flex gap-2">
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Project Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-1"
>
<Input {...field} placeholder="Type your project name" />
</FormControl>
)}
/>
<Controller
control={control}
name="template"
render={({ field: { value, onChange } }) => (
<OrgPermissionCan
I={OrgPermissionActions.Read}
a={OrgPermissionSubjects.ProjectTemplates}
>
{(isAllowed) => (
<FormControl
label="Project Template"
icon={<FontAwesomeIcon icon={faInfoCircle} size="sm" />}
tooltipText={
<>
<p>
Create this project from a template to provision it with custom
environments and roles.
</p>
{subscription && !subscription.projectTemplates && (
<p className="pt-2">Project templates are a paid feature.</p>
)}
</>
}
>
<Select
defaultValue={InfisicalProjectTemplate.Default}
placeholder={InfisicalProjectTemplate.Default}
isDisabled={!isAllowed || !subscription?.projectTemplates}
value={value}
onValueChange={onChange}
className="w-44"
>
{projectTemplates.length
? projectTemplates.map((template) => (
<SelectItem key={template.id} value={template.name}>
{template.name}
</SelectItem>
))
: Object.values(InfisicalProjectTemplate).map((template) => (
<SelectItem key={template} value={template}>
{template}
</SelectItem>
))}
</Select>
</FormControl>
)}
</OrgPermissionCan>
)}
/>
</div>
<div className="mt-4 pl-1">
<Controller
control={control}

View File

@@ -48,7 +48,8 @@ export const formSchema = z.object({
billing: generalPermissionSchema,
identity: generalPermissionSchema,
"organization-admin-console": adminConsolePermissionSchmea,
[OrgPermissionSubjects.Kms]: generalPermissionSchema
[OrgPermissionSubjects.Kms]: generalPermissionSchema,
[OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema
})
.optional()
});

View File

@@ -5,6 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
import { OrgPermissionSubjects } from "@app/context";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils";
@@ -43,6 +44,13 @@ const BILLING_PERMISSIONS = [
{ action: "delete", label: "Remove payments" }
] as const;
const PROJECT_TEMPLATES_PERMISSIONS = [
{ action: "read", label: "View & Apply" },
{ action: "create", label: "Create" },
{ action: "edit", label: "Modify" },
{ action: "delete", label: "Remove" }
] as const;
const getPermissionList = (option: string) => {
switch (option) {
case "secret-scanning":
@@ -53,6 +61,8 @@ const getPermissionList = (option: string) => {
return INCIDENT_CONTACTS_PERMISSIONS;
case "member":
return MEMBERS_PERMISSIONS;
case OrgPermissionSubjects.ProjectTemplates:
return PROJECT_TEMPLATES_PERMISSIONS;
default:
return PERMISSIONS;
}

View File

@@ -68,7 +68,8 @@ const SIMPLE_PERMISSION_OPTIONS = [
{
title: "External KMS",
formName: OrgPermissionSubjects.Kms
}
},
{ title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }
] as const;
type Props = {

View File

@@ -86,6 +86,7 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-full"
className="w-full"
isDisabled={isDisabled}
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>

View File

@@ -172,22 +172,26 @@ export const QuickSearchSecretItem = ({
}}
key={secret.id}
>
{!isSingleEnv && (
<span className="text-xs text-mineshaft-400">
{envSlugMap.get(secret.env)?.name}
</span>
)}
<p
className={twMerge(
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
!secret.value && "text-mineshaft-400"
)}
>
{secret.value || "EMPTY"}
</p>
<p className="w-[12rem] text-sm group-hover:hidden">
***************************
</p>
<Tooltip side="left" sideOffset={18} content="Click to copy to clipboard">
<div>
{!isSingleEnv && (
<span className="text-xs text-mineshaft-400">
{envSlugMap.get(secret.env)?.name}
</span>
)}
<p
className={twMerge(
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
!secret.value && "text-mineshaft-400"
)}
>
{secret.value || "EMPTY"}
</p>
<p className="w-[12rem] text-sm group-hover:hidden">
***************************
</p>
</div>
</Tooltip>
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -4,6 +4,7 @@ import { Tab } from "@headlessui/react";
import { OrgPermissionCan } from "@app/components/permissions";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { ProjectTemplatesTab } from "@app/views/Settings/OrgSettingsPage/components/ProjectTemplatesTab";
import { AuditLogStreamsTab } from "../AuditLogStreamTab";
import { ImportTab } from "../ImportTab";
@@ -18,7 +19,8 @@ const tabs = [
{ name: "Encryption", key: "tab-org-encryption" },
{ name: "Workflow Integrations", key: "workflow-integrations" },
{ name: "Audit Log Streams", key: "tag-audit-log-streams" },
{ name: "Import", key: "tab-import" }
{ name: "Import", key: "tab-import" },
{ name: "Project Templates", key: "project-templates" }
];
export const OrgTabGroup = () => {
const { query } = useRouter();
@@ -69,10 +71,13 @@ export const OrgTabGroup = () => {
<AuditLogStreamsTab />
</Tab.Panel>
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
<Tab.Panel>
<ImportTab />
</Tab.Panel>
<Tab.Panel>
<ImportTab />
</Tab.Panel>
</OrgPermissionCan>
<Tab.Panel>
<ProjectTemplatesTab />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
);

View File

@@ -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
});

View File

@@ -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}
/>
);
};

View File

@@ -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, isLoading } = 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 */}
{isLoading ? (
<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>
);
};

View File

@@ -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 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}
/>
</>
);
};

View File

@@ -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/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies";
import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule";
import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState";
import {
formRolePermission2API,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form
} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/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>
);
};

View File

@@ -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
})
.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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export * from "./EditProjectTemplateSection";

View File

@@ -0,0 +1,151 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import slugify from "@sindresorhus/slugify";
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";
const formSchema = z.object({
name: z
.string()
.trim()
.min(1)
.max(32)
.toLowerCase()
.refine((v) => slugify(v) === v, {
message: "Name must be in slug format"
}),
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>
);
};

View File

@@ -0,0 +1,118 @@
import { useState } from "react";
import Link from "next/link";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, UpgradePlanModal } 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>
<Link
href="https://infisical.com/docs/documentation/platform/project-templates"
passHref
>
<a target="_blank" rel="noopener noreferrer">
<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="ml-1.5 mb-[0.07rem] text-[10px]"
/>
</div>
</a>
</Link>
<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>
);
};

View File

@@ -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 { isLoading, 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>
{isLoading && (
<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>
);
})}
{!isLoading && 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>
);
};

View File

@@ -0,0 +1 @@
export { ProjectTemplatesSection } from "./ProjectTemplatesSection";

View File

@@ -0,0 +1 @@
export { ProjectTemplatesTab } from "./ProjectTemplatesTab";