From f4cf0c35450e2ca3e92c0ac98221b18957a8f207 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 7 Dec 2025 14:39:11 +0530 Subject: [PATCH] feat: improved ui for request detail table --- frontend/src/const/routes.ts | 4 + .../src/hooks/api/approvalRequests/types.ts | 11 +- frontend/src/hooks/api/users/types.ts | 1 + .../ApprovalRequestDetailPage.tsx | 91 ++++++++- .../components/ApprovalStepsSection.tsx | 187 ++++++++++++++++++ .../components/RequestActionsSection.tsx | 168 ++++++++++++++++ .../components/RequestDetailsSection.tsx | 78 ++++++++ .../components/index.tsx | 3 + .../components/RequestsTable.tsx | 113 ++++------- 9 files changed, 573 insertions(+), 83 deletions(-) create mode 100644 frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx create mode 100644 frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx create mode 100644 frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx create mode 100644 frontend/src/pages/pam/ApprovalRequestDetailPage/components/index.tsx diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 82697e9ba..32555eea7 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -359,6 +359,10 @@ export const ROUTE_PATHS = Object.freeze({ "/organizations/$orgId/projects/pam/$projectId/sessions", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/" ), + ApprovalRequestDetailPage: setRoute( + "/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId" + ), PamSessionByIDPage: setRoute( "/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId" diff --git a/frontend/src/hooks/api/approvalRequests/types.ts b/frontend/src/hooks/api/approvalRequests/types.ts index 9d119a364..d57728353 100644 --- a/frontend/src/hooks/api/approvalRequests/types.ts +++ b/frontend/src/hooks/api/approvalRequests/types.ts @@ -14,11 +14,16 @@ export enum ApprovalRequestStepStatus { Rejected = "rejected" } +export enum ApprovalRequestApprovalDecision { + Approved = "approved", + Rejected = "rejected" +} + export type ApprovalRequestApproval = { id: string; - requestStepId: string; - approverId: string; - approverType: ApproverType; + stepId: string; + approverUserId: string; + decision: ApprovalRequestApprovalDecision.Approved; comment?: string | null; createdAt: string; updatedAt: string; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index b7ceb4a1b..727fafbf6 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -78,6 +78,7 @@ export type TUserMembership = { scope: string; scopeOrgId: string; actorUserId: string; + actorGroupId: string; }; export type TProjectMembership = { diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx index 415c68c65..eb95eb2d0 100644 --- a/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/ApprovalRequestDetailPage.tsx @@ -1,3 +1,90 @@ -export const ApprovalRequestDetailPage = () => { - return
Request detail
; +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, useNavigate, useParams } from "@tanstack/react-router"; + +import { ContentLoader, EmptyState, PageHeader } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useOrganization, useProject } from "@app/context"; +import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies"; +import { approvalRequestQuery } from "@app/hooks/api/approvalRequests"; +import { ProjectType } from "@app/hooks/api/projects/types"; + +import { ApprovalStepsSection, RequestActionsSection, RequestDetailsSection } from "./components"; + +const PageContent = () => { + const { approvalRequestId } = useParams({ + from: ROUTE_PATHS.Pam.ApprovalRequestDetailPage.id + }); + const { currentOrg } = useOrganization(); + const { currentProject } = useProject(); + + const { data: request, isPending } = useQuery( + approvalRequestQuery.getById({ + policyType: ApprovalPolicyType.PamAccess, + requestId: approvalRequestId + }) + ); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!request) { + return ( +
+ +
+ ); + } + + return ( +
+
+ + + Approvals List + + +
+
+ + +
+
+ +
+
+
+
+ ); +}; + +export const ApprovalRequestDetailPage = () => { + return ( + <> + + Approval Request | Infisical + + + + + ); }; diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx new file mode 100644 index 000000000..bd373b675 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/ApprovalStepsSection.tsx @@ -0,0 +1,187 @@ +import { useMemo } from "react"; +import { faCheck, faCheckCircle, faClock, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { User, Users } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +import { Badge } from "@app/components/v3"; +import { useProject } from "@app/context"; +import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api"; +import { ApproverType } from "@app/hooks/api/approvalPolicies"; +import { + ApprovalRequestApproval, + ApprovalRequestStepStatus, + TApprovalRequest +} from "@app/hooks/api/approvalRequests"; +import { ApprovalRequestApprovalDecision } from "@app/hooks/api/approvalRequests/types"; + +type Props = { + request: TApprovalRequest; +}; + +const getMemberLabel = (member: { + user: { username: string; email: string; firstName: string; lastName: string }; +}) => { + const { user } = member; + if (user.firstName || user.lastName) { + return `${user.firstName || ""} ${user.lastName || ""}`.trim(); + } + return user.username || user.email; +}; + +export const ApprovalStepsSection = ({ request }: Props) => { + const { currentProject } = useProject(); + const projectId = currentProject?.id || ""; + + const { data: members = [] } = useGetWorkspaceUsers(projectId, true); + const { data: groups = [] } = useListWorkspaceGroups(projectId); + + const getApproverLabel = useMemo( + () => (approverId: string, approverType: ApproverType) => { + if (approverType === ApproverType.User) { + const member = members?.find((m) => m.user.id === approverId); + if (member) return getMemberLabel(member); + } else if (approverType === ApproverType.Group) { + const group = groups?.find(({ group: g }) => g.id === approverId); + if (group) return group.group.name; + } + return approverId; + }, + [members, groups] + ); + + const getApprovalLabel = (approval: ApprovalRequestApproval) => { + const member = members?.find((m) => m.user.id === approval.approverUserId); + if (member) return getMemberLabel(member); + return approval.approverUserId; + }; + + const getStepStatusIcon = (status: ApprovalRequestStepStatus) => { + switch (status) { + case ApprovalRequestStepStatus.Approved: + return ; + case ApprovalRequestStepStatus.InProgress: + return ; + case ApprovalRequestStepStatus.Pending: + return
; + case ApprovalRequestStepStatus.Rejected: + return ; + default: + return null; + } + }; + + return ( +
+
+

Approval Workflow

+
+
+ {request.steps.map((step, index) => ( +
+ {/* Connector line */} + {index < request.steps.length - 1 && ( +
+ )} +
+
+ {getStepStatusIcon(step.status)} +
+
+
+
+

+ Step {index + 1} + {step.status === ApprovalRequestStepStatus.InProgress && ( + (Current Step) + )} +

+ + {step.status.split("-").join(" ")} + +
+
+
+
Approvals Required:
+
{step.requiredApprovals}
+
+
+
Approvers
+
+ {step.approvers.map((approver) => ( + + {approver.type === ApproverType.Group ? : } + {getApproverLabel(approver.id, approver.type)} + + ))} +
+
+ {step.approvals.length > 0 && ( +
+

Approvals Given:

+ {step.approvals.map((approval) => ( +
+
+ +
+

+ {getApprovalLabel(approval)} +

+

+ {format(new Date(approval.createdAt), "MMM dd, yyyy hh:mm aaa")} +

+
+
+ {approval.comment && ( +

+ "{approval.comment}" +

+ )} +
+ ))} +
+ )} +
+
+
+
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx new file mode 100644 index 000000000..1eff54685 --- /dev/null +++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestActionsSection.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { faCheck, faMagnifyingGlass, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + FormLabel, + Popover, + PopoverContent, + PopoverTrigger, + TextArea +} from "@app/components/v2"; +import { useProjectPermission, useUser } from "@app/context"; +import { ApproverType } from "@app/hooks/api/approvalPolicies"; +import { + ApprovalRequestStatus, + ApprovalRequestStepStatus, + TApprovalRequest, + useApproveApprovalRequest, + useRejectApprovalRequest +} from "@app/hooks/api/approvalRequests"; + +type Props = { + request: TApprovalRequest; +}; + +export const RequestActionsSection = ({ request }: Props) => { + const { user } = useUser(); + const { memberships } = useProjectPermission(); + const [comment, setComment] = useState(""); + + const userId = user?.id || ""; + const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean); + + const { mutateAsync: approveRequest, isPending: isApproving } = useApproveApprovalRequest(); + const { mutateAsync: rejectRequest, isPending: isRejecting } = useRejectApprovalRequest(); + + // Check if the request is actionable + if (request.status !== ApprovalRequestStatus.Pending) { + return null; + } + + // Find the current active step + const currentStep = request.steps.find( + (step) => step.status === ApprovalRequestStepStatus.InProgress + ); + + if (!currentStep) { + return null; + } + + // Check if user is an approver in the current step + const isApprover = currentStep.approvers.some((approver) => + approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id) + ); + + if (!isApprover) { + return null; + } + + // Check if user has already approved/rejected + const hasAlreadyActed = currentStep.approvals.some( + (approval) => approval.approverUserId === userId + ); + + if (hasAlreadyActed) { + return ( +
+

+ You have already provided your approval for this request. +

+
+ ); + } + + const handleApprove = async () => { + try { + await approveRequest({ + policyType: request.type, + requestId: request.id, + comment: comment || undefined + }); + createNotification({ + text: "Request approved successfully", + type: "success" + }); + } finally { + setComment(""); + } + }; + + const handleReject = async () => { + try { + await rejectRequest({ + policyType: request.type, + requestId: request.id, + comment: comment || undefined + }); + createNotification({ + text: "Request rejected successfully", + type: "success" + }); + } finally { + setComment(""); + } + }; + + return ( +
+
+

Action Required

+ Awaiting Your Approval +
+
+

+ You are an approver for the current step. Please review the request details and provide + your decision. +

+ + + + + + +