mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 09:26:47 +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",
|
"/organizations/$orgId/projects/pam/$projectId/sessions",
|
||||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/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(
|
PamSessionByIDPage: setRoute(
|
||||||
"/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId",
|
"/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId",
|
||||||
"/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/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"
|
Rejected = "rejected"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ApprovalRequestApprovalDecision {
|
||||||
|
Approved = "approved",
|
||||||
|
Rejected = "rejected"
|
||||||
|
}
|
||||||
|
|
||||||
export type ApprovalRequestApproval = {
|
export type ApprovalRequestApproval = {
|
||||||
id: string;
|
id: string;
|
||||||
requestStepId: string;
|
stepId: string;
|
||||||
approverId: string;
|
approverUserId: string;
|
||||||
approverType: ApproverType;
|
decision: ApprovalRequestApprovalDecision.Approved;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export type TUserMembership = {
|
|||||||
scope: string;
|
scope: string;
|
||||||
scopeOrgId: string;
|
scopeOrgId: string;
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
|
actorGroupId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TProjectMembership = {
|
export type TProjectMembership = {
|
||||||
|
|||||||
@@ -1,3 +1,90 @@
|
|||||||
|
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 = () => {
|
export const ApprovalRequestDetailPage = () => {
|
||||||
return <div>Request detail</div>;
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+168
@@ -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";
|
||||||
+35
-78
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
faChevronRight,
|
faChevronRight,
|
||||||
@@ -37,7 +37,8 @@ import {
|
|||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { Badge } from "@app/components/v3";
|
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 { usePagination } from "@app/hooks";
|
||||||
import { ApprovalPolicyType, ApproverType } from "@app/hooks/api/approvalPolicies";
|
import { ApprovalPolicyType, ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||||
import {
|
import {
|
||||||
@@ -47,11 +48,6 @@ import {
|
|||||||
TApprovalRequest
|
TApprovalRequest
|
||||||
} from "@app/hooks/api/approvalRequests";
|
} from "@app/hooks/api/approvalRequests";
|
||||||
|
|
||||||
type Filter = {
|
|
||||||
status: "open" | "closed";
|
|
||||||
closedStatuses: ApprovalRequestStatus[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadgeColor = (status: ApprovalRequestStatus) => {
|
const getStatusBadgeColor = (status: ApprovalRequestStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case ApprovalRequestStatus.Pending:
|
case ApprovalRequestStatus.Pending:
|
||||||
@@ -77,21 +73,25 @@ const formatDuration = (seconds: number) => {
|
|||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkIfUserNeedsToApprove = (request: TApprovalRequest, userId: string): boolean => {
|
const checkIfUserNeedsToApprove = (
|
||||||
|
request: TApprovalRequest,
|
||||||
|
userId: string,
|
||||||
|
userGroups: string[]
|
||||||
|
): boolean => {
|
||||||
const currentStep = request.steps.find(
|
const currentStep = request.steps.find(
|
||||||
(step) => step.status === ApprovalRequestStepStatus.InProgress
|
(step) => step.status === ApprovalRequestStepStatus.InProgress
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentStep) return false;
|
if (!currentStep) return false;
|
||||||
|
|
||||||
const isApprover = currentStep.approvers.some(
|
const isApprover = currentStep.approvers.some((approver) =>
|
||||||
(approver) => approver.type === ApproverType.User && approver.id === userId
|
approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isApprover) return false;
|
if (!isApprover) return false;
|
||||||
|
|
||||||
const hasAlreadyApproved = currentStep.approvals.some(
|
const hasAlreadyApproved = currentStep.approvals.some(
|
||||||
(approval) => approval.approverId === userId
|
(approval) => approval.approverUserId === userId
|
||||||
);
|
);
|
||||||
|
|
||||||
return !hasAlreadyApproved;
|
return !hasAlreadyApproved;
|
||||||
@@ -101,15 +101,14 @@ export const RequestsTable = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const { currentProject } = useProject();
|
const { currentProject } = useProject();
|
||||||
|
const { memberships } = useProjectPermission();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filter, setFilter] = useState<Filter>({
|
const [filter, setFilter] = useState(ApprovalRequestStatus.Pending);
|
||||||
status: "open",
|
|
||||||
closedStatuses: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectId = currentProject?.id || "";
|
const projectId = currentProject?.id || "";
|
||||||
const userId = user?.id || "";
|
const userId = user?.id || "";
|
||||||
|
const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean);
|
||||||
|
|
||||||
const { data: requests = [], isPending: isRequestsLoading } = useQuery(
|
const { data: requests = [], isPending: isRequestsLoading } = useQuery(
|
||||||
approvalRequestQuery.list({
|
approvalRequestQuery.list({
|
||||||
@@ -119,7 +118,7 @@ export const RequestsTable = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
|
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
|
||||||
initPerPage: 10
|
initPerPage: getUserTablePreference("PamApprovalRequestTable", PreferenceKey.PerPage, 10)
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredRequests = useMemo(() => {
|
const filteredRequests = useMemo(() => {
|
||||||
@@ -137,24 +136,11 @@ export const RequestsTable = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply open/closed filter
|
return filtered
|
||||||
if (filter.status === "open") {
|
.filter((req) => req.status === filter)
|
||||||
filtered = filtered.filter((req) => req.status === ApprovalRequestStatus.Pending);
|
.sort(
|
||||||
} else {
|
(a, b) => (new Date(b.createdAt)?.getTime() || 0) - (new Date(a.createdAt)?.getTime() || 0)
|
||||||
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;
|
|
||||||
}, [requests, search, filter]);
|
}, [requests, search, filter]);
|
||||||
|
|
||||||
const paginatedRequests = useMemo(
|
const paginatedRequests = useMemo(
|
||||||
@@ -173,30 +159,7 @@ export const RequestsTable = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusFilterToggle = useCallback(
|
const isTableFiltered = filter !== ApprovalRequestStatus.Pending;
|
||||||
() =>
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -220,9 +183,11 @@ export const RequestsTable = () => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(evt) => {
|
onClick={(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
handleStatusFilterToggle();
|
setFilter(ApprovalRequestStatus.Pending);
|
||||||
}}
|
}}
|
||||||
icon={filter.status === "open" && <FontAwesomeIcon icon={faCheckCircle} />}
|
icon={
|
||||||
|
filter === ApprovalRequestStatus.Pending && <FontAwesomeIcon icon={faCheckCircle} />
|
||||||
|
}
|
||||||
iconPos="right"
|
iconPos="right"
|
||||||
>
|
>
|
||||||
Open Requests
|
Open Requests
|
||||||
@@ -241,10 +206,10 @@ export const RequestsTable = () => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(evt) => {
|
onClick={(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
handleClosedStatusToggle(ApprovalRequestStatus.Approved);
|
setFilter(ApprovalRequestStatus.Approved);
|
||||||
}}
|
}}
|
||||||
icon={
|
icon={
|
||||||
filter.closedStatuses.includes(ApprovalRequestStatus.Approved) && (
|
filter === ApprovalRequestStatus.Approved && (
|
||||||
<FontAwesomeIcon icon={faCheckCircle} />
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -255,10 +220,10 @@ export const RequestsTable = () => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(evt) => {
|
onClick={(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
handleClosedStatusToggle(ApprovalRequestStatus.Rejected);
|
setFilter(ApprovalRequestStatus.Rejected);
|
||||||
}}
|
}}
|
||||||
icon={
|
icon={
|
||||||
filter.closedStatuses.includes(ApprovalRequestStatus.Rejected) && (
|
filter === ApprovalRequestStatus.Rejected && (
|
||||||
<FontAwesomeIcon icon={faCheckCircle} />
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -269,10 +234,10 @@ export const RequestsTable = () => {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(evt) => {
|
onClick={(evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
handleClosedStatusToggle(ApprovalRequestStatus.Expired);
|
setFilter(ApprovalRequestStatus.Expired);
|
||||||
}}
|
}}
|
||||||
icon={
|
icon={
|
||||||
filter.closedStatuses.includes(ApprovalRequestStatus.Expired) && (
|
filter === ApprovalRequestStatus.Expired && (
|
||||||
<FontAwesomeIcon icon={faCheckCircle} />
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -296,7 +261,7 @@ export const RequestsTable = () => {
|
|||||||
<THead>
|
<THead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<Th>Requester</Th>
|
<Th>Requester</Th>
|
||||||
<Th>Resource / Path</Th>
|
<Th>Account Path</Th>
|
||||||
<Th>Duration</Th>
|
<Th>Duration</Th>
|
||||||
<Th>Status</Th>
|
<Th>Status</Th>
|
||||||
<Th>Requested</Th>
|
<Th>Requested</Th>
|
||||||
@@ -306,9 +271,8 @@ export const RequestsTable = () => {
|
|||||||
{isRequestsLoading && <TableSkeleton columns={5} innerKey="approval-requests" />}
|
{isRequestsLoading && <TableSkeleton columns={5} innerKey="approval-requests" />}
|
||||||
{!isRequestsLoading &&
|
{!isRequestsLoading &&
|
||||||
paginatedRequests.map((request) => {
|
paginatedRequests.map((request) => {
|
||||||
const needsApproval = checkIfUserNeedsToApprove(request, userId);
|
const needsApproval = checkIfUserNeedsToApprove(request, userId, userGroups);
|
||||||
const { resourceId, accountPath, requestDurationSeconds } =
|
const { accountPath, requestDurationSeconds } = request.requestData.requestData;
|
||||||
request.requestData.requestData;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tr
|
<Tr
|
||||||
@@ -330,9 +294,6 @@ export const RequestsTable = () => {
|
|||||||
<Td>
|
<Td>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-mineshaft-200">{accountPath}</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>
|
</div>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
@@ -376,11 +337,7 @@ export const RequestsTable = () => {
|
|||||||
)}
|
)}
|
||||||
{!isRequestsLoading && !filteredRequests?.length && (
|
{!isRequestsLoading && !filteredRequests?.length && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={
|
title="No approval requests found"
|
||||||
requests.length
|
|
||||||
? "No approval requests match search..."
|
|
||||||
: "No approval requests found"
|
|
||||||
}
|
|
||||||
icon={requests.length ? faSearch : faFileCircleQuestion}
|
icon={requests.length ? faSearch : faFileCircleQuestion}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user