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.
+
+
+
+ }
+ className="px-2 py-1"
+ >
+ Review
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx
new file mode 100644
index 000000000..9230a0c03
--- /dev/null
+++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/RequestDetailsSection.tsx
@@ -0,0 +1,78 @@
+import { format } from "date-fns";
+
+import { Badge } from "@app/components/v3";
+import { GenericFieldLabel } from "@app/components/v2";
+import { ApprovalRequestStatus, TApprovalRequest } from "@app/hooks/api/approvalRequests";
+
+type Props = {
+ request: TApprovalRequest;
+};
+
+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 formatDuration = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+
+ if (hours > 0) {
+ return `${hours}h${minutes > 0 ? ` ${minutes}m` : ""}`;
+ }
+ return `${minutes}m`;
+};
+
+export const RequestDetailsSection = ({ request }: Props) => {
+ const { accountPath, requestDurationSeconds } = request.requestData.requestData;
+
+ return (
+
+
+
Request Details
+
+
+
+
+
+ {request.status}
+
+
+
+ {request.requesterName || "Unknown"}
+
+
{request.requesterEmail}
+
{accountPath}
+
+ {formatDuration(requestDurationSeconds)}
+
+ {request.justification && (
+
+
+ {request.justification}
+
+
+ )}
+
+ {format(new Date(request.createdAt), "yyyy-MM-dd, hh:mm aaa")}
+
+ {request.expiresAt && (
+
+ {format(new Date(request.expiresAt), "yyyy-MM-dd, hh:mm aaa")}
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend/src/pages/pam/ApprovalRequestDetailPage/components/index.tsx b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/index.tsx
new file mode 100644
index 000000000..1b2814dbf
--- /dev/null
+++ b/frontend/src/pages/pam/ApprovalRequestDetailPage/components/index.tsx
@@ -0,0 +1,3 @@
+export { ApprovalStepsSection } from "./ApprovalStepsSection";
+export { RequestActionsSection } from "./RequestActionsSection";
+export { RequestDetailsSection } from "./RequestDetailsSection";
diff --git a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsTable.tsx b/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsTable.tsx
index f79fc07cc..aec25cb60 100644
--- a/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsTable.tsx
+++ b/frontend/src/pages/pam/ApprovalsPage/components/ApprovalRequestTab/components/RequestsTable.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
import {
faCheckCircle,
faChevronRight,
@@ -37,7 +37,8 @@ import {
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3";
-import { useOrganization, useProject, useUser } from "@app/context";
+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 {
@@ -47,11 +48,6 @@ import {
TApprovalRequest
} from "@app/hooks/api/approvalRequests";
-type Filter = {
- status: "open" | "closed";
- closedStatuses: ApprovalRequestStatus[];
-};
-
const getStatusBadgeColor = (status: ApprovalRequestStatus) => {
switch (status) {
case ApprovalRequestStatus.Pending:
@@ -77,21 +73,25 @@ const formatDuration = (seconds: number) => {
return `${minutes}m`;
};
-const checkIfUserNeedsToApprove = (request: TApprovalRequest, userId: string): boolean => {
+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
+ 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.approverId === userId
+ (approval) => approval.approverUserId === userId
);
return !hasAlreadyApproved;
@@ -101,15 +101,14 @@ 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({
- status: "open",
- closedStatuses: []
- });
+ 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({
@@ -119,7 +118,7 @@ export const RequestsTable = () => {
);
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
- initPerPage: 10
+ initPerPage: getUserTablePreference("PamApprovalRequestTable", PreferenceKey.PerPage, 10)
});
const filteredRequests = useMemo(() => {
@@ -137,24 +136,11 @@ export const RequestsTable = () => {
);
}
- // Apply open/closed filter
- if (filter.status === "open") {
- filtered = filtered.filter((req) => req.status === ApprovalRequestStatus.Pending);
- } else {
- const closedStatuses = [
- ApprovalRequestStatus.Approved,
- ApprovalRequestStatus.Rejected,
- ApprovalRequestStatus.Expired
- ];
- filtered = filtered.filter((req) => closedStatuses.includes(req.status));
-
- // Apply specific closed status filter
- if (filter.closedStatuses.length > 0) {
- filtered = filtered.filter((req) => filter.closedStatuses.includes(req.status));
- }
- }
-
- return filtered;
+ 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(
@@ -173,30 +159,7 @@ export const RequestsTable = () => {
});
};
- const handleStatusFilterToggle = useCallback(
- () =>
- setFilter((state) => ({
- ...state,
- status: state.status === "open" ? "closed" : "open",
- closedStatuses: []
- })),
- []
- );
-
- const handleClosedStatusToggle = useCallback(
- (status: ApprovalRequestStatus) =>
- setFilter((state) => {
- const closedStatuses = state.closedStatuses || [];
-
- if (closedStatuses.includes(status)) {
- return { ...state, closedStatuses: closedStatuses.filter((s) => s !== status) };
- }
- return { ...state, closedStatuses: [...closedStatuses, status] };
- }),
- []
- );
-
- const isTableFiltered = filter.status === "closed" || filter.closedStatuses.length > 0;
+ const isTableFiltered = filter !== ApprovalRequestStatus.Pending;
return (
@@ -220,9 +183,11 @@ export const RequestsTable = () => {
{
evt.preventDefault();
- handleStatusFilterToggle();
+ setFilter(ApprovalRequestStatus.Pending);
}}
- icon={filter.status === "open" && }
+ icon={
+ filter === ApprovalRequestStatus.Pending &&
+ }
iconPos="right"
>
Open Requests
@@ -241,10 +206,10 @@ export const RequestsTable = () => {
{
evt.preventDefault();
- handleClosedStatusToggle(ApprovalRequestStatus.Approved);
+ setFilter(ApprovalRequestStatus.Approved);
}}
icon={
- filter.closedStatuses.includes(ApprovalRequestStatus.Approved) && (
+ filter === ApprovalRequestStatus.Approved && (
)
}
@@ -255,10 +220,10 @@ export const RequestsTable = () => {
{
evt.preventDefault();
- handleClosedStatusToggle(ApprovalRequestStatus.Rejected);
+ setFilter(ApprovalRequestStatus.Rejected);
}}
icon={
- filter.closedStatuses.includes(ApprovalRequestStatus.Rejected) && (
+ filter === ApprovalRequestStatus.Rejected && (
)
}
@@ -269,10 +234,10 @@ export const RequestsTable = () => {
{
evt.preventDefault();
- handleClosedStatusToggle(ApprovalRequestStatus.Expired);
+ setFilter(ApprovalRequestStatus.Expired);
}}
icon={
- filter.closedStatuses.includes(ApprovalRequestStatus.Expired) && (
+ filter === ApprovalRequestStatus.Expired && (
)
}
@@ -296,7 +261,7 @@ export const RequestsTable = () => {
| Requester |
- Resource / Path |
+ Account Path |
Duration |
Status |
Requested |
@@ -306,9 +271,8 @@ export const RequestsTable = () => {
{isRequestsLoading && }
{!isRequestsLoading &&
paginatedRequests.map((request) => {
- const needsApproval = checkIfUserNeedsToApprove(request, userId);
- const { resourceId, accountPath, requestDurationSeconds } =
- request.requestData.requestData;
+ const needsApproval = checkIfUserNeedsToApprove(request, userId, userGroups);
+ const { accountPath, requestDurationSeconds } = request.requestData.requestData;
return (
{
{accountPath}
-
- {resourceId.slice(0, 8)}...
-
|
@@ -376,11 +337,7 @@ export const RequestsTable = () => {
)}
{!isRequestsLoading && !filteredRequests?.length && (
)}
|