diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx index 2d12b8c68..70abf1983 100644 --- a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx @@ -2,13 +2,21 @@ import { Helmet } from "react-helmet"; import { faBan, faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQuery } from "@tanstack/react-query"; -import { Link, useParams } from "@tanstack/react-router"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; -import { ContentLoader, EmptyState, PageHeader } from "@app/components/v2"; +import { createNotification } from "@app/components/notifications"; +import { + Button, + ConfirmActionModal, + ContentLoader, + EmptyState, + PageHeader +} from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; -import { useOrganization, useProject } from "@app/context"; +import { useOrganization, useProject, useUser } from "@app/context"; +import { usePopUp } from "@app/hooks"; import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies"; -import { approvalRequestQuery } from "@app/hooks/api/approvalRequests"; +import { approvalRequestQuery, useCancelApprovalRequest } from "@app/hooks/api/approvalRequests"; import { ProjectType } from "@app/hooks/api/projects/types"; import { ApprovalStepsSection, RequestActionsSection, RequestDetailsSection } from "./components"; @@ -19,6 +27,10 @@ const PageContent = () => { }); const { currentOrg } = useOrganization(); const { currentProject } = useProject(); + const { user: currentUser } = useUser(); + const cancelApprovalRequest = useCancelApprovalRequest(); + const navigate = useNavigate(); + const { handlePopUpOpen, handlePopUpToggle, popUp } = usePopUp(["cancelRequest"]); const { data: request, isPending } = useQuery( approvalRequestQuery.getById({ @@ -27,6 +39,32 @@ const PageContent = () => { }) ); + const handleRequestCancel = async () => { + if (cancelApprovalRequest.isPending || !request) return; + + await cancelApprovalRequest.mutateAsync( + { + requestId: request.id, + policyType: ApprovalPolicyType.PamAccess + }, + { + onSuccess: () => { + createNotification({ + text: "Successfully cancelled request", + type: "success" + }); + navigate({ + to: "/organizations/$orgId/projects/pam/$projectId/approvals", + params: { + orgId: currentProject.orgId, + projectId: currentProject.id + } + }); + } + } + ); + }; + if (isPending) { return (
@@ -62,7 +100,20 @@ const PageContent = () => { scope={ProjectType.PAM} title="Approval Request" description={`Request to access account ${request.requestData.requestData.accountPath} for ${request.requestData.requestData.accessDuration} by ${request.requesterName || "Unknown"}`} - /> + > +
+ {request.requesterId === currentUser.id && ( + + )} +
+
@@ -73,6 +124,14 @@ const PageContent = () => {
+ handlePopUpToggle("cancelRequest", isOpen)} + onConfirmed={handleRequestCancel} + buttonText="Confirm" + /> ); }; diff --git a/frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx b/frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx index b14ca5328..d9bdbfda5 100644 --- a/frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx +++ b/frontend/src/pages/pam/ApprovalsPage/ApprovalsPage.tsx @@ -7,6 +7,7 @@ import { ApprovalControlTabs } from "@app/types/project"; import { ApprovalRequestTab } from "./components/ApprovalRequestTab"; import { PolicyTab } from "./components/PolicyTab"; +import { RequestGrantTab } from "./components/RequestGrantTab"; const Page = () => { const navigate = useNavigate(); @@ -55,7 +56,7 @@ const Page = () => { -
Hello
+
diff --git a/frontend/src/pages/pam/ApprovalsPage/components/RequestGrantTab/RequestGrantTab.tsx b/frontend/src/pages/pam/ApprovalsPage/components/RequestGrantTab/RequestGrantTab.tsx index 564b1cf4f..9f611aa00 100644 --- a/frontend/src/pages/pam/ApprovalsPage/components/RequestGrantTab/RequestGrantTab.tsx +++ b/frontend/src/pages/pam/ApprovalsPage/components/RequestGrantTab/RequestGrantTab.tsx @@ -1,3 +1,433 @@ -export const RequestGrantTab = () => { - return
Grant list
; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { + faCheckCircle, + faChevronRight, + faEllipsisV, + faFileCircleQuestion, + faFilter, + faInfo, + faMagnifyingGlass, + faSearch, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + TextArea, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { Badge } from "@app/components/v3"; +import { useProject } from "@app/context"; +import { getMemberLabel } from "@app/helpers/members"; +import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences"; +import { usePagination } from "@app/hooks"; +import { useGetWorkspaceUsers } from "@app/hooks/api"; +import { + approvalGrantQuery, + ApprovalGrantStatus, + useRevokeApprovalGrant +} from "@app/hooks/api/approvalGrants"; +import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies"; + +const revokeGrantSchema = z.object({ + revocationReason: z.string().max(256).optional() +}); + +type TRevokeGrantForm = z.infer; + +const getStatusBadgeColor = (status: ApprovalGrantStatus) => { + switch (status) { + case ApprovalGrantStatus.Active: + return "success"; + case ApprovalGrantStatus.Expired: + return "neutral"; + case ApprovalGrantStatus.Revoked: + return "danger"; + default: + return "neutral"; + } +}; + +export const RequestGrantTab = () => { + const { currentProject } = useProject(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState(ApprovalGrantStatus.Active); + const [revokeModalOpen, setRevokeModalOpen] = useState(false); + const [grantToRevoke, setGrantToRevoke] = useState(null); + const { data: members = [] } = useGetWorkspaceUsers(currentProject.id); + + const projectId = currentProject?.id || ""; + + const { data: grants = [], isPending: isGrantsLoading } = useQuery( + approvalGrantQuery.list({ + policyType: ApprovalPolicyType.PamAccess, + projectId + }) + ); + + const { mutateAsync: revokeGrant, isPending: isRevoking } = useRevokeApprovalGrant(); + + const { + register, + handleSubmit, + reset, + formState: { errors } + } = useForm({ + resolver: zodResolver(revokeGrantSchema) + }); + + const { page, perPage, setPage, setPerPage, offset } = usePagination("", { + initPerPage: getUserTablePreference("pamApprovalGrants", PreferenceKey.PerPage, 20) + }); + + const filteredGrants = useMemo(() => { + let filtered = grants; + + // Apply search filter + if (search) { + filtered = filtered.filter( + (grant) => + grant.attributes.accountPath.toLowerCase().includes(search.toLowerCase()) || + grant.id.toLowerCase().includes(search.toLowerCase()) + ); + } + + return filtered.filter((grant) => grant.status === filter); + }, [grants, search, filter]); + + const paginatedGrants = useMemo( + () => filteredGrants.slice(offset, offset + perPage), + [filteredGrants, offset, perPage] + ); + + const openRevokeModal = (grantId: string) => { + setGrantToRevoke(grantId); + setRevokeModalOpen(true); + }; + + const closeRevokeModal = () => { + setRevokeModalOpen(false); + setGrantToRevoke(null); + reset(); + }; + + const getGrantedUser = (grantedUserId: string) => { + const member = members?.find((m) => m.user.id === grantedUserId); + if (member) { + return getMemberLabel(member); + } + return grantedUserId; + }; + + const handleRevokeGrant = async (data: TRevokeGrantForm) => { + if (!grantToRevoke) return; + + try { + await revokeGrant({ + policyType: ApprovalPolicyType.PamAccess, + grantId: grantToRevoke, + revocationReason: data.revocationReason + }); + + createNotification({ + text: "Grant revoked successfully", + type: "success" + }); + + closeRevokeModal(); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to revoke grant", + type: "error" + }); + } + }; + + const isTableFiltered = filter !== ApprovalGrantStatus.Active; + + return ( +
+
+

Access Grants

+
+
+
+ + + + + + + + Filter By + { + evt.preventDefault(); + setFilter(ApprovalGrantStatus.Active); + }} + icon={ + filter === ApprovalGrantStatus.Active && + } + iconPos="right" + > + Active Grants + + + } + > + Inactive Grants + + + + Filter by Status + + { + evt.preventDefault(); + setFilter(ApprovalGrantStatus.Expired); + }} + icon={ + filter === ApprovalGrantStatus.Expired && ( + + ) + } + iconPos="right" + > + Expired + + { + evt.preventDefault(); + setFilter(ApprovalGrantStatus.Revoked); + }} + icon={ + filter === ApprovalGrantStatus.Revoked && ( + + ) + } + iconPos="right" + > + Revoked + + + + + + setSearch(e.target.value)} + leftIcon={} + placeholder="Search access grants..." + /> +
+ + + + + + + + + + + + + + {isGrantsLoading && } + {!isGrantsLoading && + paginatedGrants.map((grant) => { + const isActive = grant.status === ApprovalGrantStatus.Active; + + return ( + + + + + + + + + + ); + })} + +
UserAccount PathDurationStatusGrantedExpires +
+ {grant.granteeUserId ? getGrantedUser(grant.granteeUserId) : "Unknown"} + +
{grant.attributes.accountPath}
+
+ + {grant.attributes.accessDuration} + + + + {isActive ? "Active" : grant.status} + + + + {formatDistance(new Date(grant.createdAt), new Date(), { + addSuffix: true + })} + + + {grant.expiresAt ? ( + + {formatDistance(new Date(grant.expiresAt), new Date(), { + addSuffix: true + })} + + ) : ( + Never + )} + { + e.stopPropagation(); + }} + > + {isActive && ( + + +
+ + + + + +
+
+ + + }> + Request Details + + + { + e.stopPropagation(); + openRevokeModal(grant.id); + }} + icon={} + > + Revoke Grant + + +
+ )} +
+ {Boolean(filteredGrants.length) && ( + + )} + {!isGrantsLoading && !filteredGrants?.length && ( + + )} +
+ + !open && closeRevokeModal()}> + +
+ +