This commit is contained in:
Scott Wilson
2024-11-29 08:19:02 -08:00
parent a852b15a1e
commit a18f3c2919
4 changed files with 128 additions and 144 deletions
@@ -37,7 +37,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
status: null, status: null,
trial_end: null, trial_end: null,
has_used_trial: true, has_used_trial: true,
secretApproval: false, secretApproval: true,
secretRotation: true, secretRotation: true,
caCrl: false, caCrl: false,
instanceUserManagement: false, instanceUserManagement: false,
@@ -34,18 +34,19 @@ export const FilterableSelect = <T,>({
tabSelectsValue={tabSelectsValue} tabSelectsValue={tabSelectsValue}
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }} components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
classNames={{ classNames={{
container: () => "w-full font-inter", container: () => "w-full text-sm font-inter",
control: ({ isFocused }) => control: ({ isFocused }) =>
twMerge( twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400", isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer" "border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer"
), ),
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5", placeholder: () =>
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1 py-0.5", input: () => "pl-1 py-0.5",
valueContainer: () => valueContainer: () =>
`p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`, `p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`,
singleValue: () => "leading-7 ml-1", singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5", multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm", multiValueLabel: () => "leading-6 text-sm",
multiValueRemove: () => "hover:text-red text-bunker-400", multiValueRemove: () => "hover:text-red text-bunker-400",
indicatorsContainer: () => "p-1 gap-1", indicatorsContainer: () => "p-1 gap-1",
@@ -53,7 +54,7 @@ export const FilterableSelect = <T,>({
indicatorSeparator: () => "bg-bunker-400", indicatorSeparator: () => "bg-bunker-400",
dropdownIndicator: () => "text-bunker-200 p-1", dropdownIndicator: () => "text-bunker-200 p-1",
menu: () => menu: () =>
"mt-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md", "my-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm", groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) => option: ({ isFocused, isSelected }) =>
twMerge( twMerge(
@@ -175,7 +175,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
Create policy Create Policy
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
@@ -1,18 +1,12 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Button, Button,
DropdownMenu, FilterableSelect,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
FormControl, FormControl,
Input, Input,
Modal, Modal,
@@ -46,21 +40,34 @@ type Props = {
const formSchema = z const formSchema = z
.object({ .object({
environment: z.string(), environment: z.object({ slug: z.string(), name: z.string() }),
name: z.string().optional(), name: z.string().optional(),
secretPath: z.string().optional(), secretPath: z.string().optional(),
approvals: z.number().min(1), approvals: z.number().min(1),
approvers: z userApprovers: z
.object({ type: z.nativeEnum(ApproverType), id: z.string() }) .object({ type: z.literal(ApproverType.User), id: z.string() })
.array()
.default([]),
groupApprovers: z
.object({ type: z.literal(ApproverType.Group), id: z.string() })
.array() .array()
.min(1)
.default([]), .default([]),
policyType: z.nativeEnum(PolicyType), policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel) enforcementLevel: z.nativeEnum(EnforcementLevel)
}) })
.refine((data) => data.approvers, { .superRefine((data, ctx) => {
path: ["approvers"], if (!(data.groupApprovers.length || data.userApprovers.length)) {
message: "At least one approver should be provided." ctx.addIssue({
path: ["userApprovers"],
code: z.ZodIssueCode.custom,
message: "At least one approver should be provided"
});
ctx.addIssue({
path: ["groupApprovers"],
code: z.ZodIssueCode.custom,
message: "At least one approver should be provided"
});
}
}); });
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
@@ -84,8 +91,15 @@ export const AccessPolicyForm = ({
values: editValues values: editValues
? { ? {
...editValues, ...editValues,
environment: editValues.environment.slug, environment: editValues.environment,
approvers: editValues?.approvers || [], userApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
groupApprovers:
editValues?.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
approvals: editValues?.approvals approvals: editValues?.approvals
} }
: undefined : undefined
@@ -110,18 +124,27 @@ export const AccessPolicyForm = ({
const approversRequired = watch("approvals") || 1; const approversRequired = watch("approvals") || 1;
const handleCreatePolicy = async (data: TFormSchema) => { const handleCreatePolicy = async ({
environment,
groupApprovers,
userApprovers,
...data
}: TFormSchema) => {
if (!projectId) return; if (!projectId) return;
try { try {
if (data.policyType === PolicyType.ChangePolicy) { if (data.policyType === PolicyType.ChangePolicy) {
await createSecretApprovalPolicy({ await createSecretApprovalPolicy({
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
workspaceId: currentWorkspace?.id || "" workspaceId: currentWorkspace?.id || ""
}); });
} else { } else {
await createAccessApprovalPolicy({ await createAccessApprovalPolicy({
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
projectSlug projectSlug
}); });
} }
@@ -139,7 +162,12 @@ export const AccessPolicyForm = ({
} }
}; };
const handleUpdatePolicy = async (data: TFormSchema) => { const handleUpdatePolicy = async ({
environment,
userApprovers,
groupApprovers,
...data
}: TFormSchema) => {
if (!projectId || !projectSlug) return; if (!projectId || !projectSlug) return;
if (!editValues?.id) return; if (!editValues?.id) return;
@@ -148,12 +176,15 @@ export const AccessPolicyForm = ({
await updateSecretApprovalPolicy({ await updateSecretApprovalPolicy({
id: editValues?.id, id: editValues?.id,
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
workspaceId: currentWorkspace?.id || "" workspaceId: currentWorkspace?.id || ""
}); });
} else { } else {
await updateAccessApprovalPolicy({ await updateAccessApprovalPolicy({
id: editValues?.id, id: editValues?.id,
...data, ...data,
approvers: [...userApprovers, ...groupApprovers],
environment: environment.slug,
projectSlug projectSlug
}); });
} }
@@ -179,9 +210,35 @@ export const AccessPolicyForm = ({
} }
}; };
const memberOptions = useMemo(
() =>
members.map(({ inviteEmail, user: { firstName, lastName, id: userId, username } }) => ({
id: userId,
type: ApproverType.User,
label:
firstName || lastName
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
: username || inviteEmail
})),
[members]
);
const groupOptions = useMemo(
() =>
groups?.map(({ group }) => ({
id: group.id,
type: ApproverType.Group,
label: group.name
})),
[groups]
);
return ( return (
<Modal isOpen={isOpen} onOpenChange={onToggle}> <Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent title={isEditMode ? `Edit ${policyName}` : "Create Policy"}> <ModalContent
bodyClassName="overflow-visible"
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
>
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller <Controller
@@ -229,7 +286,6 @@ export const AccessPolicyForm = ({
<Controller <Controller
control={control} control={control}
name="environment" name="environment"
defaultValue={environments[0]?.slug}
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Environment" label="Environment"
@@ -238,21 +294,15 @@ export const AccessPolicyForm = ({
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<Select <FilterableSelect
isDisabled={isEditMode} isDisabled={isEditMode}
value={value} value={value}
onValueChange={(val) => onChange(val)} onChange={onChange}
className="w-full border border-mineshaft-500" placeholder="Select environment..."
> options={environments}
{environments.map((sourceEnvironment) => ( getOptionValue={(option) => option.slug}
<SelectItem getOptionLabel={(option) => option.name}
value={sourceEnvironment.slug} />
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
>
{sourceEnvironment.name}
</SelectItem>
))}
</Select>
</FormControl> </FormControl>
)} )}
/> />
@@ -331,127 +381,60 @@ export const AccessPolicyForm = ({
</div> </div>
<Controller <Controller
control={control} control={control}
name="approvers" name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="User Approvers" label="User Approvers"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<DropdownMenu> <FilterableSelect
<DropdownMenuTrigger asChild> menuPlacement="top"
<Input isMulti
isReadOnly placeholder="Select members that are allowed to approve requests..."
value={ options={memberOptions}
value?.filter((e) => e.type === ApproverType.User).length getOptionValue={(option) => option.id}
? `${value.filter((e) => e.type === ApproverType.User).length} selected` getOptionLabel={(option) => {
: "None" const member = members?.find((m) => m.user.id === option.id);
}
className="text-left" if (!member) return option.id;
/>
</DropdownMenuTrigger> const {
<DropdownMenuContent inviteEmail,
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }} user: { firstName, lastName, username, email }
align="start" } = member;
>
<DropdownMenuLabel> return firstName || lastName
Select members that are allowed to approve requests ? `${firstName ?? ""} ${lastName ?? ""}`.trim()
</DropdownMenuLabel> : username || email || inviteEmail;
{members.map(({ user }) => { }}
const { id: userId } = user; value={value}
const isChecked = onChange={onChange}
value?.filter( />
(el: { id: string; type: ApproverType }) =>
el.id === userId && el.type === ApproverType.User
).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id !== userId && el.type !== ApproverType.User
)
: [...(value || []), { id: userId, type: ApproverType.User }]
);
}}
key={`create-policy-members-${userId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl> </FormControl>
)} )}
/> />
<Controller <Controller
control={control} control={control}
name="approvers" name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Group Approvers" label="Group Approvers"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
<DropdownMenu> <FilterableSelect
<DropdownMenuTrigger asChild> menuPlacement="top"
<Input isMulti
isReadOnly placeholder="Select groups that are allowed to approve requests..."
value={ options={groupOptions}
value?.filter((e) => e.type === ApproverType.Group).length getOptionValue={(option) => option.id}
? `${ getOptionLabel={(option) =>
value?.filter((e) => e.type === ApproverType.Group).length groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
} selected` }
: "None" value={value}
} onChange={onChange}
className="text-left" />
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select groups that are allowed to approve requests
</DropdownMenuLabel>
{groups &&
groups.map(({ group }) => {
const { id } = group;
const isChecked =
value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id === id && el.type === ApproverType.Group
).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter(
(el: { id: string; type: ApproverType }) =>
el.id !== id && el.type !== ApproverType.Group
)
: [...(value || []), { id, type: ApproverType.Group }]
);
}}
key={`create-policy-members-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl> </FormControl>
)} )}
/> />