mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-04 07:26:11 +00:00
feat: reorganization of files and more ui changes
This commit is contained in:
@@ -4,7 +4,8 @@ export enum ApprovalRequestStatus {
|
|||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
Approved = "approved",
|
Approved = "approved",
|
||||||
Rejected = "rejected",
|
Rejected = "rejected",
|
||||||
Expired = "expired"
|
Expired = "expired",
|
||||||
|
Cancelled = "cancelled"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ApprovalRequestStepStatus {
|
export enum ApprovalRequestStepStatus {
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ import { ROUTE_PATHS } from "@app/const/routes";
|
|||||||
import { useOrganization, useProject, useUser } from "@app/context";
|
import { useOrganization, useProject, useUser } from "@app/context";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
||||||
import { approvalRequestQuery, useCancelApprovalRequest } from "@app/hooks/api/approvalRequests";
|
import {
|
||||||
|
approvalRequestQuery,
|
||||||
|
ApprovalRequestStatus,
|
||||||
|
useCancelApprovalRequest
|
||||||
|
} from "@app/hooks/api/approvalRequests";
|
||||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||||
|
|
||||||
import { ApprovalStepsSection, RequestActionsSection, RequestDetailsSection } from "./components";
|
import { ApprovalStepsSection, RequestActionsSection, RequestDetailsSection } from "./components";
|
||||||
@@ -102,16 +106,17 @@ const PageContent = () => {
|
|||||||
description={`Request to access account ${request.requestData.requestData.accountPath} for ${request.requestData.requestData.accessDuration} by ${request.requesterName || "Unknown"}`}
|
description={`Request to access account ${request.requestData.requestData.accountPath} for ${request.requestData.requestData.accessDuration} by ${request.requesterName || "Unknown"}`}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
{request.requesterId === currentUser.id && (
|
{request.requesterId === currentUser.id &&
|
||||||
<Button
|
request.status === ApprovalRequestStatus.Pending && (
|
||||||
onClick={() => handlePopUpOpen("cancelRequest")}
|
<Button
|
||||||
variant="outline_bg"
|
onClick={() => handlePopUpOpen("cancelRequest")}
|
||||||
size="xs"
|
variant="outline_bg"
|
||||||
isLoading={cancelApprovalRequest.isPending}
|
size="xs"
|
||||||
>
|
isLoading={cancelApprovalRequest.isPending}
|
||||||
Cancel Request
|
>
|
||||||
</Button>
|
Cancel Request
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
import { Badge } from "@app/components/v3";
|
|
||||||
import { GenericFieldLabel } from "@app/components/v2";
|
import { GenericFieldLabel } from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v3";
|
||||||
import { ApprovalRequestStatus, TApprovalRequest } from "@app/hooks/api/approvalRequests";
|
import { ApprovalRequestStatus, TApprovalRequest } from "@app/hooks/api/approvalRequests";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
+355
-2
@@ -1,5 +1,358 @@
|
|||||||
import { RequestsSection } from "./components";
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
faCheckCircle,
|
||||||
|
faChevronRight,
|
||||||
|
faExclamationCircle,
|
||||||
|
faFileCircleQuestion,
|
||||||
|
faFilter,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faSearch
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { formatDistance } from "date-fns";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownSubMenu,
|
||||||
|
DropdownSubMenuContent,
|
||||||
|
DropdownSubMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v3";
|
||||||
|
import { useOrganization, useProject, useProjectPermission, useUser } from "@app/context";
|
||||||
|
import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences";
|
||||||
|
import { usePagination } from "@app/hooks";
|
||||||
|
import { ApprovalPolicyType, ApproverType } from "@app/hooks/api/approvalPolicies";
|
||||||
|
import {
|
||||||
|
approvalRequestQuery,
|
||||||
|
ApprovalRequestStatus,
|
||||||
|
ApprovalRequestStepStatus,
|
||||||
|
TApprovalRequest
|
||||||
|
} from "@app/hooks/api/approvalRequests";
|
||||||
|
|
||||||
|
const getStatusBadgeColor = (status: ApprovalRequestStatus) => {
|
||||||
|
switch (status) {
|
||||||
|
case ApprovalRequestStatus.Pending:
|
||||||
|
return "project";
|
||||||
|
case ApprovalRequestStatus.Approved:
|
||||||
|
return "success";
|
||||||
|
case ApprovalRequestStatus.Rejected:
|
||||||
|
return "danger";
|
||||||
|
case ApprovalRequestStatus.Expired:
|
||||||
|
return "neutral";
|
||||||
|
default:
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkIfUserNeedsToApprove = (
|
||||||
|
request: TApprovalRequest,
|
||||||
|
userId: string,
|
||||||
|
userGroups: string[]
|
||||||
|
): boolean => {
|
||||||
|
const currentStep = request.steps.find(
|
||||||
|
(step) => step.status === ApprovalRequestStepStatus.InProgress
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentStep) return false;
|
||||||
|
|
||||||
|
const isApprover = currentStep.approvers.some((approver) =>
|
||||||
|
approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isApprover) return false;
|
||||||
|
|
||||||
|
const hasAlreadyApproved = currentStep.approvals.some(
|
||||||
|
(approval) => approval.approverUserId === userId
|
||||||
|
);
|
||||||
|
|
||||||
|
return !hasAlreadyApproved;
|
||||||
|
};
|
||||||
|
|
||||||
export const ApprovalRequestTab = () => {
|
export const ApprovalRequestTab = () => {
|
||||||
return <RequestsSection />;
|
const navigate = useNavigate();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
const { memberships } = useProjectPermission();
|
||||||
|
const { user } = useUser();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filter, setFilter] = useState(ApprovalRequestStatus.Pending);
|
||||||
|
|
||||||
|
const projectId = currentProject?.id || "";
|
||||||
|
const userId = user?.id || "";
|
||||||
|
const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean);
|
||||||
|
|
||||||
|
const { data: requests = [], isPending: isRequestsLoading } = useQuery(
|
||||||
|
approvalRequestQuery.list({
|
||||||
|
policyType: ApprovalPolicyType.PamAccess,
|
||||||
|
projectId
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
|
||||||
|
initPerPage: getUserTablePreference("PamApprovalRequestTable", PreferenceKey.PerPage, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredRequests = useMemo(() => {
|
||||||
|
let filtered = requests;
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (search) {
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(request) =>
|
||||||
|
request.requesterName?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
request.requesterEmail?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
request.justification?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
request.requestData.requestData.accountPath.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
.filter((req) => req.status === filter)
|
||||||
|
.sort(
|
||||||
|
(a, b) => (new Date(b.createdAt)?.getTime() || 0) - (new Date(a.createdAt)?.getTime() || 0)
|
||||||
|
);
|
||||||
|
}, [requests, search, filter]);
|
||||||
|
|
||||||
|
const paginatedRequests = useMemo(
|
||||||
|
() => filteredRequests.slice(offset, offset + perPage),
|
||||||
|
[filteredRequests, offset, perPage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRowClick = (requestId: string) => {
|
||||||
|
navigate({
|
||||||
|
to: "/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId",
|
||||||
|
params: {
|
||||||
|
orgId: currentOrg.id,
|
||||||
|
projectId: currentProject.id,
|
||||||
|
approvalRequestId: requestId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTableFiltered = filter !== ApprovalRequestStatus.Pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<p className="text-xl font-medium text-mineshaft-100">Approval Requests</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="Filter Requests"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className={twMerge(
|
||||||
|
"flex h-9.5 w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||||
|
isTableFiltered && "border-primary/50 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFilter} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="p-0">
|
||||||
|
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setFilter(ApprovalRequestStatus.Pending);
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
filter === ApprovalRequestStatus.Pending && (
|
||||||
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
Open Requests
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownSubMenu>
|
||||||
|
<DropdownSubMenuTrigger
|
||||||
|
iconPos="right"
|
||||||
|
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||||
|
>
|
||||||
|
Closed Requests
|
||||||
|
</DropdownSubMenuTrigger>
|
||||||
|
<DropdownSubMenuContent className="max-h-80 thin-scrollbar overflow-y-auto rounded-l-none">
|
||||||
|
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||||
|
Filter by Status
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setFilter(ApprovalRequestStatus.Approved);
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
filter === ApprovalRequestStatus.Approved && (
|
||||||
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
Approved
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setFilter(ApprovalRequestStatus.Cancelled);
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
filter === ApprovalRequestStatus.Cancelled && (
|
||||||
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
Cancelled
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setFilter(ApprovalRequestStatus.Rejected);
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
filter === ApprovalRequestStatus.Rejected && (
|
||||||
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
Rejected
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
setFilter(ApprovalRequestStatus.Expired);
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
filter === ApprovalRequestStatus.Expired && (
|
||||||
|
<FontAwesomeIcon icon={faCheckCircle} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
Expired
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownSubMenuContent>
|
||||||
|
</DropdownSubMenu>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search approval requests..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TableContainer className="mt-4">
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Requester</Th>
|
||||||
|
<Th>Account Path</Th>
|
||||||
|
<Th>Duration</Th>
|
||||||
|
<Th>Status</Th>
|
||||||
|
<Th>Requested</Th>
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isRequestsLoading && <TableSkeleton columns={5} innerKey="approval-requests" />}
|
||||||
|
{!isRequestsLoading &&
|
||||||
|
paginatedRequests.map((request) => {
|
||||||
|
const needsApproval = checkIfUserNeedsToApprove(request, userId, userGroups);
|
||||||
|
const { accountPath, accessDuration } = request.requestData.requestData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
key={request.id}
|
||||||
|
className={twMerge(
|
||||||
|
"cursor-pointer transition-colors hover:bg-mineshaft-700",
|
||||||
|
needsApproval && "bg-primary/5 hover:bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => handleRowClick(request.id)}
|
||||||
|
>
|
||||||
|
<Td>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-mineshaft-100">
|
||||||
|
{request.requesterName || "Unknown"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-mineshaft-400">{request.requesterEmail}</div>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-mineshaft-200">{accountPath}</div>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<span className="text-sm text-mineshaft-200">{accessDuration}</span>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className="capitalize"
|
||||||
|
variant={getStatusBadgeColor(request.status)}
|
||||||
|
>
|
||||||
|
{request.status.split("-").join(" ")}
|
||||||
|
</Badge>
|
||||||
|
{needsApproval && (
|
||||||
|
<div className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary">
|
||||||
|
<FontAwesomeIcon icon={faExclamationCircle} className="h-3 w-3" />
|
||||||
|
<span>Approval Required</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<span className="text-sm text-mineshaft-400">
|
||||||
|
{formatDistance(new Date(request.createdAt), new Date(), {
|
||||||
|
addSuffix: true
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{Boolean(filteredRequests.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredRequests.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isRequestsLoading && !filteredRequests?.length && (
|
||||||
|
<EmptyState
|
||||||
|
title="No approval requests found"
|
||||||
|
icon={requests.length ? faSearch : faFileCircleQuestion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
import { RequestsTable } from "./RequestsTable";
|
|
||||||
|
|
||||||
export const RequestsSection = () => {
|
|
||||||
return (
|
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Approval Requests</p>
|
|
||||||
</div>
|
|
||||||
<RequestsTable />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-334
@@ -1,334 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
faCheckCircle,
|
|
||||||
faChevronRight,
|
|
||||||
faExclamationCircle,
|
|
||||||
faFileCircleQuestion,
|
|
||||||
faFilter,
|
|
||||||
faMagnifyingGlass,
|
|
||||||
faSearch
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { formatDistance } from "date-fns";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownSubMenu,
|
|
||||||
DropdownSubMenuContent,
|
|
||||||
DropdownSubMenuTrigger,
|
|
||||||
EmptyState,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Pagination,
|
|
||||||
Table,
|
|
||||||
TableContainer,
|
|
||||||
TableSkeleton,
|
|
||||||
TBody,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
THead,
|
|
||||||
Tr
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import { Badge } from "@app/components/v3";
|
|
||||||
import { useOrganization, useProject, useProjectPermission, useUser } from "@app/context";
|
|
||||||
import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences";
|
|
||||||
import { usePagination } from "@app/hooks";
|
|
||||||
import { ApprovalPolicyType, ApproverType } from "@app/hooks/api/approvalPolicies";
|
|
||||||
import {
|
|
||||||
approvalRequestQuery,
|
|
||||||
ApprovalRequestStatus,
|
|
||||||
ApprovalRequestStepStatus,
|
|
||||||
TApprovalRequest
|
|
||||||
} from "@app/hooks/api/approvalRequests";
|
|
||||||
|
|
||||||
const getStatusBadgeColor = (status: ApprovalRequestStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case ApprovalRequestStatus.Pending:
|
|
||||||
return "project";
|
|
||||||
case ApprovalRequestStatus.Approved:
|
|
||||||
return "success";
|
|
||||||
case ApprovalRequestStatus.Rejected:
|
|
||||||
return "danger";
|
|
||||||
case ApprovalRequestStatus.Expired:
|
|
||||||
return "neutral";
|
|
||||||
default:
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkIfUserNeedsToApprove = (
|
|
||||||
request: TApprovalRequest,
|
|
||||||
userId: string,
|
|
||||||
userGroups: string[]
|
|
||||||
): boolean => {
|
|
||||||
const currentStep = request.steps.find(
|
|
||||||
(step) => step.status === ApprovalRequestStepStatus.InProgress
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentStep) return false;
|
|
||||||
|
|
||||||
const isApprover = currentStep.approvers.some((approver) =>
|
|
||||||
approver.type === ApproverType.User ? approver.id === userId : userGroups.includes(approver.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isApprover) return false;
|
|
||||||
|
|
||||||
const hasAlreadyApproved = currentStep.approvals.some(
|
|
||||||
(approval) => approval.approverUserId === userId
|
|
||||||
);
|
|
||||||
|
|
||||||
return !hasAlreadyApproved;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RequestsTable = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { currentOrg } = useOrganization();
|
|
||||||
const { currentProject } = useProject();
|
|
||||||
const { memberships } = useProjectPermission();
|
|
||||||
const { user } = useUser();
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [filter, setFilter] = useState(ApprovalRequestStatus.Pending);
|
|
||||||
|
|
||||||
const projectId = currentProject?.id || "";
|
|
||||||
const userId = user?.id || "";
|
|
||||||
const userGroups = memberships.map((el) => el.actorGroupId).filter(Boolean);
|
|
||||||
|
|
||||||
const { data: requests = [], isPending: isRequestsLoading } = useQuery(
|
|
||||||
approvalRequestQuery.list({
|
|
||||||
policyType: ApprovalPolicyType.PamAccess,
|
|
||||||
projectId
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
|
|
||||||
initPerPage: getUserTablePreference("PamApprovalRequestTable", PreferenceKey.PerPage, 10)
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredRequests = useMemo(() => {
|
|
||||||
let filtered = requests;
|
|
||||||
|
|
||||||
// Apply search filter
|
|
||||||
if (search) {
|
|
||||||
filtered = filtered.filter(
|
|
||||||
(request) =>
|
|
||||||
request.requesterName?.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
request.requesterEmail?.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
request.justification?.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
request.requestData.requestData.accountPath.toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered
|
|
||||||
.filter((req) => req.status === filter)
|
|
||||||
.sort(
|
|
||||||
(a, b) => (new Date(b.createdAt)?.getTime() || 0) - (new Date(a.createdAt)?.getTime() || 0)
|
|
||||||
);
|
|
||||||
}, [requests, search, filter]);
|
|
||||||
|
|
||||||
const paginatedRequests = useMemo(
|
|
||||||
() => filteredRequests.slice(offset, offset + perPage),
|
|
||||||
[filteredRequests, offset, perPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRowClick = (requestId: string) => {
|
|
||||||
navigate({
|
|
||||||
to: "/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId",
|
|
||||||
params: {
|
|
||||||
orgId: currentOrg.id,
|
|
||||||
projectId: currentProject.id,
|
|
||||||
approvalRequestId: requestId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isTableFiltered = filter !== ApprovalRequestStatus.Pending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="Filter Requests"
|
|
||||||
variant="plain"
|
|
||||||
size="sm"
|
|
||||||
className={twMerge(
|
|
||||||
"flex h-9.5 w-[2.6rem] items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
|
||||||
isTableFiltered && "border-primary/50 text-primary"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faFilter} />
|
|
||||||
</IconButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="p-0">
|
|
||||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
setFilter(ApprovalRequestStatus.Pending);
|
|
||||||
}}
|
|
||||||
icon={
|
|
||||||
filter === ApprovalRequestStatus.Pending && <FontAwesomeIcon icon={faCheckCircle} />
|
|
||||||
}
|
|
||||||
iconPos="right"
|
|
||||||
>
|
|
||||||
Open Requests
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownSubMenu>
|
|
||||||
<DropdownSubMenuTrigger
|
|
||||||
iconPos="right"
|
|
||||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
|
||||||
>
|
|
||||||
Closed Requests
|
|
||||||
</DropdownSubMenuTrigger>
|
|
||||||
<DropdownSubMenuContent className="max-h-80 thin-scrollbar overflow-y-auto rounded-l-none">
|
|
||||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
|
||||||
Filter by Status
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
setFilter(ApprovalRequestStatus.Approved);
|
|
||||||
}}
|
|
||||||
icon={
|
|
||||||
filter === ApprovalRequestStatus.Approved && (
|
|
||||||
<FontAwesomeIcon icon={faCheckCircle} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
iconPos="right"
|
|
||||||
>
|
|
||||||
Approved
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
setFilter(ApprovalRequestStatus.Rejected);
|
|
||||||
}}
|
|
||||||
icon={
|
|
||||||
filter === ApprovalRequestStatus.Rejected && (
|
|
||||||
<FontAwesomeIcon icon={faCheckCircle} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
iconPos="right"
|
|
||||||
>
|
|
||||||
Rejected
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
setFilter(ApprovalRequestStatus.Expired);
|
|
||||||
}}
|
|
||||||
icon={
|
|
||||||
filter === ApprovalRequestStatus.Expired && (
|
|
||||||
<FontAwesomeIcon icon={faCheckCircle} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
iconPos="right"
|
|
||||||
>
|
|
||||||
Expired
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownSubMenuContent>
|
|
||||||
</DropdownSubMenu>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Input
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
|
||||||
placeholder="Search approval requests..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<TableContainer className="mt-4">
|
|
||||||
<Table>
|
|
||||||
<THead>
|
|
||||||
<Tr>
|
|
||||||
<Th>Requester</Th>
|
|
||||||
<Th>Account Path</Th>
|
|
||||||
<Th>Duration</Th>
|
|
||||||
<Th>Status</Th>
|
|
||||||
<Th>Requested</Th>
|
|
||||||
</Tr>
|
|
||||||
</THead>
|
|
||||||
<TBody>
|
|
||||||
{isRequestsLoading && <TableSkeleton columns={5} innerKey="approval-requests" />}
|
|
||||||
{!isRequestsLoading &&
|
|
||||||
paginatedRequests.map((request) => {
|
|
||||||
const needsApproval = checkIfUserNeedsToApprove(request, userId, userGroups);
|
|
||||||
const { accountPath, accessDuration } = request.requestData.requestData;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tr
|
|
||||||
key={request.id}
|
|
||||||
className={twMerge(
|
|
||||||
"cursor-pointer transition-colors hover:bg-mineshaft-700",
|
|
||||||
needsApproval && "bg-primary/5 hover:bg-primary/10"
|
|
||||||
)}
|
|
||||||
onClick={() => handleRowClick(request.id)}
|
|
||||||
>
|
|
||||||
<Td>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-mineshaft-100">
|
|
||||||
{request.requesterName || "Unknown"}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-mineshaft-400">{request.requesterEmail}</div>
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-mineshaft-200">{accountPath}</div>
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<span className="text-sm text-mineshaft-200">{accessDuration}</span>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge className="capitalize" variant={getStatusBadgeColor(request.status)}>
|
|
||||||
{request.status.split("-").join(" ")}
|
|
||||||
</Badge>
|
|
||||||
{needsApproval && (
|
|
||||||
<div className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-xs text-primary">
|
|
||||||
<FontAwesomeIcon icon={faExclamationCircle} className="h-3 w-3" />
|
|
||||||
<span>Approval Required</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<span className="text-sm text-mineshaft-400">
|
|
||||||
{formatDistance(new Date(request.createdAt), new Date(), {
|
|
||||||
addSuffix: true
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TBody>
|
|
||||||
</Table>
|
|
||||||
{Boolean(filteredRequests.length) && (
|
|
||||||
<Pagination
|
|
||||||
count={filteredRequests.length}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
onChangePage={setPage}
|
|
||||||
onChangePerPage={setPerPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!isRequestsLoading && !filteredRequests?.length && (
|
|
||||||
<EmptyState
|
|
||||||
title="No approval requests found"
|
|
||||||
icon={requests.length ? faSearch : faFileCircleQuestion}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
export { RequestsSection } from "./RequestsSection";
|
|
||||||
export { RequestsTable } from "./RequestsTable";
|
|
||||||
@@ -1,5 +1,65 @@
|
|||||||
import { PolicySection } from "./components";
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||||
|
import { useProject } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { ApprovalPolicyType, useDeleteApprovalPolicy } from "@app/hooks/api/approvalPolicies";
|
||||||
|
|
||||||
|
import { PoliciesTable } from "./components/PoliciesTable";
|
||||||
|
import { PolicyModal } from "./components/PolicyModal";
|
||||||
|
|
||||||
export const PolicyTab = () => {
|
export const PolicyTab = () => {
|
||||||
return <PolicySection />;
|
const { currentProject } = useProject();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteApprovalPolicy } = useDeleteApprovalPolicy();
|
||||||
|
|
||||||
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
|
"policy",
|
||||||
|
"deletePolicy"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const handleDeletePolicy = async () => {
|
||||||
|
const policyId = (popUp?.deletePolicy?.data as { policyId: string })?.policyId;
|
||||||
|
if (!currentProject?.id) return;
|
||||||
|
if (!policyId) return;
|
||||||
|
|
||||||
|
await deleteApprovalPolicy({
|
||||||
|
policyType: ApprovalPolicyType.PamAccess,
|
||||||
|
policyId
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully deleted policy",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
handlePopUpClose("deletePolicy");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<p className="text-xl font-medium text-mineshaft-100">Approval Policies</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => handlePopUpOpen("policy")}
|
||||||
|
>
|
||||||
|
Create Policy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<PoliciesTable handlePopUpOpen={handlePopUpOpen} />
|
||||||
|
<PolicyModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deletePolicy.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title="Are you sure you want to delete this policy?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
||||||
|
onDeleteApproved={handleDeletePolicy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { User, Users } from "lucide-react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v3";
|
||||||
import { useProject } from "@app/context";
|
import { useProject } from "@app/context";
|
||||||
import { getMemberLabel } from "@app/helpers/members";
|
import { getMemberLabel } from "@app/helpers/members";
|
||||||
import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api";
|
import { useGetWorkspaceUsers, useListWorkspaceGroups } from "@app/hooks/api";
|
||||||
@@ -37,8 +39,6 @@ import {
|
|||||||
ApproverType
|
ApproverType
|
||||||
} from "@app/hooks/api/approvalPolicies";
|
} from "@app/hooks/api/approvalPolicies";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
import { Badge } from "@app/components/v3";
|
|
||||||
import { User, Users } from "lucide-react";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handlePopUpOpen: (
|
handlePopUpOpen: (
|
||||||
|
|||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
|
||||||
import { useProject } from "@app/context";
|
|
||||||
import { usePopUp } from "@app/hooks";
|
|
||||||
import { ApprovalPolicyType, useDeleteApprovalPolicy } from "@app/hooks/api/approvalPolicies";
|
|
||||||
|
|
||||||
import { PoliciesTable } from "./PoliciesTable";
|
|
||||||
import { PolicyModal } from "./PolicyModal";
|
|
||||||
|
|
||||||
export const PolicySection = () => {
|
|
||||||
const { currentProject } = useProject();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteApprovalPolicy } = useDeleteApprovalPolicy();
|
|
||||||
|
|
||||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
|
||||||
"policy",
|
|
||||||
"deletePolicy"
|
|
||||||
] as const);
|
|
||||||
|
|
||||||
const handleDeletePolicy = async () => {
|
|
||||||
const policyId = (popUp?.deletePolicy?.data as { policyId: string })?.policyId;
|
|
||||||
if (!currentProject?.id) return;
|
|
||||||
if (!policyId) return;
|
|
||||||
|
|
||||||
await deleteApprovalPolicy({
|
|
||||||
policyType: ApprovalPolicyType.PamAccess,
|
|
||||||
policyId
|
|
||||||
});
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully deleted policy",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
handlePopUpClose("deletePolicy");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-x-2">
|
|
||||||
<p className="text-xl font-medium text-mineshaft-100">Approval Policies</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline_bg"
|
|
||||||
type="submit"
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
|
||||||
onClick={() => handlePopUpOpen("policy")}
|
|
||||||
>
|
|
||||||
Create Policy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<PoliciesTable handlePopUpOpen={handlePopUpOpen} />
|
|
||||||
<PolicyModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
|
||||||
<DeleteActionModal
|
|
||||||
isOpen={popUp.deletePolicy.isOpen}
|
|
||||||
deleteKey="delete"
|
|
||||||
title="Are you sure you want to delete this policy?"
|
|
||||||
onChange={(isOpen) => handlePopUpToggle("deletePolicy", isOpen)}
|
|
||||||
onDeleteApproved={handleDeletePolicy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
export { PoliciesTable } from "./PoliciesTable";
|
export { PoliciesTable } from "./PoliciesTable";
|
||||||
export { PolicyModal } from "./PolicyModal";
|
export { PolicyModal } from "./PolicyModal";
|
||||||
export { PolicySection } from "./PolicySection";
|
|
||||||
|
|||||||
Reference in New Issue
Block a user