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
@@ -87,6 +87,7 @@ export const AccessApprovalRequest = ({
isRequestedByCurrentUser: boolean;
isSelfApproveAllowed: boolean;
isApprover: boolean;
isDisabled?: boolean;
})
| null
>(null);
@@ -147,16 +148,17 @@ export const AccessApprovalRequest = ({
const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => {
const isReviewedByUser =
request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
request.reviewers.findIndex(({ userId }) => userId === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED
);
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
const isApprover =
request.policy.approvers.findIndex((el) => el.userId === user.id || "") !== -1;
const isAccepted = request.isApproved;
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
const isSelfApproveAllowed = request.policy.allowedSelfApprovals;
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
const userReviewStatus = request.reviewers.find(({ userId }) => userId === user.id)?.status;
const canBypass =
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
@@ -205,21 +207,6 @@ export const AccessApprovalRequest = ({
const handleSelectRequest = useCallback(
(request: TAccessApprovalRequest) => {
const details = generateRequestDetails(request);
// Whether the request has already been approved / rejected / reviewed
const isInactive =
details.isAccepted || details.isReviewedByUser || details.isRejectedByAnyone;
// Whether the current user can bypass policy
const canBypass =
details.isSoftEnforcement && details.isRequestedByCurrentUser && details.canBypass;
// Whether the current user can approve
const canApprove =
details.isApprover && (!details.isRequestedByCurrentUser || details.isSelfApproveAllowed);
if (isInactive || (!canApprove && !canBypass)) return;
if (membersGroupById?.[request.requestedByUserId].user || details.isRequestedByCurrentUser) {
setSelectedRequest({
...request,
@@ -381,9 +368,6 @@ export const AccessApprovalRequest = ({
return (
<div
aria-disabled={
details.isReviewedByUser || details.isRejectedByAnyone || details.isAccepted
}
key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button"
@@ -450,9 +434,11 @@ export const AccessApprovalRequest = ({
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");
@@ -1,16 +1,48 @@
import { useCallback, useMemo, useState } from "react";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import {
faCheckCircle,
faCircle,
faTriangleExclamation,
faUsers,
faXmarkCircle
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import {
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions } from "@app/context";
import { useReviewAccessRequest } from "@app/hooks/api";
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
import { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
import {
Approver,
ApproverType,
TAccessApprovalPolicy,
TAccessApprovalRequest
} from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { groupBy } from "@app/lib/fn/array";
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
};
export const ReviewAccessRequestModal = ({
isOpen,
@@ -19,7 +51,9 @@ export const ReviewAccessRequestModal = ({
projectSlug,
selectedRequester,
selectedEnvSlug,
canBypass
canBypass,
policies = [],
members = []
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
@@ -33,10 +67,15 @@ export const ReviewAccessRequestModal = ({
selectedRequester: string | undefined;
selectedEnvSlug: string | undefined;
canBypass: boolean;
policies: TAccessApprovalPolicy[];
members: TWorkspaceUser[];
}) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [bypassApproval, setBypassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState("");
const { currentWorkspace } = useWorkspace();
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
const { user } = useUser();
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
@@ -134,6 +173,54 @@ export const ReviewAccessRequestModal = ({
]
);
const approverSequence = useMemo(() => {
const policy = policies.find((el) => el.id === request.policy.id);
const reviewesGroupById = groupBy(request.reviewers, (i) => i.userId);
const membersGroupById = groupBy(members, (i) => i.user.id);
const projectGroupsGroupById = groupBy(groupMemberships, (i) => i.group.id);
const approversBySequence = policy?.approvers?.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;
const sequence = curr.sequence || 1;
acc.push(
curr.type === ApproverType.User
? { user: [curr], group: [], sequence, approvals }
: { group: [curr], user: [], sequence, approvals }
);
return acc;
},
[] as {
user: Approver[];
group: Approver[];
sequence?: number;
approvals?: number;
}[]
);
const approvers = approversBySequence?.map((approverChain) => {
const reviewers = request.policy.approvers
.filter((el) => (el.sequence || 1) === approverChain.sequence)
.map((el) => ({ ...el, status: reviewesGroupById?.[el.userId]?.[0]?.status }));
const hasApproved =
reviewers.filter((el) => el.status === "approved").length >=
(approverChain?.approvals || 1);
const hasRejected = reviewers.filter((el) => el.status === ApprovalStatus.REJECTED).length;
return { ...approverChain, reviewers, hasApproved, hasRejected };
});
return { approvers, membersGroupById, projectGroupsGroupById };
}, [request, policies]);
const hasRejected = request.reviewers.find((el) => el.status === ApprovalStatus.REJECTED);
const isReviewedByMe = request.reviewers.find((i) => i.userId === user.id);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
@@ -141,106 +228,228 @@ export const ReviewAccessRequestModal = ({
title="Review Request"
subTitle="Review the request and approve or deny access."
>
<div className="text-sm">
<span>
{request.user &&
(request.user.firstName || request.user.lastName) &&
request.user.email ? (
<span className="font-bold">
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
</span>
) : (
<span>A user</span>
)}{" "}
is requesting access to the following resource:
</span>
<div className="mb-2 mt-4 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
<div className="mb-1 lowercase">
<span className="font-bold capitalize">Requested path: </span>
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
</div>
<div className="mb-1">
<span className="font-bold">Permissions: </span>
<Badge>{requestedAccess}</Badge>
</div>
<div>
<span className="font-bold">Access Type: </span>
<span>{getAccessLabel()}</span>
</div>
{request.note && (
<div className="mt-1">
<span className="font-bold">User Note: </span>
<span>{request.note}</span>
<div className="mb-4 rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5 text-sm">
{request.user &&
(request.user.firstName || request.user.lastName) &&
request.user.email ? (
<span className="inline font-bold">
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
</span>
) : (
<span>A user</span>
)}{" "}
is requesting access to the following resource:
</div>
<div className="">
<div className="mb-2 mt-4 text-mineshaft-200">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Environment</div>
<div>{accessDetails.env || "-"}</div>
</div>
)}
<div>
<div className="mb-1 text-xs font-semibold uppercase">Secret Path</div>
<div>{accessDetails.secretPath || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Access Type</div>
<div>{getAccessLabel()}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Permission</div>
<div>{requestedAccess}</div>
</div>
<div className="col-span-2">
<div className="mb-1 text-xs font-semibold uppercase">Note</div>
<div>{request.note || "-"}</div>
</div>
</div>
</div>
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
<Button
isLoading={isLoading === "rejected"}
isDisabled={!!isLoading}
onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm"
>
Reject Request
</Button>
</div>
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
canBypass && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
bypassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy protection)
</span>
</Checkbox>
{bypassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
<div className="thin-scrollbar max-h-64 overflow-y-auto rounded p-2">
{approverSequence?.approvers?.map((approver, 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={twMerge(
"mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white",
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
<div className="text-lg">{index + 1}</div>
</div>
{index !== (approverSequence?.approvers?.length || 0) - 1 && (
<div
className={twMerge(
"absolute bottom-0 left-8 h-6 border-r border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
</FormControl>
)}
)}
{index !== 0 && (
<div
className={twMerge(
"absolute left-8 top-0 h-4 border-r border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)}
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(",") || "-"}
</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>
{approver?.group
?.map(
(el) =>
approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name
)
.join(",") || "-"}
</div>
</div>
<div className="flex items-center">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{approver.approvals || "-"}</div>
</div>
<div className="ml-16">
<Popover>
<PopoverTrigger>
<FontAwesomeIcon
icon={faUsers}
className={twMerge(
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
/>
</PopoverTrigger>
<PopoverContent hideCloseBtn className="pt-3">
<div>
<div className="mb-1 text-sm text-bunker-300">Reviewers</div>
<div className="thin-scrollbar flex max-h-64 flex-col gap-1 overflow-y-auto rounded">
{approver.reviewers.map((el) => (
<div className="flex items-center gap-2 bg-mineshaft-700 p-1 text-sm">
<div className="flex-grow">{el.username}</div>
<Tooltip
content={`Status: ${el?.status || ApprovalStatus.PENDING}`}
>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
</Tooltip>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
)}
))}
</div>
{hasRejected || isReviewedByMe ? (
<div
className={twMerge(
"mb-4 rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm",
isReviewedByMe && "border-l-green-400"
)}
>
{isReviewedByMe
? "You have reviewed this request."
: "This request has been rejected."}
</div>
) : (
<>
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
Boolean(isLoading) ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
<Button
isLoading={isLoading === "rejected"}
isDisabled={
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm"
>
Reject Request
</Button>
</div>
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
canBypass && (
<div className="mt-2 flex flex-col space-y-2">
<Checkbox
onCheckedChange={(checked) => setBypassApproval(checked === true)}
isChecked={bypassApproval}
id="byPassApproval"
checkIndicatorBg="text-white"
className={twMerge(
"mr-2",
bypassApproval ? "border-red bg-red hover:bg-red-600" : ""
)}
>
<span className="text-xs text-red">
Approve without waiting for requirements to be met (bypass policy
protection)
</span>
</Checkbox>
{bypassApproval && (
<FormControl
label="Reason for bypass"
className="mt-2"
isRequired
tooltipText="Enter a reason for bypassing the secret change policy"
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
/>
</FormControl>
)}
</div>
)}
</>
)}
</div>
</ModalContent>
</Modal>