feat: ui changes for approval to work

This commit is contained in:
=
2025-06-11 15:20:46 +05:30
parent d32b6ad41d
commit f9c00cf442
18 changed files with 1001 additions and 371 deletions
@@ -188,9 +188,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th className="w-[18%]">Eligible Approvers</Th>
<Th className="w-[18%]">Eligible Group Approvers</Th>
<Th>Approval Required</Th>
<Th>
<DropdownMenu>
<DropdownMenuTrigger>
@@ -1,6 +1,9 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faGripVertical, faTrash } 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";
@@ -8,12 +11,15 @@ import {
Button,
FilterableSelect,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch
Switch,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
@@ -28,6 +34,7 @@ import {
useUpdateAccessApprovalPolicy
} from "@app/hooks/api/accessApproval";
import {
Approver,
ApproverType,
BypasserType,
TAccessApprovalPolicy
@@ -68,10 +75,28 @@ const formSchema = z
.default([]),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true)
allowedSelfApprovals: z.boolean().default(true),
sequenceApprovers: z
.object({
user: z
.object({ type: z.literal(ApproverType.User), id: z.string() })
.array()
.default([]),
group: z
.object({ type: z.literal(ApproverType.Group), id: z.string() })
.array()
.default([]),
approvals: z.number().min(1).default(1)
})
.array()
.default([])
.optional()
})
.superRefine((data, ctx) => {
if (!(data.groupApprovers.length || data.userApprovers.length)) {
if (
data.policyType === PolicyType.ChangePolicy &&
!(data.groupApprovers.length || data.userApprovers.length)
) {
ctx.addIssue({
path: ["userApprovers"],
code: z.ZodIssueCode.custom,
@@ -95,6 +120,9 @@ export const AccessPolicyForm = ({
projectSlug,
editValues
}: Props) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const modalContainer = useRef<HTMLDivElement>(null);
const {
control,
handleSubmit,
@@ -103,6 +131,7 @@ export const AccessPolicyForm = ({
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
// @ts-expect-error due to collision of approver type
values: editValues
? {
...editValues,
@@ -124,15 +153,47 @@ export const AccessPolicyForm = ({
?.filter((bypasser) => bypasser.type === BypasserType.Group)
.map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [],
approvals: editValues?.approvals,
allowedSelfApprovals: editValues?.allowedSelfApprovals
allowedSelfApprovals: editValues?.allowedSelfApprovals,
sequenceApprovers: editValues.approvers
?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0))
.reduce(
(acc, curr) => {
if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
const approvals = curr.approvals || editValues.approvals;
acc.push(
curr.type === ApproverType.User
? {
user: [curr],
group: [],
sequence: 1,
approvals
}
: { group: [curr], user: [], sequence: 1, approvals }
);
return acc;
},
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
)
}
: undefined
: undefined,
defaultValues: {
sequenceApprovers: [{ approvals: 1 }]
}
});
const sequenceApproversFieldArray = useFieldArray({
control,
name: "sequenceApprovers"
});
const { currentWorkspace } = useWorkspace();
const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
@@ -157,6 +218,7 @@ export const AccessPolicyForm = ({
userApprovers,
groupBypassers,
userBypassers,
sequenceApprovers,
...data
}: TFormSchema) => {
if (!projectId) return;
@@ -175,7 +237,15 @@ export const AccessPolicyForm = ({
} else {
await createAccessApprovalPolicy({
...data,
approvers: [...userApprovers, ...groupApprovers],
approvers: sequenceApprovers?.flatMap((approvers, index) =>
approvers.user
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
),
approvalsRequired: sequenceApprovers?.map((el, index) => ({
stepNumber: index + 1,
numberOfApprovals: el.approvals
})),
bypassers: bypassers.length > 0 ? bypassers : undefined,
environment: environment.slug,
projectSlug
@@ -201,6 +271,7 @@ export const AccessPolicyForm = ({
groupApprovers,
userBypassers,
groupBypassers,
sequenceApprovers,
...data
}: TFormSchema) => {
if (!projectId || !projectSlug) return;
@@ -221,7 +292,15 @@ export const AccessPolicyForm = ({
await updateAccessApprovalPolicy({
id: editValues?.id,
...data,
approvers: [...userApprovers, ...groupApprovers],
approvers: sequenceApprovers?.flatMap((approvers, index) =>
approvers.user
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
),
approvalsRequired: sequenceApprovers?.map((el, index) => ({
stepNumber: index + 1,
numberOfApprovals: el.approvals
})),
bypassers: bypassers.length > 0 ? bypassers : undefined,
environment: environment.slug,
projectSlug
@@ -285,16 +364,46 @@ export const AccessPolicyForm = ({
[groups]
);
const handleDragStart = (_: React.DragEvent, index: number) => {
setDraggedItem(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverItem(index);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (draggedItem === null || dragOverItem === null || draggedItem === dragOverItem) {
setDraggedItem(null);
setDragOverItem(null);
return;
}
sequenceApproversFieldArray.move(draggedItem, dragOverItem);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
className="max-w-2xl"
className="max-w-3xl"
bodyClassName="overflow-visible"
ref={modalContainer}
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
>
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="grid grid-cols-2 gap-x-3">
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="policyType"
@@ -306,6 +415,7 @@ export const AccessPolicyForm = ({
isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
className="flex-grow"
>
<Select
isDisabled={isEditMode}
@@ -324,25 +434,30 @@ export const AccessPolicyForm = ({
</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>
)}
/>
{!isAccessPolicyType && (
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Min. Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
)}
</div>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="name"
@@ -351,6 +466,7 @@ export const AccessPolicyForm = ({
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
@@ -366,6 +482,7 @@ export const AccessPolicyForm = ({
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
@@ -400,62 +517,199 @@ export const AccessPolicyForm = ({
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
key={el.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div className="mb-3 flex items-center justify-between">
<Tag>Step {index + 1}</Tag>
<div className="flex items-center gap-3">
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
<Controller
control={control}
name={`sequenceApprovers.${index}.approvals` as const}
defaultValue={1}
render={({ field }) => (
<Input
{...field}
type="number"
size="xs"
min={1}
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
/>
)}
/>
</div>
<Tooltip content="Remove step">
<IconButton
ariaLabel="delete"
variant="plain"
onClick={() => sequenceApproversFieldArray.remove(index)}
className="text-red-500 hover:text-gray-200"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
<Tooltip content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Controller
control={control}
name={`sequenceApprovers.${index}.user` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name={`sequenceApprovers.${index}.group` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
<div className="my-2">
<Button
size="xs"
variant="outline_bg"
onClick={() =>
sequenceApproversFieldArray.append({
approvals: 1,
user: [],
group: []
})
}
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
Add Step
</Button>
</div>
</>
) : (
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
)}
<Controller
control={control}
name="allowedSelfApprovals"
@@ -10,7 +10,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -18,6 +17,7 @@ import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies";
import { useToggle } from "@app/hooks";
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";
@@ -53,113 +53,154 @@ export const ApprovalPolicyRow = ({
onEdit,
onDelete
}: Props) => {
const [isExpanded, setIsExpanded] = useToggle();
const labels = useMemo(() => {
const usersInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map((approver) => approver.id);
const sortedSteps = policy.approvers?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0));
const entityInSameSequence = sortedSteps?.reduce(
(acc, curr) => {
if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
const approvals = curr.approvals || policy.approvals;
acc.push(
curr.type === ApproverType.User
? { user: [curr], group: [], sequence: 1, approvals }
: { group: [curr], user: [], sequence: 1, approvals }
);
return acc;
},
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
);
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))
return entityInSameSequence?.map((el) => {
return {
sequence: el.sequence || policy.approvals,
userLabels: members
?.filter((member) => el.user.find((i) => i.id === member.user.id))
.map((member) => getMemberLabel(member))
.join(", ")
: null;
const groupLabels = groupsInPolicy?.length
? groups
.filter(({ group }) => groupsInPolicy?.includes(group.id))
.join(","),
groupLabels: groups
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
.map(({ group }) => group.name)
.join(", ")
: null;
return {
members: memberLabels,
groups: groupLabels
};
.join(","),
approvals: el.approvals
};
});
}, [policy, members, groups]);
return (
<Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td className="max-w-0">
<Tooltip
side="left"
content={labels.members ?? "No users are assigned as approvers for this policy"}
>
<p className="truncate">{labels.members ?? "-"}</p>
</Tooltip>
</Td>
<Td className="max-w-0">
<Tooltip
side="left"
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>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
</Badge>
</Td>
<Td>
<DropdownMenu>
<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="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
<>
<Tr
isHoverable
isSelectable
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") setIsExpanded.toggle();
}}
onClick={() => setIsExpanded.toggle()}
>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
</Badge>
</Td>
<Td>
<DropdownMenu>
<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="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
disabled={!isAllowed}
>
Edit Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={!isAllowed}
>
Delete Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
{isExpanded && (
<Tr>
<Td colSpan={5} className="rounded bg-mineshaft-900">
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
>
<div>
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white">
<div className="text-lg">{index + 1}</div>
</div>
{index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" />
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
disabled={!isAllowed}
>
Edit Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
{index !== 0 && (
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" />
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={!isAllowed}
>
Delete Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>{el.userLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>{el.groupLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{el.approvals || "-"}</div>
</div>
</div>
</div>
))}
</Td>
</Tr>
)}
</>
);
};