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

View File

@ -34,17 +34,22 @@ export const FilterableSelect = <T,>({
tabSelectsValue={tabSelectsValue}
components={{ DropdownIndicator, ClearIndicator, MultiValueRemove, Option }}
classNames={{
container: () => "w-full text-sm font-inter",
control: ({ isFocused }) =>
container: ({ isDisabled }) =>
twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
control: ({ isFocused, isDisabled }) =>
twMerge(
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"
isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
`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: () =>
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1 py-0.5",
input: () => "pl-1",
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",
multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm",

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

View File

@ -188,8 +188,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Eligible Approvers</Th>
<Th>Eligible Group Approvers</Th>
<Th className="w-[18%]">Eligible Approvers</Th>
<Th className="w-[18%]">Eligible Group Approvers</Th>
<Th>Approval Required</Th>
<Th>
<DropdownMenu>
@ -256,9 +256,9 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
{!!currentWorkspace &&
filteredPolicies?.map((policy) => (
<ApprovalPolicyRow
projectSlug={currentWorkspace.slug}
// projectSlug={currentWorkspace.slug}
policy={policy}
workspaceId={workspaceId}
// workspaceId={workspaceId}
key={policy.id}
members={members}
groups={groups}

View File

@ -15,6 +15,7 @@ import {
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies";
import {
useCreateSecretApprovalPolicy,
@ -236,143 +237,157 @@ export const AccessPolicyForm = ({
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
className="max-w-2xl"
bodyClassName="overflow-visible"
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
>
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<Controller
control={control}
name="policyType"
defaultValue={PolicyType.ChangePolicy}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
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"
<div className="grid grid-cols-2 gap-x-3">
<Controller
control={control}
name="policyType"
defaultValue={PolicyType.ChangePolicy}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Policy Type"
isRequired
isError={Boolean(error)}
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}
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</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="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"
<Select
isDisabled={isEditMode}
value={value}
onValueChange={(val) => onChange(val as PolicyType)}
className="w-full border border-mineshaft-500"
>
{Object.values(PolicyType).map((policyType) => {
return (
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
{policyDetails[policyType].name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Minimum Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
{Object.values(EnforcementLevel).map((level) => {
return (
<SelectItem value={level} key={`enforcement-level-${level}`}>
<span className="capitalize">{level}</span>
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</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">
<p>Approvers</p>
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
@ -399,14 +414,7 @@ export const AccessPolicyForm = ({
if (!member) return option.id;
const {
inviteEmail,
user: { firstName, lastName, username, email }
} = member;
return firstName || lastName
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
: username || email || inviteEmail;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@ -8,19 +8,19 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
Input,
Td,
Tooltip,
Tr
} from "@app/components/v2";
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 { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import { Approver, ApproverType } from "@app/hooks/api/accessApproval/types";
import { Approver } from "@app/hooks/api/accessApproval/types";
import { TGroupMembership } from "@app/hooks/api/groups/types";
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 { TWorkspaceUser } from "@app/hooks/api/users/types";
@ -35,14 +35,14 @@ interface IPolicy {
updatedAt: Date;
policyType: PolicyType;
enforcementLevel: EnforcementLevel;
};
}
type Props = {
policy: IPolicy;
members?: TWorkspaceUser[];
groups?: TGroupMembership[];
projectSlug: string;
workspaceId: string;
// projectSlug: string;
// workspaceId: string;
onEdit: () => void;
onDelete: () => void;
};
@ -51,175 +51,69 @@ export const ApprovalPolicyRow = ({
policy,
members = [],
groups = [],
projectSlug,
workspaceId,
// projectSlug,
// workspaceId,
onEdit,
onDelete
}: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
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;
// TODO(scott): add back to enable editing from modal? edit modal for policy is fine for now
// const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(
// policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []
// );
// 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 (
<Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<DropdownMenu
onOpenChange={(isOpen) => {
if (!isOpen) {
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 className="max-w-0">
<Tooltip content={labels.members ?? "No users are assigned as approvers for this policy"}>
<p className="truncate">{labels.members ?? "-"}</p>
</Tooltip>
</Td>
<Td>
<DropdownMenu
onOpenChange={(isOpen) => {
if (!isOpen) {
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 className="max-w-0">
<Tooltip content={labels.groups ?? "No groups are assigned as approvers for this policy"}>
<p className="truncate">{labels.groups ?? "-"}</p>
</Tooltip>
</Td>
<Td>{policy.approvals}</Td>
<Td>
@ -229,12 +123,12 @@ export const ApprovalPolicyRow = ({
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer">
<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">
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<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} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="p-1 min-w-[100%]">
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}