mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: improved ui for request detail table
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -78,6 +78,7 @@ export type TUserMembership = {
|
||||
scope: string;
|
||||
scopeOrgId: string;
|
||||
actorUserId: string;
|
||||
actorGroupId: string;
|
||||
};
|
||||
|
||||
export type TProjectMembership = {
|
||||
|
||||
@@ -1,3 +1,90 @@
|
||||
export const ApprovalRequestDetailPage = () => {
|
||||
return <div>Request detail</div>;
|
||||
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 (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<ContentLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center px-20">
|
||||
<EmptyState
|
||||
className="max-w-2xl rounded-md text-center"
|
||||
icon={faBan}
|
||||
title={`Could not find approval request with ID ${approvalRequestId}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-8xl">
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/approvals"
|
||||
params={{ orgId: currentOrg.id, projectId: currentProject.id }}
|
||||
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
Approvals List
|
||||
</Link>
|
||||
<PageHeader
|
||||
scope={ProjectType.PAM}
|
||||
title="Approval Request"
|
||||
description={`Request to access account ${request.requestData.requestData.accountPath} for ${request.requestData.requestData.requestDurationSeconds} by ${request.requesterName || "Unknown"}`}
|
||||
/>
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="flex w-96 flex-col gap-4">
|
||||
<RequestDetailsSection request={request} />
|
||||
<RequestActionsSection request={request} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<ApprovalStepsSection request={request} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ApprovalRequestDetailPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Approval Request | Infisical</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<PageContent />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 <FontAwesomeIcon icon={faCheckCircle} className="text-green-500" />;
|
||||
case ApprovalRequestStepStatus.InProgress:
|
||||
return <FontAwesomeIcon icon={faClock} className="text-yellow-500" />;
|
||||
case ApprovalRequestStepStatus.Pending:
|
||||
return <div className="h-4 w-4 rounded-full border-2 border-mineshaft-400" />;
|
||||
case ApprovalRequestStepStatus.Rejected:
|
||||
return <FontAwesomeIcon icon={faXmark} className="text-red-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between border-b border-mineshaft-500 pb-2">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Approval Workflow</h3>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{request.steps.map((step, index) => (
|
||||
<div key={step.id} className="relative">
|
||||
{/* Connector line */}
|
||||
{index < request.steps.length - 1 && (
|
||||
<div className="absolute top-8 left-2 h-full w-0.5 bg-mineshaft-600" />
|
||||
)}
|
||||
<div className="relative flex gap-3">
|
||||
<div className="relative top-3 z-10 flex h-4 w-4 items-center justify-center bg-mineshaft-900">
|
||||
{getStepStatusIcon(step.status)}
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<div
|
||||
className={twMerge(
|
||||
"rounded-lg border bg-mineshaft-800",
|
||||
step.status === ApprovalRequestStepStatus.InProgress
|
||||
? "border-yellow-500/40"
|
||||
: "border-mineshaft-600"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mb-2 flex items-center justify-between p-2",
|
||||
step.status === ApprovalRequestStepStatus.InProgress
|
||||
? "bg-yellow-500/20"
|
||||
: "bg-mineshaft-700"
|
||||
)}
|
||||
>
|
||||
<h4 className="font-medium text-mineshaft-100">
|
||||
Step {index + 1}
|
||||
{step.status === ApprovalRequestStepStatus.InProgress && (
|
||||
<span className="ml-2 text-xs text-yellow-500">(Current Step)</span>
|
||||
)}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={
|
||||
step.status === ApprovalRequestStepStatus.InProgress ? "project" : "neutral"
|
||||
}
|
||||
className="capitalize"
|
||||
>
|
||||
{step.status.split("-").join(" ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-3 p-2 pb-4 text-bunker-200">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<div>Approvals Required:</div>
|
||||
<div>{step.requiredApprovals}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="mb-2">Approvers</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{step.approvers.map((approver) => (
|
||||
<Badge variant="neutral">
|
||||
{approver.type === ApproverType.Group ? <User /> : <Users />}
|
||||
{getApproverLabel(approver.id, approver.type)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{step.approvals.length > 0 && (
|
||||
<div className="mt-3 space-y-2 border-t border-mineshaft-600 pt-3">
|
||||
<p className="text-xs font-medium text-mineshaft-300">Approvals Given:</p>
|
||||
{step.approvals.map((approval) => (
|
||||
<div
|
||||
key={approval.id}
|
||||
className="flex items-start justify-between rounded bg-mineshaft-900/50 p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
approval.decision === ApprovalRequestApprovalDecision.Approved
|
||||
? faCheck
|
||||
: faXmark
|
||||
}
|
||||
className={
|
||||
approval.decision === ApprovalRequestApprovalDecision.Approved
|
||||
? "text-green-500"
|
||||
: "text-red-500"
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-mineshaft-100">
|
||||
{getApprovalLabel(approval)}
|
||||
</p>
|
||||
<p className="text-xs text-mineshaft-400">
|
||||
{format(new Date(approval.createdAt), "MMM dd, yyyy hh:mm aaa")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{approval.comment && (
|
||||
<p className="max-w-xs text-xs text-mineshaft-400 italic">
|
||||
"{approval.comment}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
You have already provided your approval for this request.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-4 py-3">
|
||||
<div className="flex items-center justify-between border-b border-yellow-500/30 pb-2">
|
||||
<h3 className="font-medium text-mineshaft-100">Action Required</h3>
|
||||
<span className="text-xs text-yellow-500">Awaiting Your Approval</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
You are an approver for the current step. Please review the request details and provide
|
||||
your decision.
|
||||
</p>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" title="Finish your review" className="w-96">
|
||||
<FormLabel label="Finish your review" />
|
||||
<TextArea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Add a comment about your decision..."
|
||||
rows={3}
|
||||
reSize="vertical"
|
||||
className="mt-1 mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||
onClick={handleApprove}
|
||||
isLoading={isApproving}
|
||||
isDisabled={isRejecting}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
Approve Request
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faXmark} />}
|
||||
onClick={handleReject}
|
||||
isLoading={isRejecting}
|
||||
isDisabled={isApproving}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
Reject Request
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-500 pb-2">
|
||||
<h3 className="text-lg font-medium text-mineshaft-100">Request Details</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<GenericFieldLabel label="Status">
|
||||
<Badge className="capitalize" variant={getStatusBadgeColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Requester Name">
|
||||
{request.requesterName || "Unknown"}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Requester Email">{request.requesterEmail}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Account Path">{accountPath}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Duration">
|
||||
{formatDuration(requestDurationSeconds)}
|
||||
</GenericFieldLabel>
|
||||
{request.justification && (
|
||||
<GenericFieldLabel label="Justification">
|
||||
<p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">
|
||||
{request.justification}
|
||||
</p>
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
<GenericFieldLabel label="Requested At">
|
||||
{format(new Date(request.createdAt), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</GenericFieldLabel>
|
||||
{request.expiresAt && (
|
||||
<GenericFieldLabel label="Expires">
|
||||
{format(new Date(request.expiresAt), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</GenericFieldLabel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ApprovalStepsSection } from "./ApprovalStepsSection";
|
||||
export { RequestActionsSection } from "./RequestActionsSection";
|
||||
export { RequestDetailsSection } from "./RequestDetailsSection";
|
||||
@@ -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<Filter>({
|
||||
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 (
|
||||
<div>
|
||||
@@ -220,9 +183,11 @@ export const RequestsTable = () => {
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleStatusFilterToggle();
|
||||
setFilter(ApprovalRequestStatus.Pending);
|
||||
}}
|
||||
icon={filter.status === "open" && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
icon={
|
||||
filter === ApprovalRequestStatus.Pending && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Open Requests
|
||||
@@ -241,10 +206,10 @@ export const RequestsTable = () => {
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleClosedStatusToggle(ApprovalRequestStatus.Approved);
|
||||
setFilter(ApprovalRequestStatus.Approved);
|
||||
}}
|
||||
icon={
|
||||
filter.closedStatuses.includes(ApprovalRequestStatus.Approved) && (
|
||||
filter === ApprovalRequestStatus.Approved && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
@@ -255,10 +220,10 @@ export const RequestsTable = () => {
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleClosedStatusToggle(ApprovalRequestStatus.Rejected);
|
||||
setFilter(ApprovalRequestStatus.Rejected);
|
||||
}}
|
||||
icon={
|
||||
filter.closedStatuses.includes(ApprovalRequestStatus.Rejected) && (
|
||||
filter === ApprovalRequestStatus.Rejected && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
@@ -269,10 +234,10 @@ export const RequestsTable = () => {
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleClosedStatusToggle(ApprovalRequestStatus.Expired);
|
||||
setFilter(ApprovalRequestStatus.Expired);
|
||||
}}
|
||||
icon={
|
||||
filter.closedStatuses.includes(ApprovalRequestStatus.Expired) && (
|
||||
filter === ApprovalRequestStatus.Expired && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
@@ -296,7 +261,7 @@ export const RequestsTable = () => {
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Requester</Th>
|
||||
<Th>Resource / Path</Th>
|
||||
<Th>Account Path</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Requested</Th>
|
||||
@@ -306,9 +271,8 @@ export const RequestsTable = () => {
|
||||
{isRequestsLoading && <TableSkeleton columns={5} innerKey="approval-requests" />}
|
||||
{!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 (
|
||||
<Tr
|
||||
@@ -330,9 +294,6 @@ export const RequestsTable = () => {
|
||||
<Td>
|
||||
<div>
|
||||
<div className="text-sm text-mineshaft-200">{accountPath}</div>
|
||||
<div className="font-mono text-xs text-mineshaft-400">
|
||||
{resourceId.slice(0, 8)}...
|
||||
</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
@@ -376,11 +337,7 @@ export const RequestsTable = () => {
|
||||
)}
|
||||
{!isRequestsLoading && !filteredRequests?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
requests.length
|
||||
? "No approval requests match search..."
|
||||
: "No approval requests found"
|
||||
}
|
||||
title="No approval requests found"
|
||||
icon={requests.length ? faSearch : faFileCircleQuestion}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user