diff --git a/frontend/src/hooks/api/approvalRequests/types.ts b/frontend/src/hooks/api/approvalRequests/types.ts index f59214b3e..8885fb30d 100644 --- a/frontend/src/hooks/api/approvalRequests/types.ts +++ b/frontend/src/hooks/api/approvalRequests/types.ts @@ -4,7 +4,8 @@ export enum ApprovalRequestStatus { Pending = "pending", Approved = "approved", Rejected = "rejected", - Expired = "expired" + Expired = "expired", + Cancelled = "cancelled" } export enum ApprovalRequestStepStatus { diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx index 70abf1983..65ba38e78 100644 --- a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx @@ -16,7 +16,11 @@ import { ROUTE_PATHS } from "@app/const/routes"; import { useOrganization, useProject, useUser } from "@app/context"; import { usePopUp } from "@app/hooks"; import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies"; -import { approvalRequestQuery, useCancelApprovalRequest } from "@app/hooks/api/approvalRequests"; +import { + approvalRequestQuery, + ApprovalRequestStatus, + useCancelApprovalRequest +} from "@app/hooks/api/approvalRequests"; import { ProjectType } from "@app/hooks/api/projects/types"; import { ApprovalStepsSection, RequestActionsSection, RequestDetailsSection } from "./components"; @@ -102,16 +106,17 @@ const PageContent = () => { description={`Request to access account ${request.requestData.requestData.accountPath} for ${request.requestData.requestData.accessDuration} by ${request.requesterName || "Unknown"}`} >
- {request.requesterId === currentUser.id && ( - - )} + {request.requesterId === currentUser.id && + request.status === ApprovalRequestStatus.Pending && ( + + )}
diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx index 752021595..c37390037 100644 --- a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx @@ -1,7 +1,7 @@ import { format } from "date-fns"; -import { Badge } from "@app/components/v3"; import { GenericFieldLabel } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ApprovalRequestStatus, TApprovalRequest } from "@app/hooks/api/approvalRequests"; type Props = { diff --git a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/ApprovalRequestTab.tsx b/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/ApprovalRequestTab.tsx index 3a7250f91..42997f9a7 100644 --- a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/ApprovalRequestTab.tsx +++ b/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/ApprovalRequestTab.tsx @@ -1,5 +1,358 @@ -import { RequestsSection } from "./components"; +import { useMemo, useState } from "react"; +import { + faCheckCircle, + faChevronRight, + faExclamationCircle, + faFileCircleQuestion, + faFilter, + faMagnifyingGlass, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { formatDistance } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { Badge } from "@app/components/v3"; +import { useOrganization, useProject, useProjectPermission, useUser } from "@app/context"; +import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences"; +import { usePagination } from "@app/hooks"; +import { ApprovalPolicyType, ApproverType } from "@app/hooks/api/approvalPolicies"; +import { + approvalRequestQuery, + ApprovalRequestStatus, + ApprovalRequestStepStatus, + TApprovalRequest +} from "@app/hooks/api/approvalRequests"; + +const getStatusBadgeColor = (status: ApprovalRequestStatus) => { + switch (status) { + case ApprovalRequestStatus.Pending: + return "project"; + case ApprovalRequestStatus.Approved: + return "success"; + case ApprovalRequestStatus.Rejected: + return "danger"; + case ApprovalRequestStatus.Expired: + return "neutral"; + default: + return "neutral"; + } +}; + +const checkIfUserNeedsToApprove = ( + request: TApprovalRequest, + userId: string, + userGroups: string[] +): boolean => { + const currentStep = request.steps.find( + (step) => step.status === ApprovalRequestStepStatus.InProgress + ); + + if (!currentStep) return false; + + const isApprover = currentStep.approvers.some((approver) => + approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id) + ); + + if (!isApprover) return false; + + const hasAlreadyApproved = currentStep.approvals.some( + (approval) => approval.approverUserId === userId + ); + + return !hasAlreadyApproved; +}; export const ApprovalRequestTab = () => { - return ; + const navigate = useNavigate(); + const { currentOrg } = useOrganization(); + const { currentProject } = useProject(); + const { memberships } = useProjectPermission(); + const { user } = useUser(); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState(ApprovalRequestStatus.Pending); + + const projectId = currentProject?.id || ""; + const userId = user?.id || ""; + const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean); + + const { data: requests = [], isPending: isRequestsLoading } = useQuery( + approvalRequestQuery.list({ + policyType: ApprovalPolicyType.PamAccess, + projectId + }) + ); + + const { page, perPage, setPage, setPerPage, offset } = usePagination("", { + initPerPage: getUserTablePreference("PamApprovalRequestTable", PreferenceKey.PerPage, 10) + }); + + const filteredRequests = useMemo(() => { + let filtered = requests; + + // Apply search filter + if (search) { + filtered = filtered.filter( + (request) => + request.requesterName?.toLowerCase().includes(search.toLowerCase()) || + request.requesterEmail?.toLowerCase().includes(search.toLowerCase()) || + request.justification?.toLowerCase().includes(search.toLowerCase()) || + request.requestData.requestData.accountPath.toLowerCase().includes(search.toLowerCase()) + ); + } + + return filtered + .filter((req) => req.status === filter) + .sort( + (a, b) => (new Date(b.createdAt)?.getTime() || 0) - (new Date(a.createdAt)?.getTime() || 0) + ); + }, [requests, search, filter]); + + const paginatedRequests = useMemo( + () => filteredRequests.slice(offset, offset + perPage), + [filteredRequests, offset, perPage] + ); + + const handleRowClick = (requestId: string) => { + navigate({ + to: "/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId", + params: { + orgId: currentOrg.id, + projectId: currentProject.id, + approvalRequestId: requestId + } + }); + }; + + const isTableFiltered = filter !== ApprovalRequestStatus.Pending; + + return ( +
+
+

Approval Requests

+
+
+
+ + + + + + + + Filter By + { + evt.preventDefault(); + setFilter(ApprovalRequestStatus.Pending); + }} + icon={ + filter === ApprovalRequestStatus.Pending && ( + + ) + } + iconPos="right" + > + Open Requests + + + } + > + Closed Requests + + + + Filter by Status + + { + evt.preventDefault(); + setFilter(ApprovalRequestStatus.Approved); + }} + icon={ + filter === ApprovalRequestStatus.Approved && ( + + ) + } + iconPos="right" + > + Approved + + { + evt.preventDefault(); + setFilter(ApprovalRequestStatus.Cancelled); + }} + icon={ + filter === ApprovalRequestStatus.Cancelled && ( + + ) + } + iconPos="right" + > + Cancelled + + { + evt.preventDefault(); + setFilter(ApprovalRequestStatus.Rejected); + }} + icon={ + filter === ApprovalRequestStatus.Rejected && ( + + ) + } + iconPos="right" + > + Rejected + + { + evt.preventDefault(); + setFilter(ApprovalRequestStatus.Expired); + }} + icon={ + filter === ApprovalRequestStatus.Expired && ( + + ) + } + iconPos="right" + > + Expired + + + + + + setSearch(e.target.value)} + leftIcon={} + placeholder="Search approval requests..." + /> +
+ + + + + + + + + + + + + {isRequestsLoading && } + {!isRequestsLoading && + paginatedRequests.map((request) => { + const needsApproval = checkIfUserNeedsToApprove(request, userId, userGroups); + const { accountPath, accessDuration } = request.requestData.requestData; + + return ( + handleRowClick(request.id)} + > + + + + + + + ); + })} + +
RequesterAccount PathDurationStatusRequested
+
+
+ {request.requesterName || "Unknown"} +
+
{request.requesterEmail}
+
+
+
+
{accountPath}
+
+
+ {accessDuration} + +
+ + {request.status.split("-").join(" ")} + + {needsApproval && ( +
+ + Approval Required +
+ )} +
+
+ + {formatDistance(new Date(request.createdAt), new Date(), { + addSuffix: true + })} + +
+ {Boolean(filteredRequests.length) && ( + + )} + {!isRequestsLoading && !filteredRequests?.length && ( + + )} +
+
+
+ ); }; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsSection.tsx b/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsSection.tsx deleted file mode 100644 index 621f04908..000000000 --- a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsSection.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { RequestsTable } from "./RequestsTable"; - -export const RequestsSection = () => { - return ( -
-
-

Approval Requests

-
- -
- ); -}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsTable.tsx b/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsTable.tsx deleted file mode 100644 index 85eb9f1d9..000000000 --- a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsTable.tsx +++ /dev/null @@ -1,334 +0,0 @@ -import { useMemo, useState } from "react"; -import { - faCheckCircle, - faChevronRight, - faExclamationCircle, - faFileCircleQuestion, - faFilter, - faMagnifyingGlass, - faSearch -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useQuery } from "@tanstack/react-query"; -import { useNavigate } from "@tanstack/react-router"; -import { formatDistance } from "date-fns"; -import { twMerge } from "tailwind-merge"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, - DropdownSubMenu, - DropdownSubMenuContent, - DropdownSubMenuTrigger, - EmptyState, - IconButton, - Input, - Pagination, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { Badge } from "@app/components/v3"; -import { useOrganization, useProject, useProjectPermission, useUser } from "@app/context"; -import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences"; -import { usePagination } from "@app/hooks"; -import { ApprovalPolicyType, ApproverType } from "@app/hooks/api/approvalPolicies"; -import { - approvalRequestQuery, - ApprovalRequestStatus, - ApprovalRequestStepStatus, - TApprovalRequest -} from "@app/hooks/api/approvalRequests"; - -const getStatusBadgeColor = (status: ApprovalRequestStatus) => { - switch (status) { - case ApprovalRequestStatus.Pending: - return "project"; - case ApprovalRequestStatus.Approved: - return "success"; - case ApprovalRequestStatus.Rejected: - return "danger"; - case ApprovalRequestStatus.Expired: - return "neutral"; - default: - return "neutral"; - } -}; - -const checkIfUserNeedsToApprove = ( - request: TApprovalRequest, - userId: string, - userGroups: string[] -): boolean => { - const currentStep = request.steps.find( - (step) => step.status === ApprovalRequestStepStatus.InProgress - ); - - if (!currentStep) return false; - - const isApprover = currentStep.approvers.some((approver) => - approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id) - ); - - if (!isApprover) return false; - - const hasAlreadyApproved = currentStep.approvals.some( - (approval) => approval.approverUserId === userId - ); - - return !hasAlreadyApproved; -}; - -export const RequestsTable = () => { - const navigate = useNavigate(); - const { currentOrg } = useOrganization(); - const { currentProject } = useProject(); - const { memberships } = useProjectPermission(); - const { user } = useUser(); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState(ApprovalRequestStatus.Pending); - - const projectId = currentProject?.id || ""; - const userId = user?.id || ""; - const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean); - - const { data: requests = [], isPending: isRequestsLoading } = useQuery( - approvalRequestQuery.list({ - policyType: ApprovalPolicyType.PamAccess, - projectId - }) - ); - - const { page, perPage, setPage, setPerPage, offset } = usePagination("", { - initPerPage: getUserTablePreference("PamApprovalRequestTable", PreferenceKey.PerPage, 10) - }); - - const filteredRequests = useMemo(() => { - let filtered = requests; - - // Apply search filter - if (search) { - filtered = filtered.filter( - (request) => - request.requesterName?.toLowerCase().includes(search.toLowerCase()) || - request.requesterEmail?.toLowerCase().includes(search.toLowerCase()) || - request.justification?.toLowerCase().includes(search.toLowerCase()) || - request.requestData.requestData.accountPath.toLowerCase().includes(search.toLowerCase()) - ); - } - - return filtered - .filter((req) => req.status === filter) - .sort( - (a, b) => (new Date(b.createdAt)?.getTime() || 0) - (new Date(a.createdAt)?.getTime() || 0) - ); - }, [requests, search, filter]); - - const paginatedRequests = useMemo( - () => filteredRequests.slice(offset, offset + perPage), - [filteredRequests, offset, perPage] - ); - - const handleRowClick = (requestId: string) => { - navigate({ - to: "/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId", - params: { - orgId: currentOrg.id, - projectId: currentProject.id, - approvalRequestId: requestId - } - }); - }; - - const isTableFiltered = filter !== ApprovalRequestStatus.Pending; - - return ( -
-
- - - - - - - - Filter By - { - evt.preventDefault(); - setFilter(ApprovalRequestStatus.Pending); - }} - icon={ - filter === ApprovalRequestStatus.Pending && - } - iconPos="right" - > - Open Requests - - - } - > - Closed Requests - - - - Filter by Status - - { - evt.preventDefault(); - setFilter(ApprovalRequestStatus.Approved); - }} - icon={ - filter === ApprovalRequestStatus.Approved && ( - - ) - } - iconPos="right" - > - Approved - - { - evt.preventDefault(); - setFilter(ApprovalRequestStatus.Rejected); - }} - icon={ - filter === ApprovalRequestStatus.Rejected && ( - - ) - } - iconPos="right" - > - Rejected - - { - evt.preventDefault(); - setFilter(ApprovalRequestStatus.Expired); - }} - icon={ - filter === ApprovalRequestStatus.Expired && ( - - ) - } - iconPos="right" - > - Expired - - - - - - setSearch(e.target.value)} - leftIcon={} - placeholder="Search approval requests..." - /> -
- - - - - - - - - - - - - {isRequestsLoading && } - {!isRequestsLoading && - paginatedRequests.map((request) => { - const needsApproval = checkIfUserNeedsToApprove(request, userId, userGroups); - const { accountPath, accessDuration } = request.requestData.requestData; - - return ( - handleRowClick(request.id)} - > - - - - - - - ); - })} - -
RequesterAccount PathDurationStatusRequested
-
-
- {request.requesterName || "Unknown"} -
-
{request.requesterEmail}
-
-
-
-
{accountPath}
-
-
- {accessDuration} - -
- - {request.status.split("-").join(" ")} - - {needsApproval && ( -
- - Approval Required -
- )} -
-
- - {formatDistance(new Date(request.createdAt), new Date(), { - addSuffix: true - })} - -
- {Boolean(filteredRequests.length) && ( - - )} - {!isRequestsLoading && !filteredRequests?.length && ( - - )} -
-
- ); -}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/index.tsx b/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/index.tsx deleted file mode 100644 index 35945d6a2..000000000 --- a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { RequestsSection } from "./RequestsSection"; -export { RequestsTable } from "./RequestsTable"; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx index cdafe6805..397631508 100644 --- a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/PolicyTab.tsx @@ -1,5 +1,65 @@ -import { PolicySection } from "./components"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { ApprovalPolicyType, useDeleteApprovalPolicy } from "@app/hooks/api/approvalPolicies"; + +import { PoliciesTable } from "./components/PoliciesTable"; +import { PolicyModal } from "./components/PolicyModal"; export const PolicyTab = () => { - return ; + const { currentProject } = useProject(); + + const { mutateAsync: deleteApprovalPolicy } = useDeleteApprovalPolicy(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "policy", + "deletePolicy" + ] as const); + + const handleDeletePolicy = async () => { + const policyId = (popUp?.deletePolicy?.data as { policyId: string })?.policyId; + if (!currentProject?.id) return; + if (!policyId) return; + + await deleteApprovalPolicy({ + policyType: ApprovalPolicyType.PamAccess, + policyId + }); + createNotification({ + text: "Successfully deleted policy", + type: "success" + }); + handlePopUpClose("deletePolicy"); + }; + + return ( +
+
+
+

Approval Policies

+
+ +
+ + + handlePopUpToggle("deletePolicy", isOpen)} + onDeleteApproved={handleDeletePolicy} + /> +
+ ); }; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx index 4aa0d1abe..afff6ae5b 100644 --- a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PoliciesTable.tsx @@ -9,6 +9,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useQuery } from "@tanstack/react-query"; +import { User, Users } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { @@ -28,6 +29,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useProject } from "@app/context"; import { getMemberLabel } from "@app/helpers/members"; import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api"; @@ -37,8 +39,6 @@ import { ApproverType } from "@app/hooks/api/approvalPolicies"; import { UsePopUpState } from "@app/hooks/usePopUp"; -import { Badge } from "@app/components/v3"; -import { User, Users } from "lucide-react"; type Props = { handlePopUpOpen: ( diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySection.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySection.tsx deleted file mode 100644 index 94251134f..000000000 --- a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/PolicySection.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { Button, DeleteActionModal } from "@app/components/v2"; -import { useProject } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { ApprovalPolicyType, useDeleteApprovalPolicy } from "@app/hooks/api/approvalPolicies"; - -import { PoliciesTable } from "./PoliciesTable"; -import { PolicyModal } from "./PolicyModal"; - -export const PolicySection = () => { - const { currentProject } = useProject(); - - const { mutateAsync: deleteApprovalPolicy } = useDeleteApprovalPolicy(); - - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "policy", - "deletePolicy" - ] as const); - - const handleDeletePolicy = async () => { - const policyId = (popUp?.deletePolicy?.data as { policyId: string })?.policyId; - if (!currentProject?.id) return; - if (!policyId) return; - - await deleteApprovalPolicy({ - policyType: ApprovalPolicyType.PamAccess, - policyId - }); - createNotification({ - text: "Successfully deleted policy", - type: "success" - }); - handlePopUpClose("deletePolicy"); - }; - - return ( -
-
-
-

Approval Policies

-
- -
- - - handlePopUpToggle("deletePolicy", isOpen)} - onDeleteApproved={handleDeletePolicy} - /> -
- ); -}; diff --git a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx index 0bbf4d7b4..af44d67b8 100644 --- a/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx +++ b/frontend/src/pages/pam/ApprovalsPage/components/PolicyTab/components/index.tsx @@ -1,3 +1,2 @@ export { PoliciesTable } from "./PoliciesTable"; export { PolicyModal } from "./PolicyModal"; -export { PolicySection } from "./PolicySection";