improvement: update secret approval policy form to use filterable selects w/ UI revisions

This commit is contained in:
Scott Wilson
2024-11-29 10:44:05 -08:00
parent a18f3c2919
commit bb094f60c1
5 changed files with 238 additions and 319 deletions
@@ -34,17 +34,22 @@ export const FilterableSelect = <T,>({
tabSelectsValue={tabSelectsValue} tabSelectsValue={tabSelectsValue}
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }} components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
classNames={{ classNames={{
container: () => "w-full text-sm font-inter", container: ({ isDisabled }) =>
control: ({ isFocused }) => twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
control: ({ isFocused, isDisabled }) =>
twMerge( twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400", isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
"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 ${
isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer"
} `
), ),
placeholder: () => placeholder: () =>
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`, `${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1 py-0.5", input: () => "pl-1",
valueContainer: () => valueContainer: () =>
`p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`, `px-1 max-h-[8.2rem] ${
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
} gap-1`,
singleValue: () => "leading-7 ml-1", singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 text-sm 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",
+12
View File
@@ -0,0 +1,12 @@
import { TWorkspaceUser } from "@app/hooks/api/users/types";
export const getMemberLabel = (member: TWorkspaceUser) => {
const {
inviteEmail,
user: { firstName, lastName, username, email }
} = member;
return firstName || lastName
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
: username || email || inviteEmail;
};
@@ -188,8 +188,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Th>Name</Th> <Th>Name</Th>
<Th>Environment</Th> <Th>Environment</Th>
<Th>Secret Path</Th> <Th>Secret Path</Th>
<Th>Eligible Approvers</Th> <Th className="w-[18%]">Eligible Approvers</Th>
<Th>Eligible Group Approvers</Th> <Th className="w-[18%]">Eligible Group Approvers</Th>
<Th>Approval Required</Th> <Th>Approval Required</Th>
<Th> <Th>
<DropdownMenu> <DropdownMenu>
@@ -256,9 +256,9 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
{!!currentWorkspace && {!!currentWorkspace &&
filteredPolicies?.map((policy) => ( filteredPolicies?.map((policy) => (
<ApprovalPolicyRow <ApprovalPolicyRow
projectSlug={currentWorkspace.slug} // projectSlug={currentWorkspace.slug}
policy={policy} policy={policy}
workspaceId={workspaceId} // workspaceId={workspaceId}
key={policy.id} key={policy.id}
members={members} members={members}
groups={groups} groups={groups}
@@ -15,6 +15,7 @@ import {
SelectItem SelectItem
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies"; import { policyDetails } from "@app/helpers/policies";
import { import {
useCreateSecretApprovalPolicy, useCreateSecretApprovalPolicy,
@@ -236,143 +237,157 @@ export const AccessPolicyForm = ({
return ( return (
<Modal isOpen={isOpen} onOpenChange={onToggle}> <Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent <ModalContent
className="max-w-2xl"
bodyClassName="overflow-visible" bodyClassName="overflow-visible"
title={isEditMode ? `Edit ${policyName}` : "Create Policy"} 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 <div className="grid grid-cols-2 gap-x-3">
control={control} <Controller
name="policyType" control={control}
defaultValue={PolicyType.ChangePolicy} name="policyType"
render={({ field: { value, onChange }, fieldState: { error } }) => ( defaultValue={PolicyType.ChangePolicy}
<FormControl render={({ field: { value, onChange }, fieldState: { error } }) => (
label="Policy Type" <FormControl
isRequired label="Policy Type"
isError={Boolean(error)} isRequired
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path." isError={Boolean(error)}
errorText={error?.message} tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
> errorText={error?.message}
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
> >
{Object.values(PolicyType).map((policyType) => { <Select
return ( isDisabled={isEditMode}
<SelectItem value={policyType} key={`policy-type-${policyType}`}> value={value}
{policyDetails[policyType].name} onValueChange={(val) => onChange(val as PolicyType)}
</SelectItem> className="w-full border border-mineshaft-500"
); >
})} {Object.values(PolicyType).map((policyType) => {
</Select> return (
</FormControl> <SelectItem value={policyType} key={`policy-type-${policyType}`}>
)} {policyDetails[policyType].name}
/> </SelectItem>
<Controller );
control={control} })}
name="name" </Select>
render={({ field, fieldState: { error } }) => ( </FormControl>
<FormControl )}
label="Policy Name" />
isError={Boolean(error)} <Controller
errorText={error?.message} control={control}
> name="approvals"
<Input {...field} value={field.value || ""} /> defaultValue={1}
</FormControl> render={({ field, fieldState: { error } }) => (
)} <FormControl
/> label="Minimum Approvals Required"
<Controller isError={Boolean(error)}
control={control} errorText={error?.message}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
className="mt-4"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Minimum Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Enforcement Level"
isError={Boolean(error)}
errorText={error?.message}
tooltipText="Determines the level of enforcement for required approvers of a request"
helperText={
<div className="ml-1">
{field.value === EnforcementLevel.Hard
? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.`
: `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`}
</div>
}
>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
className="w-full border border-mineshaft-500"
> >
{Object.values(EnforcementLevel).map((level) => { <Input
return ( {...field}
<SelectItem value={level} key={`enforcement-level-${level}`}> type="number"
<span className="capitalize">{level}</span> min={1}
</SelectItem> onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
); />
})} </FormControl>
</Select> )}
</FormControl> />
)} <Controller
/> control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
<Controller
control={control}
name="enforcementLevel"
defaultValue={EnforcementLevel.Hard}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Enforcement Level"
isError={Boolean(error)}
errorText={error?.message}
tooltipText={
<>
<p>
Determines the level of enforcement for required approvers of a request:
</p>
<p className="mt-2">
<span className="font-bold">Hard</span> enforcement requires at least{" "}
<span className="font-bold"> {approversRequired}</span> approver(s) to
approve the request.`
</p>
<p className="mt-2">
<span className="font-bold">Soft</span> enforcement At least{" "}
<span className="font-bold">{approversRequired}</span> approver(s) must
approve the request; however, the requester can bypass approval
requirements in emergencies.
</p>
</>
}
>
<Select
value={field.value}
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
className="w-full border border-mineshaft-500"
>
{Object.values(EnforcementLevel).map((level) => {
return (
<SelectItem value={level} key={`enforcement-level-${level}`}>
<span className="capitalize">{level}</span>
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
isDisabled={isEditMode}
value={value}
onChange={onChange}
placeholder="Select environment..."
options={environments}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="secretPath"
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
<div className="mb-2"> <div className="mb-2">
<p>Approvers</p> <p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90"> <p className="font-inter text-xs text-mineshaft-300 opacity-90">
@@ -399,14 +414,7 @@ export const AccessPolicyForm = ({
if (!member) return option.id; if (!member) return option.id;
const { return getMemberLabel(member);
inviteEmail,
user: { firstName, lastName, username, email }
} = member;
return firstName || lastName
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
: username || email || inviteEmail;
}} }}
value={value} value={value}
onChange={onChange} onChange={onChange}
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useMemo } from "react";
import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -8,19 +8,19 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
Input,
Td, Td,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies"; import { policyDetails } from "@app/helpers/policies";
import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; import { Approver } from "@app/hooks/api/accessApproval/types";
import { Approver, ApproverType } from "@app/hooks/api/accessApproval/types";
import { TGroupMembership } from "@app/hooks/api/groups/types"; import { TGroupMembership } from "@app/hooks/api/groups/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { ApproverType } from "@app/hooks/api/secretApproval/types";
import { WorkspaceEnv } from "@app/hooks/api/types"; import { WorkspaceEnv } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types"; import { TWorkspaceUser } from "@app/hooks/api/users/types";
@@ -35,14 +35,14 @@ interface IPolicy {
updatedAt: Date; updatedAt: Date;
policyType: PolicyType; policyType: PolicyType;
enforcementLevel: EnforcementLevel; enforcementLevel: EnforcementLevel;
}; }
type Props = { type Props = {
policy: IPolicy; policy: IPolicy;
members?: TWorkspaceUser[]; members?: TWorkspaceUser[];
groups?: TGroupMembership[]; groups?: TGroupMembership[];
projectSlug: string; // projectSlug: string;
workspaceId: string; // workspaceId: string;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}; };
@@ -51,175 +51,69 @@ export const ApprovalPolicyRow = ({
policy, policy,
members = [], members = [],
groups = [], groups = [],
projectSlug, // projectSlug,
workspaceId, // workspaceId,
onEdit, onEdit,
onDelete onDelete
}: Props) => { }: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []); // TODO(scott): add back to enable editing from modal? edit modal for policy is fine for now
const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []); // const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy(); // policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy(); // );
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; // const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<Approver[]>(
// policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []
// );
// const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } =
// useUpdateAccessApprovalPolicy();
// const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } =
// useUpdateSecretApprovalPolicy();
// const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
//
// const { permission } = useProjectPermission();
const { permission } = useProjectPermission(); const labels = useMemo(() => {
const usersInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map((approver) => approver.id);
const groupsInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map((approver) => approver.id);
const memberLabels = usersInPolicy?.length
? members
.filter((member) => usersInPolicy?.includes(member.user.id))
.map((member) => getMemberLabel(member))
.join(", ")
: null;
const groupLabels = groupsInPolicy?.length
? groups
.filter(({ group }) => groupsInPolicy?.includes(group.id))
.map(({ group }) => group.name)
.join(", ")
: null;
return {
members: memberLabels,
groups: groupLabels
};
}, [policy, members, groups]);
return ( return (
<Tr> <Tr>
<Td>{policy.name}</Td> <Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td> <Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td> <Td>{policy.secretPath || "*"}</Td>
<Td> <Td className="max-w-0">
<DropdownMenu <Tooltip content={labels.members ?? "No users are assigned as approvers for this policy"}>
onOpenChange={(isOpen) => { <p className="truncate">{labels.members ?? "-"}</p>
if (!isOpen) { </Tooltip>
if (policy.policyType === PolicyType.AccessPolicy) {
updateAccessApprovalPolicy(
{
projectSlug,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
}
}
);
} else {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
}
}
);
}
} else {
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
}
}}
>
<DropdownMenuTrigger
asChild
disabled={
isLoading ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
}
>
<Input
isReadOnly
value={selectedApprovers.length ? `${selectedApprovers.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select members that are allowed to approve changes
</DropdownMenuLabel>
{members?.map(({ user }) => {
const userId = user.id;
const isChecked = selectedApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === userId && el.type === ApproverType.User).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedApprovers((state) =>
isChecked ? state.filter((el) => el.id !== userId || el.type !== ApproverType.User) : [...state, { id: userId, type: ApproverType.User }]
);
}}
key={`create-policy-members-${userId}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{user.username}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td> </Td>
<Td> <Td className="max-w-0">
<DropdownMenu <Tooltip content={labels.groups ?? "No groups are assigned as approvers for this policy"}>
onOpenChange={(isOpen) => { <p className="truncate">{labels.groups ?? "-"}</p>
if (!isOpen) { </Tooltip>
if (policy.policyType === PolicyType.AccessPolicy) {
updateAccessApprovalPolicy(
{
projectSlug,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
}
},
);
} else {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy.id,
approvers: selectedApprovers.concat(selectedGroupApprovers),
},
{
onError: () => {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
}
}
);
}
} else {
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
}
}}
>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={selectedGroupApprovers?.length ? `${selectedGroupApprovers.length} selected` : "None"}
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 = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0;
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedGroupApprovers(
isChecked
? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group)
: [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }]
);
}}
key={`create-policy-groups-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td> </Td>
<Td>{policy.approvals}</Td> <Td>{policy.approvals}</Td>
<Td> <Td>
@@ -229,12 +123,12 @@ export const ApprovalPolicyRow = ({
</Td> </Td>
<Td> <Td>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer"> <DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex justify-center items-center hover:text-primary-400 data-[state=open]:text-primary-400 hover:scale-125 data-[state=open]:scale-125 transition-transform duration-300 ease-in-out"> <div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} /> <FontAwesomeIcon size="sm" icon={faEllipsis} />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="center" className="p-1 min-w-[100%]"> <DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval} a={ProjectPermissionSub.SecretApproval}