mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: added grant management screen and cancel for request
This commit is contained in:
@@ -2,13 +2,21 @@ 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, useParams } from "@tanstack/react-router";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
|
||||
import { ContentLoader, EmptyState, PageHeader } from "@app/components/v2";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
ConfirmActionModal,
|
||||
ContentLoader,
|
||||
EmptyState,
|
||||
PageHeader
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useOrganization, useProject } from "@app/context";
|
||||
import { useOrganization, useProject, useUser } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
||||
import { approvalRequestQuery } from "@app/hooks/api/approvalRequests";
|
||||
import { approvalRequestQuery, useCancelApprovalRequest } from "@app/hooks/api/approvalRequests";
|
||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
||||
|
||||
import { ApprovalStepsSection, RequestActionsSection, RequestDetailsSection } from "./components";
|
||||
@@ -19,6 +27,10 @@ const PageContent = () => {
|
||||
});
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentProject } = useProject();
|
||||
const { user: currentUser } = useUser();
|
||||
const cancelApprovalRequest = useCancelApprovalRequest();
|
||||
const navigate = useNavigate();
|
||||
const { handlePopUpOpen, handlePopUpToggle, popUp } = usePopUp(["cancelRequest"]);
|
||||
|
||||
const { data: request, isPending } = useQuery(
|
||||
approvalRequestQuery.getById({
|
||||
@@ -27,6 +39,32 @@ const PageContent = () => {
|
||||
})
|
||||
);
|
||||
|
||||
const handleRequestCancel = async () => {
|
||||
if (cancelApprovalRequest.isPending || !request) return;
|
||||
|
||||
await cancelApprovalRequest.mutateAsync(
|
||||
{
|
||||
requestId: request.id,
|
||||
policyType: ApprovalPolicyType.PamAccess
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
createNotification({
|
||||
text: "Successfully cancelled request",
|
||||
type: "success"
|
||||
});
|
||||
navigate({
|
||||
to: "/organizations/$orgId/projects/pam/$projectId/approvals",
|
||||
params: {
|
||||
orgId: currentProject.orgId,
|
||||
projectId: currentProject.id
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
@@ -62,7 +100,20 @@ const PageContent = () => {
|
||||
scope={ProjectType.PAM}
|
||||
title="Approval Request"
|
||||
description={`Request to access account ${request.requestData.requestData.accountPath} for ${request.requestData.requestData.accessDuration} by ${request.requesterName || "Unknown"}`}
|
||||
/>
|
||||
>
|
||||
<div>
|
||||
{request.requesterId === currentUser.id && (
|
||||
<Button
|
||||
onClick={() => handlePopUpOpen("cancelRequest")}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
isLoading={cancelApprovalRequest.isPending}
|
||||
>
|
||||
Cancel Request
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="flex justify-center gap-4">
|
||||
<div className="flex w-96 flex-col gap-4">
|
||||
<RequestDetailsSection request={request} />
|
||||
@@ -73,6 +124,14 @@ const PageContent = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmActionModal
|
||||
isOpen={popUp.cancelRequest.isOpen}
|
||||
confirmKey="cancel"
|
||||
title="Do you want to cancel this approval request?"
|
||||
onChange={(isOpen) => handlePopUpToggle("cancelRequest", isOpen)}
|
||||
onConfirmed={handleRequestCancel}
|
||||
buttonText="Confirm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ApprovalControlTabs } from "@app/types/project";
|
||||
|
||||
import { ApprovalRequestTab } from "./components/ApprovalRequestTab";
|
||||
import { PolicyTab } from "./components/PolicyTab";
|
||||
import { RequestGrantTab } from "./components/RequestGrantTab";
|
||||
|
||||
const Page = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -55,7 +56,7 @@ const Page = () => {
|
||||
<PolicyTab />
|
||||
</TabPanel>
|
||||
<TabPanel value={ApprovalControlTabs.Grants}>
|
||||
<div>Hello</div>
|
||||
<RequestGrantTab />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,433 @@
|
||||
export const RequestGrantTab = () => {
|
||||
return <div>Grant list</div>;
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faEllipsisV,
|
||||
faFileCircleQuestion,
|
||||
faFilter,
|
||||
faInfo,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
TextArea,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useProject } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { getUserTablePreference, PreferenceKey } from "@app/helpers/userTablePreferences";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import {
|
||||
approvalGrantQuery,
|
||||
ApprovalGrantStatus,
|
||||
useRevokeApprovalGrant
|
||||
} from "@app/hooks/api/approvalGrants";
|
||||
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
|
||||
|
||||
const revokeGrantSchema = z.object({
|
||||
revocationReason: z.string().max(256).optional()
|
||||
});
|
||||
|
||||
type TRevokeGrantForm = z.infer<typeof revokeGrantSchema>;
|
||||
|
||||
const getStatusBadgeColor = (status: ApprovalGrantStatus) => {
|
||||
switch (status) {
|
||||
case ApprovalGrantStatus.Active:
|
||||
return "success";
|
||||
case ApprovalGrantStatus.Expired:
|
||||
return "neutral";
|
||||
case ApprovalGrantStatus.Revoked:
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
};
|
||||
|
||||
export const RequestGrantTab = () => {
|
||||
const { currentProject } = useProject();
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState(ApprovalGrantStatus.Active);
|
||||
const [revokeModalOpen, setRevokeModalOpen] = useState(false);
|
||||
const [grantToRevoke, setGrantToRevoke] = useState<string | null>(null);
|
||||
const { data: members = [] } = useGetWorkspaceUsers(currentProject.id);
|
||||
|
||||
const projectId = currentProject?.id || "";
|
||||
|
||||
const { data: grants = [], isPending: isGrantsLoading } = useQuery(
|
||||
approvalGrantQuery.list({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
projectId
|
||||
})
|
||||
);
|
||||
|
||||
const { mutateAsync: revokeGrant, isPending: isRevoking } = useRevokeApprovalGrant();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors }
|
||||
} = useForm<TRevokeGrantForm>({
|
||||
resolver: zodResolver(revokeGrantSchema)
|
||||
});
|
||||
|
||||
const { page, perPage, setPage, setPerPage, offset } = usePagination("", {
|
||||
initPerPage: getUserTablePreference("pamApprovalGrants", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const filteredGrants = useMemo(() => {
|
||||
let filtered = grants;
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
filtered = filtered.filter(
|
||||
(grant) =>
|
||||
grant.attributes.accountPath.toLowerCase().includes(search.toLowerCase()) ||
|
||||
grant.id.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return filtered.filter((grant) => grant.status === filter);
|
||||
}, [grants, search, filter]);
|
||||
|
||||
const paginatedGrants = useMemo(
|
||||
() => filteredGrants.slice(offset, offset + perPage),
|
||||
[filteredGrants, offset, perPage]
|
||||
);
|
||||
|
||||
const openRevokeModal = (grantId: string) => {
|
||||
setGrantToRevoke(grantId);
|
||||
setRevokeModalOpen(true);
|
||||
};
|
||||
|
||||
const closeRevokeModal = () => {
|
||||
setRevokeModalOpen(false);
|
||||
setGrantToRevoke(null);
|
||||
reset();
|
||||
};
|
||||
|
||||
const getGrantedUser = (grantedUserId: string) => {
|
||||
const member = members?.find((m) => m.user.id === grantedUserId);
|
||||
if (member) {
|
||||
return getMemberLabel(member);
|
||||
}
|
||||
return grantedUserId;
|
||||
};
|
||||
|
||||
const handleRevokeGrant = async (data: TRevokeGrantForm) => {
|
||||
if (!grantToRevoke) return;
|
||||
|
||||
try {
|
||||
await revokeGrant({
|
||||
policyType: ApprovalPolicyType.PamAccess,
|
||||
grantId: grantToRevoke,
|
||||
revocationReason: data.revocationReason
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Grant revoked successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
closeRevokeModal();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to revoke grant",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isTableFiltered = filter !== ApprovalGrantStatus.Active;
|
||||
|
||||
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">Access Grants</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Filter Grants"
|
||||
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(ApprovalGrantStatus.Active);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalGrantStatus.Active && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Active Grants
|
||||
</DropdownMenuItem>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Inactive Grants
|
||||
</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(ApprovalGrantStatus.Expired);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalGrantStatus.Expired && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Expired
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setFilter(ApprovalGrantStatus.Revoked);
|
||||
}}
|
||||
icon={
|
||||
filter === ApprovalGrantStatus.Revoked && (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
Revoked
|
||||
</DropdownMenuItem>
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search access grants..."
|
||||
/>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>User</Th>
|
||||
<Th>Account Path</Th>
|
||||
<Th>Duration</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Granted</Th>
|
||||
<Th>Expires</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isGrantsLoading && <TableSkeleton columns={7} innerKey="access-grants" />}
|
||||
{!isGrantsLoading &&
|
||||
paginatedGrants.map((grant) => {
|
||||
const isActive = grant.status === ApprovalGrantStatus.Active;
|
||||
|
||||
return (
|
||||
<Tr key={grant.id} className="group">
|
||||
<Td>
|
||||
{grant.granteeUserId ? getGrantedUser(grant.granteeUserId) : "Unknown"}
|
||||
</Td>
|
||||
<Td>
|
||||
<div>{grant.attributes.accountPath}</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-200">
|
||||
{grant.attributes.accessDuration}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge
|
||||
variant={
|
||||
isActive
|
||||
? getStatusBadgeColor(ApprovalGrantStatus.Active)
|
||||
: getStatusBadgeColor(grant.status)
|
||||
}
|
||||
className="capitalize"
|
||||
>
|
||||
{isActive ? "Active" : grant.status}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-sm text-mineshaft-400">
|
||||
{formatDistance(new Date(grant.createdAt), new Date(), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
{grant.expiresAt ? (
|
||||
<span className="text-sm text-mineshaft-400">
|
||||
{formatDistance(new Date(grant.expiresAt), new Date(), {
|
||||
addSuffix: true
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-mineshaft-500">Never</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{isActive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<Tooltip content="More options">
|
||||
<IconButton
|
||||
ariaLabel="More options"
|
||||
variant="plain"
|
||||
className="w-4 p-0"
|
||||
size="md"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisV} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-1" sideOffset={5}>
|
||||
<Link
|
||||
to="/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId"
|
||||
params={{
|
||||
orgId: currentProject.orgId,
|
||||
projectId: currentProject.id,
|
||||
approvalRequestId: grant.requestId || ""
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem icon={<FontAwesomeIcon icon={faInfo} />}>
|
||||
Request Details
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openRevokeModal(grant.id);
|
||||
}}
|
||||
icon={<FontAwesomeIcon icon={faXmark} />}
|
||||
>
|
||||
Revoke Grant
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGrants.length) && (
|
||||
<Pagination
|
||||
count={filteredGrants.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isGrantsLoading && !filteredGrants?.length && (
|
||||
<EmptyState
|
||||
title={grants.length ? "No access grants match search..." : "No access grants found"}
|
||||
icon={grants.length ? faSearch : faFileCircleQuestion}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
<Modal isOpen={revokeModalOpen} onOpenChange={(open) => !open && closeRevokeModal()}>
|
||||
<ModalContent
|
||||
title="Revoke Access Grant"
|
||||
subTitle="Are you sure you want to revoke this access grant? This action cannot be undone."
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleRevokeGrant)}>
|
||||
<FormControl
|
||||
label="Revocation Reason (Optional)"
|
||||
isError={Boolean(errors.revocationReason)}
|
||||
errorText={errors.revocationReason?.message}
|
||||
>
|
||||
<TextArea
|
||||
{...register("revocationReason")}
|
||||
placeholder="Provide a reason for revoking this grant..."
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="mt-6 flex items-center space-x-4">
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
type="submit"
|
||||
isLoading={isRevoking}
|
||||
isDisabled={isRevoking}
|
||||
>
|
||||
Revoke Grant
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={closeRevokeModal} isDisabled={isRevoking}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { RequestGrantTab } from "./RequestGrantTab";
|
||||
Reference in New Issue
Block a user